diff --git a/node-launchpad/.config/config.json5 b/node-launchpad/.config/config.json5 index 63786942ce..3cd8da331a 100644 --- a/node-launchpad/.config/config.json5 +++ b/node-launchpad/.config/config.json5 @@ -8,8 +8,8 @@ "": {"SwitchScene":"Help"}, "": {"SwitchScene":"Help"}, - "": {"StatusActions":"StartNodes"}, - "": {"StatusActions":"StartNodes"}, + "": {"StatusActions":"StartStopNode"}, + "": {"StatusActions":"StartStopNode"}, "": {"StatusActions":"StartNodes"}, "": {"StatusActions":"StopNodes"}, "": {"StatusActions":"StopNodes"}, @@ -19,6 +19,8 @@ "": {"StatusActions":"TriggerRewardsAddress"}, "": {"StatusActions":"TriggerNodeLogs"}, "": {"StatusActions":"TriggerNodeLogs"}, + "<+>": {"StatusActions":"AddNode"}, + "<->": {"StatusActions":"TriggerRemoveNode"}, "up" : {"StatusActions":"PreviousTableItem"}, "down": {"StatusActions":"NextTableItem"}, diff --git a/node-launchpad/src/action.rs b/node-launchpad/src/action.rs index 5f4669a4d7..c8bbf9f5c8 100644 --- a/node-launchpad/src/action.rs +++ b/node-launchpad/src/action.rs @@ -43,25 +43,65 @@ pub enum Action { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)] pub enum StatusActions { + AddNode, StartNodes, StopNodes, - StartNodesCompleted, - StopNodesCompleted, - ResetNodesCompleted { trigger_start_node: bool }, + RemoveNodes, + StartStopNode, + StartNodesCompleted { + service_name: String, + }, + StopNodesCompleted { + service_name: String, + }, + ResetNodesCompleted { + trigger_start_node: bool, + }, + RemoveNodesCompleted { + service_name: String, + }, + AddNodesCompleted { + service_name: String, + }, UpdateNodesCompleted, SuccessfullyDetectedNatStatus, ErrorWhileRunningNatDetection, - ErrorLoadingNodeRegistry { raw_error: String }, - ErrorGettingNodeRegistryPath { raw_error: String }, - ErrorScalingUpNodes { raw_error: String }, - ErrorStoppingNodes { raw_error: String }, - ErrorResettingNodes { raw_error: String }, - ErrorUpdatingNodes { raw_error: String }, + ErrorLoadingNodeRegistry { + raw_error: String, + }, + ErrorGettingNodeRegistryPath { + raw_error: String, + }, + ErrorScalingUpNodes { + raw_error: String, + }, + ErrorResettingNodes { + raw_error: String, + }, + ErrorUpdatingNodes { + raw_error: String, + }, + ErrorAddingNodes { + raw_error: String, + }, + ErrorStartingNodes { + services: Vec, + raw_error: String, + }, + ErrorStoppingNodes { + services: Vec, + raw_error: String, + }, + ErrorRemovingNodes { + services: Vec, + raw_error: String, + }, NodesStatsObtained(NodeStats), TriggerManageNodes, TriggerRewardsAddress, TriggerNodeLogs, + TriggerRemoveNode, PreviousTableItem, NextTableItem, diff --git a/node-launchpad/src/app.rs b/node-launchpad/src/app.rs index dac3f1e4a3..e7850afcc5 100644 --- a/node-launchpad/src/app.rs +++ b/node-launchpad/src/app.rs @@ -15,8 +15,9 @@ use crate::{ options::Options, popup::{ change_drive::ChangeDrivePopup, connection_mode::ChangeConnectionModePopUp, - manage_nodes::ManageNodes, port_range::PortRangePopUp, reset_nodes::ResetNodesPopup, - rewards_address::RewardsAddress, upgrade_nodes::UpgradeNodesPopUp, + manage_nodes::ManageNodes, port_range::PortRangePopUp, remove_node::RemoveNodePopUp, + reset_nodes::ResetNodesPopup, rewards_address::RewardsAddress, + upgrade_nodes::UpgradeNodesPopUp, }, status::{Status, StatusConfig}, Component, @@ -98,6 +99,7 @@ impl App { connection_mode, port_from: Some(port_from), port_to: Some(port_to), + storage_mountpoint: storage_mountpoint.clone(), }; let status = Status::new(status_config).await?; @@ -120,7 +122,8 @@ 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::new(app_data.nodes_to_start); + let upgrade_nodes = UpgradeNodesPopUp::new(); + let remove_node = RemoveNodePopUp::default(); Ok(Self { config, @@ -148,6 +151,7 @@ impl App { Box::new(reset_nodes), Box::new(manage_nodes), Box::new(upgrade_nodes), + Box::new(remove_node), ], should_quit: false, should_suspend: false, diff --git a/node-launchpad/src/components/footer.rs b/node-launchpad/src/components/footer.rs index ace7bfb897..351ad108ae 100644 --- a/node-launchpad/src/components/footer.rs +++ b/node-launchpad/src/components/footer.rs @@ -10,9 +10,10 @@ use crate::style::{COOL_GREY, EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE}; use ratatui::{prelude::*, widgets::*}; pub enum NodesToStart { - Configured, - NotConfigured, Running, + NotRunning, + RunningSelected, + NotRunningSelected, } #[derive(Default)] @@ -22,50 +23,99 @@ impl StatefulWidget for Footer { type State = NodesToStart; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let (text_style, command_style) = if matches!(state, NodesToStart::Configured) { - ( - Style::default().fg(EUCALYPTUS), - Style::default().fg(GHOST_WHITE), - ) - } else { - ( - Style::default().fg(COOL_GREY), - Style::default().fg(LIGHT_PERIWINKLE), - ) - }; + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Length(3)]) + .split(area); + + let command_enabled = Style::default().fg(GHOST_WHITE); + let text_enabled = Style::default().fg(EUCALYPTUS); + let command_disabled = Style::default().fg(LIGHT_PERIWINKLE); + let text_disabled = Style::default().fg(COOL_GREY); + + let mut remove_command_style = command_disabled; + let mut remove_text_style = text_disabled; + let mut start_stop_command_style = command_disabled; + let mut start_stop_text_style = text_disabled; + let mut open_logs_command_style = command_disabled; + let mut open_logs_text_style = text_disabled; + let mut stop_all_command_style = command_disabled; + let mut stop_all_text_style = text_disabled; + + match state { + NodesToStart::Running => { + stop_all_command_style = command_enabled; + stop_all_text_style = text_enabled; + } + NodesToStart::RunningSelected => { + remove_command_style = command_enabled; + remove_text_style = text_enabled; + start_stop_command_style = command_enabled; + start_stop_text_style = text_enabled; + open_logs_command_style = command_enabled; + open_logs_text_style = text_enabled; + stop_all_command_style = command_enabled; + stop_all_text_style = text_enabled; + } + NodesToStart::NotRunning => {} + NodesToStart::NotRunningSelected => { + remove_command_style = command_enabled; + remove_text_style = text_enabled; + start_stop_command_style = command_enabled; + start_stop_text_style = text_enabled; + open_logs_command_style = command_enabled; + open_logs_text_style = text_enabled; + } + } let commands = vec![ - Span::styled("[Ctrl+G] ", Style::default().fg(GHOST_WHITE)), - Span::styled("Manage Nodes", Style::default().fg(EUCALYPTUS)), + Span::styled("[+] ", command_enabled), + Span::styled("Add", text_enabled), Span::styled(" ", Style::default()), - Span::styled("[Ctrl+S] ", command_style), - Span::styled("Start Nodes", text_style), + Span::styled("[-] ", remove_command_style), + Span::styled("Remove", remove_text_style), Span::styled(" ", Style::default()), - Span::styled("[L] ", command_style), - Span::styled("Open Logs", Style::default().fg(EUCALYPTUS)), + Span::styled("[Ctrl+S] ", start_stop_command_style), + Span::styled("Start/Stop Node", start_stop_text_style), Span::styled(" ", Style::default()), - Span::styled("[Ctrl+X] ", command_style), - Span::styled( - "Stop All", - if matches!(state, NodesToStart::Running) { - Style::default().fg(EUCALYPTUS) - } else { - Style::default().fg(COOL_GREY) - }, - ), + Span::styled("[L] ", open_logs_command_style), + Span::styled("Open Logs", open_logs_text_style), ]; + let stop_all = vec![ + Span::styled("[Ctrl+X] ", stop_all_command_style), + Span::styled("Stop All", stop_all_text_style), + ]; + + let total_width = (layout[0].width - 1) as usize; + let spaces = " ".repeat(total_width.saturating_sub( + commands.iter().map(|s| s.width()).sum::() + + stop_all.iter().map(|s| s.width()).sum::(), + )); + + let commands_length = 6 + commands.iter().map(|s| s.width()).sum::() as u16; + let spaces_length = spaces.len().saturating_sub(6) as u16; + let stop_all_length = stop_all.iter().map(|s| s.width()).sum::() as u16; + let cell1 = Cell::from(Line::from(commands)); - let row = Row::new(vec![cell1]); + let cell2 = Cell::from(Line::raw(spaces)); + let cell3 = Cell::from(Line::from(stop_all)); + let row = Row::new(vec![cell1, cell2, cell3]); - let table = Table::new(vec![row], vec![Constraint::Max(1)]) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(EUCALYPTUS)) - .padding(Padding::horizontal(1)), - ) - .widths(vec![Constraint::Fill(1)]); + let table = Table::new( + [row], + [ + Constraint::Length(commands_length), + Constraint::Length(spaces_length), + Constraint::Length(stop_all_length), + ], + ) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(EUCALYPTUS)) + .padding(Padding::horizontal(1)), + ); StatefulWidget::render(table, area, buf, &mut TableState::default()); } diff --git a/node-launchpad/src/components/options.rs b/node-launchpad/src/components/options.rs index 7916efcb06..52392ca22b 100644 --- a/node-launchpad/src/components/options.rs +++ b/node-launchpad/src/components/options.rs @@ -68,7 +68,7 @@ impl Component for Options { .constraints( [ Constraint::Length(1), - Constraint::Length(7), + Constraint::Length(5), Constraint::Length(3), Constraint::Length(3), Constraint::Length(4), @@ -93,7 +93,6 @@ impl Component for Options { .border_style(Style::default().fg(VERY_LIGHT_AZURE)); let storage_drivename = Table::new( vec![ - Row::new(vec![Line::from(vec![])]), Row::new(vec![ Cell::from( Line::from(vec![Span::styled( @@ -177,7 +176,6 @@ impl Component for Options { .alignment(Alignment::Right), ), ]), - Row::new(vec![Line::from(vec![])]), ], &[ Constraint::Length(18), diff --git a/node-launchpad/src/components/popup.rs b/node-launchpad/src/components/popup.rs index 964dbe8a8d..64980c2487 100644 --- a/node-launchpad/src/components/popup.rs +++ b/node-launchpad/src/components/popup.rs @@ -10,6 +10,7 @@ pub mod change_drive; pub mod connection_mode; pub mod manage_nodes; pub mod port_range; +pub mod remove_node; pub mod reset_nodes; pub mod rewards_address; pub mod upgrade_nodes; diff --git a/node-launchpad/src/components/popup/manage_nodes.rs b/node-launchpad/src/components/popup/manage_nodes.rs index 2ad7674730..9aa7f1d73b 100644 --- a/node-launchpad/src/components/popup/manage_nodes.rs +++ b/node-launchpad/src/components/popup/manage_nodes.rs @@ -165,7 +165,11 @@ impl Component for ManageNodes { fn update(&mut self, action: Action) -> Result> { let send_back = match action { Action::SwitchScene(scene) => match scene { - Scene::ManageNodesPopUp => { + Scene::ManageNodesPopUp { amount_of_nodes } => { + self.nodes_to_start_input = self + .nodes_to_start_input + .clone() + .with_value(amount_of_nodes.to_string()); self.active = true; self.old_value = self.nodes_to_start_input.value().to_string(); // set to entry input mode as we want to handle everything within our handle_key_events diff --git a/node-launchpad/src/components/popup/remove_node.rs b/node-launchpad/src/components/popup/remove_node.rs new file mode 100644 index 0000000000..0507d0426c --- /dev/null +++ b/node-launchpad/src/components/popup/remove_node.rs @@ -0,0 +1,157 @@ +// Copyright 2024 MaidSafe.net limited. +// +// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3. +// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed +// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// 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 super::super::utils::centered_rect_fixed; +use super::super::Component; +use crate::{ + action::{Action, StatusActions}, + mode::{InputMode, Scene}, + style::{clear_area, EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE, VIVID_SKY_BLUE}, +}; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{prelude::*, widgets::*}; + +#[derive(Default)] +pub struct RemoveNodePopUp { + /// Whether the component is active right now, capturing keystrokes + draw things. + active: bool, +} + +impl Component for RemoveNodePopUp { + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + if !self.active { + return Ok(vec![]); + } + // while in entry mode, keybinds are not captured, so gotta exit entry mode from here + let send_back = match key.code { + KeyCode::Enter => { + debug!("Got Enter, Removing node..."); + vec![ + Action::StatusActions(StatusActions::RemoveNodes), + Action::SwitchScene(Scene::Status), + ] + } + KeyCode::Esc => { + debug!("Got Esc, Not removing node."); + vec![Action::SwitchScene(Scene::Status)] + } + _ => vec![], + }; + Ok(send_back) + } + + fn update(&mut self, action: Action) -> Result> { + let send_back = match action { + Action::SwitchScene(scene) => match scene { + Scene::RemoveNodePopUp => { + self.active = true; + Some(Action::SwitchInputMode(InputMode::Entry)) + } + _ => { + self.active = false; + None + } + }, + _ => None, + }; + Ok(send_back) + } + + fn draw(&mut self, f: &mut crate::tui::Frame<'_>, area: Rect) -> Result<()> { + if !self.active { + return Ok(()); + } + + let layer_zero = centered_rect_fixed(52, 15, area); + + let layer_one = Layout::new( + Direction::Vertical, + [ + // for the pop_up_border + Constraint::Length(2), + // for the input field + Constraint::Min(1), + // for the pop_up_border + Constraint::Length(1), + ], + ) + .split(layer_zero); + + // layer zero + let pop_up_border = Paragraph::new("").block( + Block::default() + .borders(Borders::ALL) + .title(" Remove Node ") + .bold() + .title_style(Style::new().fg(VIVID_SKY_BLUE)) + .padding(Padding::uniform(2)) + .border_style(Style::new().fg(VIVID_SKY_BLUE)), + ); + clear_area(f, layer_zero); + + // split the area into 3 parts, for the lines, hypertext, buttons + let layer_two = Layout::new( + Direction::Vertical, + [ + // for the text + Constraint::Length(9), + // gap + Constraint::Length(4), + // for the buttons + Constraint::Length(1), + ], + ) + .split(layer_one[1]); + + let text = Paragraph::new(vec![ + Line::from(Span::styled("\n\n", Style::default())), + Line::from(Span::styled("\n\n", Style::default())), + Line::from(vec![Span::styled( + "Removing this node will stop it, and delete all its data.", + Style::default().fg(LIGHT_PERIWINKLE), + )]), + Line::from(Span::styled("\n\n", Style::default())), + Line::from(Span::styled( + "Press Enter to confirm.", + Style::default().fg(LIGHT_PERIWINKLE), + )), + Line::from(Span::styled("\n\n", Style::default())), + ]) + .block(Block::default().padding(Padding::horizontal(2))) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + f.render_widget(text, layer_two[0]); + + let dash = Block::new() + .borders(Borders::BOTTOM) + .border_style(Style::new().fg(GHOST_WHITE)); + f.render_widget(dash, layer_two[1]); + + let buttons_layer = + Layout::horizontal(vec![Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(layer_two[2]); + + let button_no = Line::from(vec![Span::styled( + " No, Cancel [Esc]", + Style::default().fg(LIGHT_PERIWINKLE), + )]); + f.render_widget(button_no, buttons_layer[0]); + + let button_yes = Paragraph::new(Line::from(vec![Span::styled( + "Yes, Remove Node [Enter] ", + Style::default().fg(EUCALYPTUS), + )])) + .alignment(Alignment::Right); + f.render_widget(button_yes, buttons_layer[1]); + f.render_widget(pop_up_border, layer_zero); + + Ok(()) + } +} diff --git a/node-launchpad/src/components/popup/upgrade_nodes.rs b/node-launchpad/src/components/popup/upgrade_nodes.rs index 3fcddc5839..f5004cf97b 100644 --- a/node-launchpad/src/components/popup/upgrade_nodes.rs +++ b/node-launchpad/src/components/popup/upgrade_nodes.rs @@ -10,8 +10,8 @@ use super::super::utils::centered_rect_fixed; use super::super::Component; use crate::{ action::{Action, OptionsActions}, - components::status, mode::{InputMode, Scene}, + node_mgmt, style::{clear_area, EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE, VIVID_SKY_BLUE}, }; use color_eyre::Result; @@ -19,17 +19,19 @@ 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(nodes_to_start: usize) -> Self { - Self { - nodes_to_start, - active: false, - } + pub fn new() -> Self { + Self { active: false } + } +} + +impl Default for UpgradeNodesPopUp { + fn default() -> Self { + Self::new() } } @@ -68,10 +70,6 @@ impl Component for UpgradeNodesPopUp { None } }, - Action::StoreNodesToStart(ref nodes_to_start) => { - self.nodes_to_start = *nodes_to_start; - None - } _ => None, }; Ok(send_back) @@ -114,9 +112,9 @@ impl Component for UpgradeNodesPopUp { Direction::Vertical, [ // for the text - Constraint::Length(9), + Constraint::Length(10), // gap - Constraint::Length(4), + Constraint::Length(3), // for the buttons Constraint::Length(1), ], @@ -138,13 +136,15 @@ impl Component for UpgradeNodesPopUp { )), 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, + "Upgrade time is {:.1?} seconds per node", + node_mgmt::FIXED_INTERVAL / 1_000, ), Style::default().fg(LIGHT_PERIWINKLE), )), + Line::from(Span::styled( + "plus, new binary download time.", + 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 8f1ac95425..0b67411bb3 100644 --- a/node-launchpad/src/components/status.rs +++ b/node-launchpad/src/components/status.rs @@ -8,28 +8,29 @@ use super::footer::NodesToStart; use super::header::SelectedMenuItem; -use super::{ - footer::Footer, header::Header, popup::manage_nodes::GB_PER_NODE, utils::centered_rect_fixed, - Component, Frame, -}; +use super::popup::manage_nodes::GB; +use super::utils::centered_rect_fixed; +use super::{footer::Footer, header::Header, popup::manage_nodes::GB_PER_NODE, Component, Frame}; use crate::action::OptionsActions; +use crate::components::popup::manage_nodes::MAX_NODE_COUNT; 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::{MaintainNodesArgs, NodeManagement, NodeManagementTask, UpgradeNodesArgs}; +use crate::node_mgmt::{ + MaintainNodesArgs, NodeManagement, NodeManagementTask, UpgradeNodesArgs, NODES_ALL, +}; use crate::node_mgmt::{PORT_MAX, PORT_MIN}; -use crate::style::{COOL_GREY, INDIGO}; +use crate::style::{clear_area, COOL_GREY, INDIGO}; +use crate::system::{get_available_space_b, get_drive_name}; use crate::tui::Event; use crate::{ action::{Action, StatusActions}, config::Config, mode::{InputMode, Scene}, node_stats::NodeStats, - style::{ - clear_area, EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE, VERY_LIGHT_AZURE, VIVID_SKY_BLUE, - }, + style::{EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE, VERY_LIGHT_AZURE, VIVID_SKY_BLUE}, }; use color_eyre::eyre::{Ok, OptionExt, Result}; use crossterm::event::KeyEvent; @@ -47,11 +48,9 @@ use std::{ time::{Duration, Instant}, vec, }; -use strum::Display; 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; @@ -89,9 +88,6 @@ pub struct Status<'a> { nodes_to_start: usize, // Rewards address rewards_address: String, - // Currently the node registry file does not support concurrent actions and thus can lead to - // inconsistent state. Another solution would be to have a file lock/db. - lock_registry: Option, // Peers to pass into nodes for startup peers_args: PeersArgs, // If path is provided, we don't fetch the binary from the network @@ -104,17 +100,11 @@ pub struct Status<'a> { port_from: Option, // Port to port_to: Option, + storage_mountpoint: PathBuf, + available_disk_space_gb: usize, error_popup: Option, } -#[derive(Clone, Display, Debug)] -pub enum LockRegistryState { - StartingNodes, - StoppingNodes, - ResettingNodes, - UpdatingNodes, -} - pub struct StatusConfig { pub allocated_disk_space: usize, pub rewards_address: String, @@ -124,6 +114,7 @@ pub struct StatusConfig { pub connection_mode: ConnectionMode, pub port_from: Option, pub port_to: Option, + pub storage_mountpoint: PathBuf, } impl Status<'_> { @@ -141,7 +132,6 @@ impl Status<'_> { node_management: NodeManagement::new()?, items: None, nodes_to_start: config.allocated_disk_space, - lock_registry: None, rewards_address: config.rewards_address, safenode_path: config.safenode_path, data_dir_path: config.data_dir_path, @@ -149,6 +139,8 @@ impl Status<'_> { port_from: config.port_from, port_to: config.port_to, error_popup: None, + storage_mountpoint: config.storage_mountpoint.clone(), + available_disk_space_gb: get_available_space_b(&config.storage_mountpoint)? / GB, }; // Nodes registry @@ -170,6 +162,42 @@ impl Status<'_> { Ok(status) } + fn set_lock(&mut self, service_name: &str, locked: bool) { + if let Some(ref mut items) = self.items { + for item in &mut items.items { + if item.name == *service_name { + item.locked = locked; + } + } + } + } + + // FIXME: Can be used if NodeItem implements Copy. Dependencies cannot. + fn _lock_service(&mut self, service_name: &str) { + self.set_lock(service_name, true); + } + + fn unlock_service(&mut self, service_name: &str) { + self.set_lock(service_name, false); + } + + /// Updates the NodeStatus of a specific item in the items list based on its service name. + /// + /// # Arguments + /// + /// * `service_name` - The name of the service to update. + /// * `status` - The new status to assign to the item. + fn update_item(&mut self, service_name: String, status: NodeStatus) -> Result<()> { + if let Some(ref mut items) = self.items { + for item in &mut items.items { + if item.name == service_name { + item.status = status; + } + } + } + Ok(()) + } + fn update_node_items(&mut self, new_status: Option) -> Result<()> { // Iterate over existing node services and update their corresponding NodeItem if let Some(ref mut items) = self.items { @@ -182,7 +210,9 @@ impl Status<'_> { { if let Some(status) = new_status { item.status = status; - } else if item.status == NodeStatus::Updating { + } else if item.status == NodeStatus::Updating + || item.status == NodeStatus::Starting + { item.spinner_state.calc_next(); } else if new_status != Some(NodeStatus::Updating) { // Update status based on current node status @@ -195,14 +225,6 @@ impl Status<'_> { ServiceStatus::Added => NodeStatus::Added, ServiceStatus::Removed => NodeStatus::Removed, }; - - // Starting is not part of ServiceStatus so we do it manually - if let Some(LockRegistryState::StartingNodes) = self.lock_registry { - item.spinner_state.calc_next(); - if item.status != NodeStatus::Running { - item.status = NodeStatus::Starting; - } - } } // Update peers count @@ -239,6 +261,7 @@ impl Status<'_> { records: 0, peers: 0, connections: 0, + locked: false, status: NodeStatus::Added, // Set initial status as Added spinner: Throbber::default(), spinner_state: ThrobberState::default(), @@ -273,6 +296,7 @@ impl Status<'_> { records: 0, peers: 0, connections: 0, + locked: false, status, spinner: Throbber::default(), spinner_state: ThrobberState::default(), @@ -331,6 +355,17 @@ impl Status<'_> { && self.error_while_running_nat_detection < MAX_ERRORS_WHILE_RUNNING_NAT_DETECTION } + fn nodes_starting(&self) -> bool { + if let Some(items) = &self.items { + items + .items + .iter() + .any(|item| item.status == NodeStatus::Starting) + } else { + false + } + } + fn get_running_nodes(&self) -> Vec { self.node_services .iter() @@ -390,12 +425,12 @@ impl Component for Status<'_> { let _ = self.update_node_items(None); } Action::SwitchScene(scene) => match scene { - Scene::Status | Scene::StatusRewardsAddressPopUp => { + Scene::Status | Scene::StatusRewardsAddressPopUp | Scene::RemoveNodePopUp => { self.active = true; // make sure we're in navigation mode return Ok(Some(Action::SwitchInputMode(InputMode::Navigation))); } - Scene::ManageNodesPopUp => self.active = true, + Scene::ManageNodesPopUp { .. } => self.active = true, _ => self.active = false, }, Action::StoreNodesToStart(count) => { @@ -416,8 +451,6 @@ impl Component for Status<'_> { self.rewards_address = rewards_address; if we_have_nodes && has_changed { - debug!("Setting lock_registry to ResettingNodes"); - self.lock_registry = Some(LockRegistryState::ResettingNodes); info!("Resetting safenode services because the Rewards Address was reset."); let action_sender = self.get_actions_sender()?; self.node_management @@ -428,8 +461,6 @@ impl Component for Status<'_> { } } Action::StoreStorageDrive(ref drive_mountpoint, ref _drive_name) => { - debug!("Setting lock_registry to ResettingNodes"); - self.lock_registry = Some(LockRegistryState::ResettingNodes); info!("Resetting safenode services because the Storage Drive was changed."); let action_sender = self.get_actions_sender()?; self.node_management @@ -441,8 +472,6 @@ impl Component for Status<'_> { get_launchpad_nodes_data_dir_path(&drive_mountpoint.to_path_buf(), false)?; } Action::StoreConnectionMode(connection_mode) => { - debug!("Setting lock_registry to ResettingNodes"); - self.lock_registry = Some(LockRegistryState::ResettingNodes); self.connection_mode = connection_mode; info!("Resetting safenode services because the Connection Mode range was changed."); let action_sender = self.get_actions_sender()?; @@ -453,8 +482,6 @@ impl Component for Status<'_> { })?; } Action::StorePortRange(port_from, port_range) => { - debug!("Setting lock_registry to ResettingNodes"); - self.lock_registry = Some(LockRegistryState::ResettingNodes); self.port_from = Some(port_from); self.port_to = Some(port_range); info!("Resetting safenode services because the Port Range was changed."); @@ -469,19 +496,45 @@ impl Component for Status<'_> { StatusActions::NodesStatsObtained(stats) => { self.node_stats = stats; } - StatusActions::StartNodesCompleted | StatusActions::StopNodesCompleted => { - self.lock_registry = None; + StatusActions::StartNodesCompleted { service_name } => { + if service_name == *NODES_ALL { + if let Some(items) = &self.items { + let items_clone = items.clone(); + for item in &items_clone.items { + self.unlock_service(item.name.as_str()); + self.update_item(item.name.clone(), NodeStatus::Running)?; + } + } + } else { + self.unlock_service(service_name.as_str()); + self.update_item(service_name, NodeStatus::Running)?; + } + self.load_node_registry_and_update_states()?; + } + StatusActions::StopNodesCompleted { service_name } => { + self.unlock_service(service_name.as_str()); + self.update_item(service_name, NodeStatus::Stopped)?; self.load_node_registry_and_update_states()?; } StatusActions::UpdateNodesCompleted => { - self.lock_registry = None; + if let Some(items) = &self.items { + let items_clone = items.clone(); + for item in &items_clone.items { + self.unlock_service(item.name.as_str()); + } + } self.clear_node_items(); self.load_node_registry_and_update_states()?; let _ = self.update_node_items(None); debug!("Update nodes completed"); } StatusActions::ResetNodesCompleted { trigger_start_node } => { - self.lock_registry = None; + if let Some(items) = &self.items { + let items_clone = items.clone(); + for item in &items_clone.items { + self.unlock_service(item.name.as_str()); + } + } self.load_node_registry_and_update_states()?; self.clear_node_items(); @@ -491,6 +544,19 @@ impl Component for Status<'_> { } debug!("Reset nodes completed"); } + StatusActions::AddNodesCompleted { service_name } => { + self.unlock_service(service_name.as_str()); + self.update_item(service_name.clone(), NodeStatus::Stopped)?; + self.load_node_registry_and_update_states()?; + debug!("Adding {:?} completed", service_name.clone()); + } + StatusActions::RemoveNodesCompleted { service_name } => { + self.unlock_service(service_name.as_str()); + self.update_item(service_name, NodeStatus::Removed)?; + self.load_node_registry_and_update_states()?; + let _ = self.update_node_items(None); + debug!("Removing nodes completed"); + } StatusActions::SuccessfullyDetectedNatStatus => { debug!( "Successfully detected nat status, is_nat_status_determined set to true" @@ -529,7 +595,13 @@ impl Component for Status<'_> { // Switch back to entry mode so we can handle key events return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); } - StatusActions::ErrorStoppingNodes { raw_error } => { + StatusActions::ErrorStoppingNodes { + services, + raw_error, + } => { + for service_name in services { + self.unlock_service(service_name.as_str()); + } self.error_popup = Some(ErrorPopup::new( "Error".to_string(), "Error stopping nodes".to_string(), @@ -542,6 +614,12 @@ impl Component for Status<'_> { return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); } StatusActions::ErrorUpdatingNodes { raw_error } => { + if let Some(items) = &self.items { + let items_clone = items.clone(); + for item in &items_clone.items { + self.unlock_service(item.name.as_str()); + } + } self.error_popup = Some(ErrorPopup::new( "Error".to_string(), "Error upgrading nodes".to_string(), @@ -565,8 +643,72 @@ impl Component for Status<'_> { // Switch back to entry mode so we can handle key events return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); } + StatusActions::ErrorAddingNodes { raw_error } => { + self.error_popup = Some(ErrorPopup::new( + "Error".to_string(), + "Error adding node".to_string(), + raw_error, + )); + if let Some(error_popup) = &mut self.error_popup { + error_popup.show(); + } + // Switch back to entry mode so we can handle key events + return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); + } + StatusActions::ErrorRemovingNodes { + services, + raw_error, + } => { + for service_name in services { + self.unlock_service(service_name.as_str()); + } + self.error_popup = Some(ErrorPopup::new( + "Error".to_string(), + "Error removing node".to_string(), + raw_error, + )); + if let Some(error_popup) = &mut self.error_popup { + error_popup.show(); + } + // Switch back to entry mode so we can handle key events + return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); + } + StatusActions::ErrorStartingNodes { + services, + raw_error, + } => { + for service_name in services { + self.unlock_service(service_name.as_str()); + } + self.error_popup = Some(ErrorPopup::new( + "Error".to_string(), + "Error starting node. Please try again.".to_string(), + raw_error, + )); + if let Some(error_popup) = &mut self.error_popup { + error_popup.show(); + } + // Switch back to entry mode so we can handle key events + return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); + } StatusActions::TriggerManageNodes => { - return Ok(Some(Action::SwitchScene(Scene::ManageNodesPopUp))); + let mut amount_of_nodes = 0; + if let Some(items) = &mut self.items { + amount_of_nodes = items.items.len(); + } + + return Ok(Some(Action::SwitchScene(Scene::ManageNodesPopUp { + amount_of_nodes, + }))); + } + StatusActions::TriggerRemoveNode => { + if let Some(_node) = self.items.as_ref().and_then(|items| items.selected_item()) + { + return Ok(Some(Action::SwitchScene(Scene::RemoveNodePopUp))); + } else { + debug!("No items to be removed"); + return Ok(None); + } } StatusActions::PreviousTableItem => { if let Some(items) = &mut self.items { @@ -578,6 +720,63 @@ impl Component for Status<'_> { items.next(); } } + StatusActions::StartStopNode => { + debug!("Start/Stop node"); + + // Check if a node is selected + if let Some(node) = self.items.as_ref().and_then(|items| items.selected_item()) + { + let node_index = self + .items + .as_ref() + .unwrap() + .items + .iter() + .position(|item| item.name == node.name) + .unwrap(); + let action_sender = self.get_actions_sender()?; + let node = &mut self.items.as_mut().unwrap().items[node_index]; + + if node.status == NodeStatus::Removed { + debug!("Node is removed. Cannot be started."); + return Ok(None); + } + + if node.locked { + debug!("Node still performing operation"); + return Ok(None); + } + node.locked = true; // Lock the node before starting or stopping + + let service_name = vec![node.name.clone()]; + + match node.status { + NodeStatus::Stopped | NodeStatus::Added => { + debug!("Starting Node {:?}", node.name); + self.node_management + .send_task(NodeManagementTask::StartNode { + services: service_name, + action_sender, + })?; + node.status = NodeStatus::Starting; + } + NodeStatus::Running => { + debug!("Stopping Node {:?}", node.name); + self.node_management + .send_task(NodeManagementTask::StopNodes { + services: service_name, + action_sender, + })?; + } + _ => { + debug!("Cannot Start/Stop node. Node status is {:?}", node.status); + } + } + } else { + debug!("Got action to Start/Stop node but no node was selected."); + return Ok(None); + } + } StatusActions::StartNodes => { debug!("Got action to start nodes"); @@ -595,17 +794,18 @@ impl Component for Status<'_> { ))); } - if self.lock_registry.is_some() { - error!( - "Registry is locked ({:?}) Cannot Start nodes now.", - self.lock_registry - ); - return Ok(None); + // Set status and locking + if let Some(ref mut items) = self.items { + for item in &mut items.items { + if item.status == NodeStatus::Added + || item.status == NodeStatus::Stopped + { + item.status = NodeStatus::Starting; + item.locked = true; + } + } } - debug!("Setting lock_registry to StartingNodes"); - self.lock_registry = Some(LockRegistryState::StartingNodes); - let port_range = PortRange::Range( self.port_from.unwrap_or(PORT_MIN) as u16, self.port_to.unwrap_or(PORT_MAX) as u16, @@ -635,17 +835,8 @@ impl Component for Status<'_> { } StatusActions::StopNodes => { debug!("Got action to stop nodes"); - if self.lock_registry.is_some() { - error!( - "Registry is locked ({:?}) Cannot Stop nodes now.", - self.lock_registry - ); - return Ok(None); - } let running_nodes = self.get_running_nodes(); - debug!("Setting lock_registry to StoppingNodes"); - self.lock_registry = Some(LockRegistryState::StoppingNodes); let action_sender = self.get_actions_sender()?; info!("Stopping node service: {running_nodes:?}"); @@ -655,6 +846,129 @@ impl Component for Status<'_> { action_sender, })?; } + StatusActions::AddNode => { + debug!("Got action to Add node"); + + // Validations - Available space + if GB_PER_NODE > self.available_disk_space_gb { + self.error_popup = Some(ErrorPopup::new( + "Cannot Add Node".to_string(), + format!("\nEach Node requires {}GB of available space.", GB_PER_NODE), + format!("{} has only {}GB remaining.\n\nYou can free up some space or change to different drive in the options.", get_drive_name(&self.storage_mountpoint)?, self.available_disk_space_gb), + )); + if let Some(error_popup) = &mut self.error_popup { + error_popup.show(); + } + // Switch back to entry mode so we can handle key events + return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); + } + + // Validations - Amount of nodes + let amount_of_nodes = if let Some(ref items) = self.items { + items.items.len() + } else { + 0 + }; + + if amount_of_nodes + 1 > MAX_NODE_COUNT { + self.error_popup = Some(ErrorPopup::new( + "Cannot Add Node".to_string(), + format!( + "There are not enough ports available in your\ncustom port range to start another node ({}).", + MAX_NODE_COUNT + ), + "\nVisit autonomi.com/support/port-error for help".to_string(), + )); + if let Some(error_popup) = &mut self.error_popup { + error_popup.show(); + } + // Switch back to entry mode so we can handle key events + return Ok(Some(Action::SwitchInputMode(InputMode::Entry))); + } + + if self.rewards_address.is_empty() { + info!("Rewards address is not set. Ask for input."); + return Ok(Some(Action::StatusActions( + StatusActions::TriggerRewardsAddress, + ))); + } + + if self.nodes_to_start == 0 { + info!("Nodes to start not set. Ask for input."); + return Ok(Some(Action::StatusActions( + StatusActions::TriggerManageNodes, + ))); + } + + let port_range = PortRange::Range( + self.port_from.unwrap_or(PORT_MIN) as u16, + self.port_to.unwrap_or(PORT_MAX) as u16, + ); + + let action_sender = self.get_actions_sender()?; + + let add_node_args = MaintainNodesArgs { + count: 1, + owner: self.rewards_address.clone(), + peers_args: self.peers_args.clone(), + run_nat_detection: self.should_we_run_nat_detection(), + safenode_path: self.safenode_path.clone(), + data_dir_path: Some(self.data_dir_path.clone()), + action_sender: action_sender.clone(), + connection_mode: self.connection_mode, + port_range: Some(port_range), + rewards_address: self.rewards_address.clone(), + }; + + self.node_management + .send_task(NodeManagementTask::AddNode { + args: add_node_args, + })?; + } + StatusActions::RemoveNodes => { + debug!("Got action to remove node"); + // Check if a node is selected + if self + .items + .as_ref() + .and_then(|items| items.selected_item()) + .is_none() + { + debug!("Got action to Start/Stop node but no node was selected."); + return Ok(None); + } + + let node_index = + self.items + .as_ref() + .and_then(|items| { + items.items.iter().position(|item| { + item.name == items.selected_item().unwrap().name + }) + }) + .unwrap(); + + let action_sender = self.get_actions_sender()?; + + let node = &mut self.items.as_mut().unwrap().items[node_index]; + + if node.locked { + debug!("Node still performing operation"); + return Ok(None); + } else { + // Lock the node before starting or stopping + node.locked = true; + } + + let service_name = vec![node.name.clone()]; + + // Send the task to remove the node + self.node_management + .send_task(NodeManagementTask::RemoveNodes { + services: service_name, + action_sender, + })?; + } StatusActions::TriggerRewardsAddress => { if self.rewards_address.is_empty() { return Ok(Some(Action::SwitchScene(Scene::StatusRewardsAddressPopUp))); @@ -675,17 +989,6 @@ impl Component for Status<'_> { Action::OptionsActions(OptionsActions::UpdateNodes) => { debug!("Got action to Update Nodes"); self.load_node_registry_and_update_states()?; - if self.lock_registry.is_some() { - error!( - "Registry is locked ({:?}) Cannot Update nodes now. Stop them first.", - self.lock_registry - ); - return Ok(None); - } else { - debug!("Lock registry ({:?})", self.lock_registry); - }; - debug!("Setting lock_registry to UpdatingNodes"); - self.lock_registry = Some(LockRegistryState::UpdatingNodes); let action_sender = self.get_actions_sender()?; info!("Got action to update nodes"); let _ = self.update_node_items(Some(NodeStatus::Updating)); @@ -693,11 +996,9 @@ impl Component for Status<'_> { let upgrade_nodes_args = UpgradeNodesArgs { action_sender, - connection_timeout_s: 5, do_not_start: true, custom_bin_path: None, force: false, - fixed_interval: Some(FIXED_INTERVAL), peer_ids, provided_env_variables: None, service_names, @@ -711,16 +1012,6 @@ impl Component for Status<'_> { } Action::OptionsActions(OptionsActions::ResetNodes) => { debug!("Got action to reset nodes"); - if self.lock_registry.is_some() { - error!( - "Registry is locked ({:?}) Cannot Reset nodes now.", - self.lock_registry - ); - return Ok(None); - } - - debug!("Setting lock_registry to ResettingNodes"); - self.lock_registry = Some(LockRegistryState::ResettingNodes); let action_sender = self.get_actions_sender()?; info!("Got action to reset nodes"); self.node_management @@ -729,6 +1020,10 @@ impl Component for Status<'_> { action_sender, })?; } + Action::OptionsActions(OptionsActions::UpdateStorageDrive(mountpoint, _drive_name)) => { + self.storage_mountpoint.clone_from(&mountpoint); + self.available_disk_space_gb = get_available_space_b(&mountpoint)? / GB; + } _ => {} } Ok(None) @@ -873,9 +1168,12 @@ impl Component for Status<'_> { if items.items.is_empty() || self.rewards_address.is_empty() { let line1 = Line::from(vec![ Span::styled("Press ", Style::default().fg(LIGHT_PERIWINKLE)), - Span::styled("[Ctrl+G] ", Style::default().fg(GHOST_WHITE).bold()), + Span::styled("[+] ", Style::default().fg(GHOST_WHITE).bold()), Span::styled("to Add and ", Style::default().fg(LIGHT_PERIWINKLE)), - Span::styled("Start Nodes ", Style::default().fg(GHOST_WHITE).bold()), + Span::styled( + "Start your first node ", + Style::default().fg(GHOST_WHITE).bold(), + ), Span::styled("on this device", Style::default().fg(LIGHT_PERIWINKLE)), ]); @@ -912,7 +1210,14 @@ impl Component for Status<'_> { .title(Line::from(vec![ Span::styled(" Nodes", Style::default().fg(GHOST_WHITE).bold()), Span::styled( - format!(" ({}) ", self.nodes_to_start), + format!( + " ({}) ", + if let Some(ref items) = self.items { + items.items.len() + } else { + 0 + } + ), Style::default().fg(LIGHT_PERIWINKLE), ), ])) @@ -979,19 +1284,31 @@ impl Component for Status<'_> { // ==== Footer ===== + let selected = self + .items + .as_ref() + .and_then(|items| items.selected_item()) + .is_some(); + let footer = Footer::default(); let footer_state = if let Some(ref items) = self.items { - if !items.items.is_empty() || self.rewards_address.is_empty() { + if !items.items.is_empty() || !self.rewards_address.is_empty() { if !self.get_running_nodes().is_empty() { - &mut NodesToStart::Running + if selected { + &mut NodesToStart::RunningSelected + } else { + &mut NodesToStart::Running + } + } else if selected { + &mut NodesToStart::NotRunningSelected } else { - &mut NodesToStart::Configured + &mut NodesToStart::NotRunning } } else { - &mut NodesToStart::NotConfigured + &mut NodesToStart::NotRunning } } else { - &mut NodesToStart::NotConfigured + &mut NodesToStart::NotRunning }; f.render_stateful_widget(footer, layout[3], footer_state); @@ -1006,78 +1323,48 @@ impl Component for Status<'_> { } } - // Status Popup - if let Some(registry_state) = &self.lock_registry { - let popup_text = match registry_state { - LockRegistryState::StartingNodes => { - if self.should_we_run_nat_detection() { - vec![ - Line::raw("Starting nodes..."), - Line::raw(""), - Line::raw(""), - Line::raw("Please wait, performing initial NAT detection"), - Line::raw("This may take a couple minutes."), - ] - } else { - // We avoid rendering the popup as we have status lines now - return Ok(()); - } - } - LockRegistryState::StoppingNodes => { - vec![ - Line::raw(""), - Line::raw(""), - Line::raw(""), - Line::raw("Stopping nodes..."), - ] - } - LockRegistryState::ResettingNodes => { - vec![ - Line::raw(""), - Line::raw(""), - Line::raw(""), - Line::raw("Resetting nodes..."), - ] - } - LockRegistryState::UpdatingNodes => { - return Ok(()); - } - }; - if !popup_text.is_empty() { - let popup_area = centered_rect_fixed(50, 12, area); - clear_area(f, popup_area); - - let popup_border = Paragraph::new("").block( - Block::default() - .borders(Borders::ALL) - .title(" Manage Nodes ") - .bold() - .title_style(Style::new().fg(VIVID_SKY_BLUE)) - .padding(Padding::uniform(2)) - .border_style(Style::new().fg(GHOST_WHITE)), - ); + if self.nodes_starting() && self.should_we_run_nat_detection() { + let popup_text = vec![ + Line::raw("Starting nodes..."), + Line::raw(""), + Line::raw(""), + Line::raw("Please wait, performing initial NAT detection"), + Line::raw("This may take a couple minutes."), + ]; - let centred_area = Layout::new( - Direction::Vertical, - vec![ - // border - Constraint::Length(2), - // our text goes here - Constraint::Min(5), - // border - Constraint::Length(1), - ], - ) - .split(popup_area); - let text = Paragraph::new(popup_text) - .block(Block::default().padding(Padding::horizontal(2))) - .wrap(Wrap { trim: false }) - .alignment(Alignment::Center) - .fg(EUCALYPTUS); - f.render_widget(text, centred_area[1]); - - f.render_widget(popup_border, popup_area); - } + let popup_area = centered_rect_fixed(50, 12, area); + clear_area(f, popup_area); + + let popup_border = Paragraph::new("").block( + Block::default() + .borders(Borders::ALL) + .title(" Manage Nodes ") + .bold() + .title_style(Style::new().fg(VIVID_SKY_BLUE)) + .padding(Padding::uniform(2)) + .border_style(Style::new().fg(GHOST_WHITE)), + ); + + let centred_area = Layout::new( + Direction::Vertical, + vec![ + // border + Constraint::Length(2), + // our text goes here + Constraint::Min(5), + // border + Constraint::Length(1), + ], + ) + .split(popup_area); + let text = Paragraph::new(popup_text) + .block(Block::default().padding(Padding::horizontal(2))) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Center) + .fg(EUCALYPTUS); + f.render_widget(text, centred_area[1]); + + f.render_widget(popup_border, popup_area); } Ok(()) @@ -1116,10 +1403,14 @@ impl StatefulTable { fn next(&mut self) { let i = match self.state.selected() { Some(i) => { - if i >= self.items.len() - 1 { - 0 + if !self.items.is_empty() { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } } else { - i + 1 + 0 } } None => self.last_selected.unwrap_or(0), @@ -1131,10 +1422,14 @@ impl StatefulTable { fn previous(&mut self) { let i = match self.state.selected() { Some(i) => { - if i == 0 { - self.items.len() - 1 + if !self.items.is_empty() { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } } else { - i - 1 + 0 } } None => self.last_selected.unwrap_or(0), @@ -1184,6 +1479,7 @@ pub struct NodeItem<'a> { records: usize, peers: usize, connections: usize, + locked: bool, // Semaphore for being able to change status status: NodeStatus, spinner: Throbber<'a>, spinner_state: ThrobberState, diff --git a/node-launchpad/src/mode.rs b/node-launchpad/src/mode.rs index a74047e7dc..044f5e65c3 100644 --- a/node-launchpad/src/mode.rs +++ b/node-launchpad/src/mode.rs @@ -23,9 +23,12 @@ pub enum Scene { }, StatusRewardsAddressPopUp, OptionsRewardsAddressPopUp, - ManageNodesPopUp, + ManageNodesPopUp { + amount_of_nodes: usize, + }, ResetNodesPopUp, UpgradeNodesPopUp, + RemoveNodePopUp, } #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/node-launchpad/src/node_mgmt.rs b/node-launchpad/src/node_mgmt.rs index 3ca62e3f7f..9d26b25b61 100644 --- a/node-launchpad/src/node_mgmt.rs +++ b/node-launchpad/src/node_mgmt.rs @@ -19,6 +19,11 @@ pub const PORT_MIN: u32 = 1024; const NODE_ADD_MAX_RETRIES: u32 = 5; +pub const FIXED_INTERVAL: u64 = 60_000; +pub const CONNECTION_TIMEOUT_START: u64 = 120; + +pub const NODES_ALL: &str = "NODES_ALL"; + #[derive(Debug)] pub enum NodeManagementTask { MaintainNodes { @@ -35,6 +40,17 @@ pub enum NodeManagementTask { UpgradeNodes { args: UpgradeNodesArgs, }, + AddNode { + args: MaintainNodesArgs, + }, + RemoveNodes { + services: Vec, + action_sender: UnboundedSender, + }, + StartNode { + services: Vec, + action_sender: UnboundedSender, + }, } #[derive(Clone)] @@ -70,6 +86,15 @@ impl NodeManagement { stop_nodes(services, action_sender).await; } NodeManagementTask::UpgradeNodes { args } => upgrade_nodes(args).await, + NodeManagementTask::RemoveNodes { + services, + action_sender, + } => remove_nodes(services, action_sender).await, + NodeManagementTask::StartNode { + services, + action_sender, + } => start_nodes(services, action_sender).await, + NodeManagementTask::AddNode { args } => add_node(args).await, } } // If the while loop returns, then all the LocalSpawner @@ -102,21 +127,27 @@ impl NodeManagement { /// Stop the specified services 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 + sn_node_manager::cmd::node::stop(None, vec![], services.clone(), VerbosityLevel::Minimal) + .await { error!("Error while stopping services {err:?}"); send_action( action_sender, Action::StatusActions(StatusActions::ErrorStoppingNodes { + services, raw_error: err.to_string(), }), ); } else { info!("Successfully stopped services"); - send_action( - action_sender, - Action::StatusActions(StatusActions::StopNodesCompleted), - ); + for service in services { + send_action( + action_sender.clone(), + Action::StatusActions(StatusActions::StopNodesCompleted { + service_name: service, + }), + ); + } } } @@ -175,7 +206,9 @@ async fn maintain_n_running_nodes(args: MaintainNodesArgs) { debug!("Finished maintaining {} nodes", args.count); send_action( args.action_sender, - Action::StatusActions(StatusActions::StartNodesCompleted), + Action::StatusActions(StatusActions::StartNodesCompleted { + service_name: NODES_ALL.to_string(), + }), ); } @@ -203,11 +236,9 @@ async fn reset_nodes(action_sender: UnboundedSender, start_nodes_after_r #[derive(Debug)] pub struct UpgradeNodesArgs { pub action_sender: UnboundedSender, - pub connection_timeout_s: u64, pub do_not_start: bool, pub custom_bin_path: Option, pub force: bool, - pub fixed_interval: Option, pub peer_ids: Vec, pub provided_env_variables: Option>, pub service_names: Vec, @@ -216,12 +247,30 @@ pub struct UpgradeNodesArgs { } async fn upgrade_nodes(args: UpgradeNodesArgs) { + // First we stop the Nodes + if let Err(err) = sn_node_manager::cmd::node::stop( + None, + vec![], + args.service_names.clone(), + VerbosityLevel::Minimal, + ) + .await + { + error!("Error while stopping services {err:?}"); + send_action( + args.action_sender.clone(), + Action::StatusActions(StatusActions::ErrorUpdatingNodes { + raw_error: err.to_string(), + }), + ); + } + if let Err(err) = sn_node_manager::cmd::node::upgrade( - args.connection_timeout_s, + 0, // will be overwrite by FIXED_INTERVAL args.do_not_start, args.custom_bin_path, args.force, - args.fixed_interval, + Some(FIXED_INTERVAL), args.peer_ids, args.provided_env_variables, args.service_names, @@ -247,6 +296,155 @@ async fn upgrade_nodes(args: UpgradeNodesArgs) { } } +async fn remove_nodes(services: Vec, action_sender: UnboundedSender) { + if let Err(err) = + sn_node_manager::cmd::node::remove(false, vec![], services.clone(), VerbosityLevel::Minimal) + .await + { + error!("Error while removing services {err:?}"); + send_action( + action_sender, + Action::StatusActions(StatusActions::ErrorRemovingNodes { + services, + raw_error: err.to_string(), + }), + ); + } else { + info!("Successfully removed services {:?}", services); + for service in services { + send_action( + action_sender.clone(), + Action::StatusActions(StatusActions::RemoveNodesCompleted { + service_name: service, + }), + ); + } + } +} + +async fn add_node(args: MaintainNodesArgs) { + debug!("Adding node"); + + if args.run_nat_detection { + run_nat_detection(&args.action_sender).await; + } + + let config = prepare_node_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 used_ports = get_used_ports(&node_registry); + let (mut current_port, max_port) = get_port_range(&config.custom_ports); + + while used_ports.contains(¤t_port) && current_port <= max_port { + current_port += 1; + } + + if current_port > max_port { + error!("Reached maximum port number. Unable to find an available port."); + send_action( + args.action_sender.clone(), + Action::StatusActions(StatusActions::ErrorAddingNodes { + raw_error: format!( + "When adding a new node we reached maximum port number ({}).\nUnable to find an available port.", + max_port + ), + }), + ); + } + + let port_range = Some(PortRange::Single(current_port)); + match sn_node_manager::cmd::node::add( + false, // auto_restart, + config.auto_set_nat_flags, + Some(config.count), + config.data_dir_path, + true, // enable_metrics_server, + None, // env_variables, + None, // evm_network + config.home_network, + false, // local, + None, // log_dir_path, + None, // log_format, + None, // max_archived_log_files, + None, // max_log_files, + None, // metrics_port, + None, // node_ip, + port_range, // node_port + config.owner.clone(), + config.peers_args.clone(), + RewardsAddress::from_str(config.rewards_address.as_str()).unwrap(), + None, // rpc_address, + None, // rpc_port, + config.safenode_path.clone(), // src_path, + config.upnp, + None, // url, + None, // user, + None, // version, + VerbosityLevel::Minimal, + ) + .await + { + Err(err) => { + error!("Error while adding services {err:?}"); + send_action( + args.action_sender, + Action::StatusActions(StatusActions::ErrorAddingNodes { + raw_error: err.to_string(), + }), + ); + } + Ok(services) => { + info!("Successfully added services: {:?}", services); + for service in services { + send_action( + args.action_sender.clone(), + Action::StatusActions(StatusActions::AddNodesCompleted { + service_name: service, + }), + ); + } + } + } +} + +async fn start_nodes(services: Vec, action_sender: UnboundedSender) { + debug!("Starting node {:?}", services); + if let Err(err) = sn_node_manager::cmd::node::start( + CONNECTION_TIMEOUT_START, + None, + vec![], + services.clone(), + VerbosityLevel::Minimal, + ) + .await + { + error!("Error while starting services {err:?}"); + send_action( + action_sender, + Action::StatusActions(StatusActions::ErrorStartingNodes { + services, + raw_error: err.to_string(), + }), + ); + } else { + info!("Successfully started services {:?}", services); + for service in services { + send_action( + action_sender.clone(), + Action::StatusActions(StatusActions::StartNodesCompleted { + service_name: service, + }), + ); + } + } +} + // --- Helper functions --- fn send_action(action_sender: UnboundedSender, action: Action) { @@ -411,7 +609,7 @@ async fn scale_down_nodes(config: &NodeConfig, count: u16) { match sn_node_manager::cmd::node::maintain_n_running_nodes( false, config.auto_set_nat_flags, - 120, + CONNECTION_TIMEOUT_START, count, config.data_dir_path.clone(), true, @@ -485,7 +683,7 @@ async fn add_nodes( match sn_node_manager::cmd::node::maintain_n_running_nodes( false, config.auto_set_nat_flags, - 120, + CONNECTION_TIMEOUT_START, config.count, config.data_dir_path.clone(), true, @@ -574,9 +772,8 @@ async fn add_nodes( action_sender.clone(), Action::StatusActions(StatusActions::ErrorScalingUpNodes { raw_error: format!( - "When trying run a node, we reached the maximum amount of retries ({}).\n\ - Could this be a firewall blocking nodes starting?\n\ - Or ports on your router already in use?", + "When trying to start a node, we reached the maximum amount of retries ({}).\n\ + Could this be a firewall blocking nodes starting or ports on your router already in use?", NODE_ADD_MAX_RETRIES ), }), diff --git a/node-launchpad/src/system.rs b/node-launchpad/src/system.rs index d1691e0d80..f2445874be 100644 --- a/node-launchpad/src/system.rs +++ b/node-launchpad/src/system.rs @@ -160,3 +160,19 @@ pub fn get_available_space_b(storage_mountpoint: &PathBuf) -> Result { Ok(available_space_b) } + +// Gets the name of the drive given a mountpoint +pub fn get_drive_name(storage_mountpoint: &PathBuf) -> Result { + let disks = Disks::new_with_refreshed_list(); + let name = disks + .list() + .iter() + .find(|disk| disk.mount_point() == storage_mountpoint) + .context("Cannot find the primary disk. Configuration file might be wrong.")? + .name() + .to_str() + .unwrap_or_default() + .to_string(); + + Ok(name) +} diff --git a/sn_node_manager/src/lib.rs b/sn_node_manager/src/lib.rs index b73ed48612..f5b5ed1a68 100644 --- a/sn_node_manager/src/lib.rs +++ b/sn_node_manager/src/lib.rs @@ -269,7 +269,9 @@ impl ServiceManager { // crate treats as an error. We then return our own error type, which allows us // to handle it here and just proceed with removing the service from the // registry. - println!("The user appears to have removed the {name} service manually"); + if self.verbosity != VerbosityLevel::Minimal { + println!("The user appears to have removed the {name} service manually"); + } } ServiceError::ServiceDoesNotExists(name) => { warn!("The service {name} has most probably been removed already, it does not exists. Skipping the error.");