diff --git a/res/app.css b/res/app.css index fb4b96c4..24e8f7b0 100644 --- a/res/app.css +++ b/res/app.css @@ -10,3 +10,11 @@ progressbar trough { progressbar progress { min-height: inherit; } + +.warning-label { + color: #ffA400; +} + +.error-label { + color: #ff3800; +} diff --git a/src/backend.rs b/src/backend.rs index d1cda14f..f9134233 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -17,7 +17,7 @@ use futures::channel::mpsc; use futures::{future, select, SinkExt, StreamExt}; use parking_lot::Mutex; use sc_subspace_chain_specs::GEMINI_3G_CHAIN_SPEC; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::pin::pin; use std::sync::Arc; use subspace_core_primitives::{BlockNumber, PublicKey}; @@ -94,8 +94,10 @@ pub enum BackendNotification { config: RawConfig, error: ConfigError, }, + ConfigSaveResult(anyhow::Result<()>), Running { config: Config, + raw_config: RawConfig, best_block_number: BlockNumber, }, Node(NodeNotification), @@ -113,7 +115,17 @@ pub enum BackendNotification { /// Control action messages sent to backend to control its behavior #[derive(Debug)] pub enum BackendAction { - NewConfig { config: RawConfig }, + /// Config was created or updated + NewConfig { raw_config: RawConfig }, +} + +struct LoadedBackend { + config: Config, + raw_config: RawConfig, + config_file_path: PathBuf, + consensus_node: ConsensusNode, + farmer: Farmer, + node_runner: NodeRunner, } // NOTE: this is an async function, but it might do blocking operations and should be running on a @@ -124,8 +136,8 @@ pub async fn create( ) { let loading_result = try { 'load: loop { - if let Some(result) = load(&mut notifications_sender).await? { - break result; + if let Some(backend_loaded) = load(&mut notifications_sender).await? { + break backend_loaded; } if let Err(error) = notifications_sender @@ -140,18 +152,18 @@ pub async fn create( #[allow(clippy::never_loop)] while let Some(backend_action) = backend_action_receiver.next().await { match backend_action { - BackendAction::NewConfig { config } => { - if let Err(error) = Config::try_from_raw_config(&config).await { + BackendAction::NewConfig { raw_config } => { + if let Err(error) = Config::try_from_raw_config(&raw_config).await { notifications_sender .send(BackendNotification::ConfigurationIsInvalid { - config: config.clone(), + config: raw_config.clone(), error, }) .await?; } let config_file_path = RawConfig::default_path().await?; - config + raw_config .write_to_path(&config_file_path) .await .map_err(|error| { @@ -172,10 +184,10 @@ pub async fn create( } }; - let (config, consensus_node, farmer, node_runner) = match loading_result { - Ok(result) => { + let loaded_backend = match loading_result { + Ok(loaded_backend) => { // Loaded successfully - result + loaded_backend } Err(error) => { if let Err(error) = notifications_sender @@ -189,10 +201,8 @@ pub async fn create( }; let run_fut = run( - config, - consensus_node, - farmer, - node_runner, + loaded_backend, + &mut backend_action_receiver, &mut notifications_sender, ); if let Err(error) = run_fut.await { @@ -207,12 +217,13 @@ pub async fn create( async fn load( notifications_sender: &mut mpsc::Sender, -) -> anyhow::Result)>> { - let Some(config) = load_configuration(notifications_sender).await? else { +) -> anyhow::Result> { + let (config_file_path, Some(raw_config)) = load_configuration(notifications_sender).await? + else { return Ok(None); }; - let Some(config) = check_configuration(&config, notifications_sender).await? else { + let Some(config) = check_configuration(&raw_config, notifications_sender).await? else { return Ok(None); }; @@ -257,16 +268,29 @@ async fn load( ) .await?; - Ok(Some((config, consensus_node, farmer, node_runner))) + Ok(Some(LoadedBackend { + config, + raw_config, + config_file_path, + consensus_node, + farmer, + node_runner, + })) } async fn run( - config: Config, - consensus_node: ConsensusNode, - farmer: Farmer, - mut node_runner: NodeRunner, + loaded_backend: LoadedBackend, + backend_action_receiver: &mut mpsc::Receiver, notifications_sender: &mut mpsc::Sender, ) -> anyhow::Result<()> { + let LoadedBackend { + config, + raw_config, + config_file_path, + consensus_node, + farmer, + mut node_runner, + } = loaded_backend; let networking_fut = run_future_in_dedicated_thread( Box::pin({ let span = info_span!("Network"); @@ -280,6 +304,7 @@ async fn run( notifications_sender .send(BackendNotification::Running { config, + raw_config, best_block_number: consensus_node.best_block_number(), }) .await?; @@ -371,17 +396,36 @@ async fn run( let consensus_node_fut = pin!(consensus_node.run()); let farmer_fut = pin!(farmer.run()); let networking_fut = pin!(networking_fut); + let process_backend_actions_fut = pin!({ + let mut notifications_sender = notifications_sender.clone(); + + async move { + process_backend_actions( + &config_file_path, + backend_action_receiver, + &mut notifications_sender, + ) + .await + } + }); + let mut consensus_node_fut = consensus_node_fut.fuse(); + let mut farmer_fut = farmer_fut.fuse(); + let mut networking_fut = networking_fut.fuse(); + let mut process_backend_actions_fut = process_backend_actions_fut.fuse(); let result: anyhow::Result<()> = select! { - result = consensus_node_fut.fuse() => { + result = consensus_node_fut => { result.map_err(anyhow::Error::from) } - result = farmer_fut.fuse() => { + result = farmer_fut => { result.map_err(anyhow::Error::from) } - result = networking_fut.fuse() => { + result = networking_fut => { result.map_err(|_cancelled| anyhow::anyhow!("Networking exited")) } + _ = process_backend_actions_fut => { + Ok(()) + } }; notifications_sender @@ -395,7 +439,7 @@ async fn run( async fn load_configuration( notifications_sender: &mut mpsc::Sender, -) -> anyhow::Result> { +) -> anyhow::Result<(PathBuf, Option)> { notifications_sender .send(BackendNotification::Loading { step: LoadingStep::LoadingConfiguration, @@ -424,7 +468,7 @@ async fn load_configuration( }) .await?; - Ok(maybe_config) + Ok((config_file_path, maybe_config)) } /// Returns `Ok(None)` if configuration failed validation @@ -743,3 +787,32 @@ async fn create_farmer( Ok(farmer) } + +async fn process_backend_actions( + config_file_path: &Path, + backend_action_receiver: &mut mpsc::Receiver, + notifications_sender: &mut mpsc::Sender, +) { + while let Some(action) = backend_action_receiver.next().await { + match action { + BackendAction::NewConfig { raw_config } => { + let result = raw_config + .write_to_path(config_file_path) + .await + .map_err(|error| { + anyhow::anyhow!( + "Failed to write config to \"{}\": {}", + config_file_path.display(), + error + ) + }); + if let Err(error) = notifications_sender + .send(BackendNotification::ConfigSaveResult(result)) + .await + { + error!(%error, "Failed to send config save result notification"); + } + } + } + } +} diff --git a/src/backend/config.rs b/src/backend/config.rs index a1b5139c..9fe8bf17 100644 --- a/src/backend/config.rs +++ b/src/backend/config.rs @@ -148,8 +148,8 @@ impl Config { /// Tries to construct config from given raw config. /// /// It will check that path exists or parent directory can be accesses. - pub async fn try_from_raw_config(config: &RawConfig) -> Result { - let reward_address = config.reward_address(); + pub async fn try_from_raw_config(raw_config: &RawConfig) -> Result { + let reward_address = raw_config.reward_address(); let reward_address = parse_ss58_reward_address(reward_address).map_err(|error| { ConfigError::InvalidSs58RewardAddress { reward_address: reward_address.to_string(), @@ -157,12 +157,12 @@ impl Config { } })?; - let node_path = config.node_path().clone(); + let node_path = raw_config.node_path().clone(); check_path(&node_path).await?; - let mut farms = Vec::with_capacity(config.farms().len()); + let mut farms = Vec::with_capacity(raw_config.farms().len()); - for farm in config.farms() { + for farm in raw_config.farms() { let path = PathBuf::from(&farm.path); check_path(&path).await?; diff --git a/src/frontend/configuration.rs b/src/frontend/configuration.rs index fc72e1c2..a76b4e17 100644 --- a/src/frontend/configuration.rs +++ b/src/frontend/configuration.rs @@ -26,13 +26,18 @@ pub enum ConfigurationInput { OpenDirectory(DirectoryKind), DirectorySelected(PathBuf), FarmSizeChanged { farm_index: usize, size: String }, + Reconfigure(RawConfig), Start, + Cancel, + Save, Ignore, } #[derive(Debug)] pub enum ConfigurationOutput { StartWithNewConfig(RawConfig), + ConfigUpdate(RawConfig), + Close, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -93,6 +98,7 @@ pub struct ConfigurationView { farms: Vec, pending_directory_selection: Option, open_dialog: Controller, + reconfiguration: bool, } #[relm4::component(pub)] @@ -112,6 +118,7 @@ impl Component for ConfigurationView { gtk::Box { set_orientation: gtk::Orientation::Vertical, + set_spacing: 20, gtk::ListBox { gtk::ListBoxRow { @@ -315,27 +322,60 @@ impl Component for ConfigurationView { }, }, - gtk::Box { - set_halign: gtk::Align::End, - - gtk::Button { - add_css_class: "suggested-action", - connect_clicked => ConfigurationInput::Start, - set_margin_top: 20, - #[watch] - set_sensitive: - model.reward_address.valid() - && model.node_path.valid() - && !model.farms.is_empty() - && model.farms.iter().all(|farm| { - farm.path.valid() && farm.size.valid() - }), - - gtk::Label { - set_label: "Start", - set_margin_all: 10, + if model.reconfiguration { + gtk::Box { + set_halign: gtk::Align::End, + set_spacing: 10, + + gtk::Button { + connect_clicked => ConfigurationInput::Cancel, + + gtk::Label { + set_label: "Cancel", + set_margin_all: 10, + }, }, - }, + + gtk::Button { + add_css_class: "suggested-action", + connect_clicked => ConfigurationInput::Save, + #[watch] + set_sensitive: + model.reward_address.valid() + && model.node_path.valid() + && !model.farms.is_empty() + && model.farms.iter().all(|farm| { + farm.path.valid() && farm.size.valid() + }), + + gtk::Label { + set_label: "Save", + set_margin_all: 10, + }, + }, + } + } else { + gtk::Box { + set_halign: gtk::Align::End, + + gtk::Button { + add_css_class: "suggested-action", + connect_clicked => ConfigurationInput::Start, + #[watch] + set_sensitive: + model.reward_address.valid() + && model.node_path.valid() + && !model.farms.is_empty() + && model.farms.iter().all(|farm| { + farm.path.valid() && farm.size.valid() + }), + + gtk::Label { + set_label: "Start", + set_margin_all: 10, + }, + }, + } }, }, }, @@ -365,6 +405,7 @@ impl Component for ConfigurationView { farms: Default::default(), pending_directory_selection: Default::default(), open_dialog, + reconfiguration: false, }; let widgets = view_output!(); @@ -433,29 +474,63 @@ impl ConfigurationView { }) } } + ConfigurationInput::Reconfigure(raw_config) => { + // `Unknown` is a hack to make it actually render the first time + self.reward_address = MaybeValid::Unknown(raw_config.reward_address().to_string()); + self.node_path = MaybeValid::Valid(raw_config.node_path().clone()); + self.farms = raw_config + .farms() + .iter() + .map(|farm| DiskFarm { + path: MaybeValid::Valid(farm.path.clone()), + // `Unknown` is a hack to make it actually render the first time + size: MaybeValid::Unknown(farm.size.clone()), + }) + .collect(); + self.reconfiguration = true; + } ConfigurationInput::Start => { - let config = RawConfig::V0 { - reward_address: String::clone(&self.reward_address), - node_path: PathBuf::clone(&self.node_path), - farms: self - .farms - .iter() - .map(|farm| Farm { - path: PathBuf::clone(&farm.path), - size: String::clone(&farm.size), - }) - .collect(), - }; if sender - .output(ConfigurationOutput::StartWithNewConfig(config)) + .output(ConfigurationOutput::StartWithNewConfig( + self.create_raw_config(), + )) .is_err() { debug!("Failed to send ConfigurationOutput::StartWithNewConfig"); } } + ConfigurationInput::Cancel => { + if sender.output(ConfigurationOutput::Close).is_err() { + debug!("Failed to send ConfigurationOutput::Close"); + } + } + ConfigurationInput::Save => { + if sender + .output(ConfigurationOutput::ConfigUpdate(self.create_raw_config())) + .is_err() + { + debug!("Failed to send ConfigurationOutput::ConfigUpdate"); + } + } ConfigurationInput::Ignore => { // Ignore } } } + + /// Create raw config from own state + fn create_raw_config(&self) -> RawConfig { + RawConfig::V0 { + reward_address: String::clone(&self.reward_address), + node_path: PathBuf::clone(&self.node_path), + farms: self + .farms + .iter() + .map(|farm| Farm { + path: PathBuf::clone(&farm.path), + size: String::clone(&farm.size), + }) + .collect(), + } + } } diff --git a/src/main.rs b/src/main.rs index 1f52c6e9..82289ab9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,9 @@ mod backend; mod frontend; +use crate::backend::config::RawConfig; use crate::backend::{BackendAction, BackendNotification}; -use crate::frontend::configuration::{ConfigurationOutput, ConfigurationView}; +use crate::frontend::configuration::{ConfigurationInput, ConfigurationOutput, ConfigurationView}; use crate::frontend::loading::{LoadingInput, LoadingView}; use crate::frontend::running::{RunningInput, RunningView}; use futures::channel::mpsc; @@ -14,6 +15,7 @@ use gtk::prelude::*; use relm4::prelude::*; use relm4::{set_global_css, RELM_THREADS}; use std::thread::available_parallelism; +use subspace_farmer::utils::AsyncJoinOnDrop; use subspace_proof_of_space::chia::ChiaTable; use tokio::runtime::Handle; use tracing_subscriber::filter::LevelFilter; @@ -32,12 +34,14 @@ type PosTable = ChiaTable; enum AppInput { BackendNotification(BackendNotification), Configuration(ConfigurationOutput), + OpenReconfiguration, ShowAboutDialog, } enum View { Loading, Configuration, + Reconfiguration, Running, Stopped(Option), Error(anyhow::Error), @@ -48,6 +52,7 @@ impl View { match self { Self::Loading => "Loading", Self::Configuration => "Configuration", + Self::Reconfiguration => "Reconfiguration", Self::Running => "Running", Self::Stopped(_) => "Stopped", Self::Error(_) => "Error", @@ -55,9 +60,44 @@ impl View { } } +#[derive(Debug, Default)] +enum StatusBarNotification { + #[default] + None, + Warning(String), + Error(String), +} + +impl StatusBarNotification { + fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + fn css_classes() -> &'static [&'static str] { + &["label", "warning-label", "error-label"] + } + + fn css_class(&self) -> &'static str { + match self { + Self::None => "label", + Self::Warning(_) => "warning-label", + Self::Error(_) => "error-label", + } + } + + fn message(&self) -> &str { + match self { + Self::None => "", + Self::Warning(message) | Self::Error(message) => message.as_str(), + } + } +} + // TODO: Efficient updates with tracker struct App { current_view: View, + current_raw_config: Option, + status_bar_notification: StatusBarNotification, backend_action_sender: mpsc::Sender, loading_view: Controller, configuration_view: Controller, @@ -98,10 +138,15 @@ impl AsyncComponent for App { set_spacing: 5, gtk::Button { + connect_clicked => AppInput::OpenReconfiguration, + set_label: "Update configuration", + #[watch] + set_visible: model.current_raw_config.is_some(), + }, + + gtk::Button { + connect_clicked => AppInput::ShowAboutDialog, set_label: "About", - connect_clicked[sender] => move |_| { - sender.input(AppInput::ShowAboutDialog); - }, }, }, }, @@ -111,11 +156,12 @@ impl AsyncComponent for App { gtk::Box { set_margin_all: 10, set_orientation: gtk::Orientation::Vertical, + set_spacing: 10, #[transition = "SlideLeftRight"] match &model.current_view { View::Loading => model.loading_view.widget().clone(), - View::Configuration => model.configuration_view.widget().clone(), + View::Configuration | View::Reconfiguration => model.configuration_view.widget().clone(), View::Running=> model.running_view.widget().clone(), View::Stopped(Some(error)) => { // TODO: Better error handling @@ -137,6 +183,22 @@ impl AsyncComponent for App { } }, }, + + #[name = "status_bar_notification_label"] + gtk::Label { + #[track = "!status_bar_notification_label.has_css_class(model.status_bar_notification.css_class())"] + add_css_class: { + for css_class in StatusBarNotification::css_classes() { + status_bar_notification_label.remove_css_class(css_class); + } + + model.status_bar_notification.css_class() + }, + #[watch] + set_label: model.status_bar_notification.message(), + #[watch] + set_visible: !model.status_bar_notification.is_none(), + }, }, } } @@ -147,14 +209,15 @@ impl AsyncComponent for App { root: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let (action_sender, action_receiver) = mpsc::channel(1); + let (backend_action_sender, backend_action_receiver) = mpsc::channel(1); let (notification_sender, mut notification_receiver) = mpsc::channel(100); // Create backend in dedicated thread tokio::task::spawn_blocking(move || { - if true { - Handle::current().block_on(backend::create(action_receiver, notification_sender)); - } + Handle::current().block_on(backend::create( + backend_action_receiver, + notification_sender, + )); }); // Forward backend notifications as application inputs @@ -198,7 +261,9 @@ impl AsyncComponent for App { let mut model = Self { current_view: View::Loading, - backend_action_sender: action_sender, + current_raw_config: None, + status_bar_notification: StatusBarNotification::None, + backend_action_sender, loading_view, configuration_view, running_view, @@ -228,6 +293,14 @@ impl AsyncComponent for App { self.process_configuration_output(configuration_output) .await; } + AppInput::OpenReconfiguration => { + self.menu_popover.hide(); + if let Some(raw_config) = self.current_raw_config.clone() { + self.configuration_view + .emit(ConfigurationInput::Reconfigure(raw_config)); + self.current_view = View::Reconfiguration; + } + } AppInput::ShowAboutDialog => { self.menu_popover.hide(); self.about_dialog.show(); @@ -242,22 +315,37 @@ impl App { // TODO: Render progress BackendNotification::Loading { step, progress: _ } => { self.current_view = View::Loading; + self.status_bar_notification = StatusBarNotification::None; self.loading_view.emit(LoadingInput::BackendLoading(step)); } BackendNotification::NotConfigured => { // TODO: Welcome screen first self.current_view = View::Configuration; } - BackendNotification::ConfigurationIsInvalid { .. } => { - // TODO: Toast with configuration error, render old values with corresponding validity status once - // notification has that information - self.current_view = View::Configuration; + BackendNotification::ConfigurationIsInvalid { error, .. } => { + self.status_bar_notification = + StatusBarNotification::Error(format!("Configuration is invalid: {error}")); } + BackendNotification::ConfigSaveResult(result) => match result { + Ok(()) => { + self.status_bar_notification = StatusBarNotification::Warning( + "Application restart is needed for configuration changes to take effect" + .to_string(), + ); + } + Err(error) => { + self.status_bar_notification = StatusBarNotification::Error(format!( + "Failed to save configuration changes: {error}" + )); + } + }, BackendNotification::Running { config, + raw_config, best_block_number, } => { let num_farms = config.farms.len(); + self.current_raw_config.replace(raw_config); self.current_view = View::Running; self.running_view.emit(RunningInput::Initialize { best_block_number, @@ -283,16 +371,33 @@ impl App { async fn process_configuration_output(&mut self, configuration_output: ConfigurationOutput) { match configuration_output { - ConfigurationOutput::StartWithNewConfig(config) => { + ConfigurationOutput::StartWithNewConfig(raw_config) => { + if let Err(error) = self + .backend_action_sender + .send(BackendAction::NewConfig { raw_config }) + .await + { + self.current_view = + View::Error(anyhow::anyhow!("Failed to send config to backend: {error}")); + } + } + ConfigurationOutput::ConfigUpdate(raw_config) => { + self.current_raw_config.replace(raw_config.clone()); + // Config is updated when application is already running, switch to corresponding screen + self.current_view = View::Running; if let Err(error) = self .backend_action_sender - .send(BackendAction::NewConfig { config }) + .send(BackendAction::NewConfig { raw_config }) .await { self.current_view = View::Error(anyhow::anyhow!("Failed to send config to backend: {error}")); } } + ConfigurationOutput::Close => { + // Configuration view is closed when application is already running, switch to corresponding screen + self.current_view = View::Running; + } } } }