diff --git a/Cargo.lock b/Cargo.lock index a16285e..f306481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10695,7 +10695,7 @@ dependencies = [ [[package]] name = "space-acres" -version = "0.0.19" +version = "0.0.20" dependencies = [ "anyhow", "arc-swap", @@ -10709,6 +10709,7 @@ dependencies = [ "event-listener-primitives", "file-rotate", "frame-system", + "fs4 0.7.0", "futures", "gtk4", "hex", diff --git a/Cargo.toml b/Cargo.toml index 2551cbc..a1d32c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "space-acres" description = "Space Acres is an opinionated unofficial GUI application for farming on Subspace Network" license = "0BSD" -version = "0.0.19" +version = "0.0.20" authors = ["Nazar Mokrynskyi "] repository = "https://github.com/nazar-pc/space-acres" edition = "2021" @@ -41,6 +41,7 @@ duct = "0.13.6" event-listener-primitives = "2.0.1" file-rotate = "0.7.5" frame-system = { git = "https://github.com/subspace/polkadot-sdk", rev = "c63a8b28a9fd26d42116b0dcef1f2a5cefb9cd1c", default-features = false } +fs4 = "0.7.0" futures = "0.3.29" gtk = { version = "0.7.3", package = "gtk4" } hex = "0.4.3" diff --git a/res/app.css b/res/app.css index 29cb062..094a974 100644 --- a/res/app.css +++ b/res/app.css @@ -19,6 +19,14 @@ progressbar progress { color: #ff3800; } +.free-disk-space > trough > block.low { + background-color: #ff3800; +} + +.free-disk-space > trough > block.high { + background-color: #ffA400; +} + farm-sectors > child { padding: 0; } diff --git a/src/backend/node.rs b/src/backend/node.rs index abe5975..46e30ea 100644 --- a/src/backend/node.rs +++ b/src/backend/node.rs @@ -83,6 +83,12 @@ pub enum SyncState { Idle, } +impl SyncState { + pub fn is_synced(&self) -> bool { + matches!(self, SyncState::Idle) + } +} + #[derive(Debug, Copy, Clone)] pub struct BlockImported { pub number: BlockNumber, diff --git a/src/frontend/running.rs b/src/frontend/running.rs index 39a9b66..7dcd97e 100644 --- a/src/frontend/running.rs +++ b/src/frontend/running.rs @@ -1,20 +1,18 @@ mod farm; +mod node; use crate::backend::config::RawConfig; use crate::backend::farmer::{FarmerNotification, InitialFarmState}; -use crate::backend::node::{ChainInfo, SyncKind, SyncState}; +use crate::backend::node::ChainInfo; use crate::backend::NodeNotification; use crate::frontend::running::farm::{FarmWidget, FarmWidgetInit, FarmWidgetInput}; +use crate::frontend::running::node::{NodeInput, NodeView}; use gtk::prelude::*; use relm4::factory::FactoryHashMap; use relm4::prelude::*; use subspace_core_primitives::BlockNumber; use subspace_runtime_primitives::{Balance, SSC}; -/// Maximum blocks to store in the import queue. -// HACK: This constant comes from Substrate's sync, but it is not public in there -const MAX_IMPORTING_BLOCKS: BlockNumber = 2048; - #[derive(Debug)] pub enum RunningInput { Initialize { @@ -29,26 +27,21 @@ pub enum RunningInput { FarmerNotification(FarmerNotification), } -#[derive(Debug, Default)] -struct NodeState { - best_block_number: BlockNumber, - sync_state: SyncState, -} - #[derive(Debug, Default)] struct FarmerState { initial_reward_address_balance: Balance, reward_address_balance: Balance, piece_cache_sync_progress: f32, - reward_address: String, + reward_address_url: String, + token_symbol: String, } #[derive(Debug)] pub struct RunningView { - node_state: NodeState, + node_view: Controller, + node_synced: bool, farmer_state: FarmerState, farms: FactoryHashMap, - chain_info: ChainInfo, } #[relm4::component(pub)] @@ -63,79 +56,7 @@ impl Component for RunningView { gtk::Box { set_orientation: gtk::Orientation::Vertical, - gtk::Box { - set_height_request: 100, - set_orientation: gtk::Orientation::Vertical, - set_spacing: 10, - - gtk::Label { - add_css_class: "heading", - set_halign: gtk::Align::Start, - #[watch] - set_label: &format!( - "{} consensus node", - model.chain_info.chain_name.strip_prefix("Subspace ").unwrap_or(&model.chain_info.chain_name) - ), - }, - - #[transition = "SlideUpDown"] - match model.node_state.sync_state { - SyncState::Unknown => gtk::Box { - gtk::Label { - #[watch] - set_label: &format!( - "Connecting to the network, best block #{}", - model.node_state.best_block_number - ), - } - }, - SyncState::Syncing { kind, target, speed } => gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 10, - - gtk::Box { - set_spacing: 5, - - gtk::Label { - set_halign: gtk::Align::Start, - - #[watch] - set_label: &{ - let kind = match kind { - SyncKind::Dsn => "Syncing from DSN", - SyncKind::Regular => "Regular sync", - }; - - format!( - "{} #{}/{}{}", - kind, - model.node_state.best_block_number, - target, - speed - .map(|speed| format!(", {:.2} blocks/s", speed)) - .unwrap_or_default(), - ) - }, - }, - - gtk::Spinner { - start: (), - }, - }, - - gtk::ProgressBar { - #[watch] - set_fraction: model.node_state.best_block_number as f64 / target as f64, - }, - }, - SyncState::Idle => gtk::Box { - gtk::Label { - #[watch] - set_label: &format!("Synced, best block #{}", model.node_state.best_block_number), - } - }, - }, - }, + model.node_view.widget().clone(), gtk::Separator { set_margin_all: 10, @@ -159,13 +80,7 @@ impl Component for RunningView { remove_css_class: "link", set_tooltip: "Total account balance and coins farmed since application started, click to see details in Astral", #[watch] - // TODO: Would be great to have `gemini-3g` in chain spec, but it is - // not available in there in clean form - set_uri: &format!( - "https://explorer.subspace.network/#/{}/consensus/accounts/{}", - model.chain_info.protocol_id.strip_prefix("subspace-").unwrap_or(&model.chain_info.protocol_id), - model.farmer_state.reward_address - ), + set_uri: &model.farmer_state.reward_address_url, set_use_underline: false, gtk::Label { @@ -175,7 +90,7 @@ impl Component for RunningView { let balance_increase = model.farmer_state.reward_address_balance - model.farmer_state.initial_reward_address_balance; let current_balance = (current_balance / (SSC / 100)) as f32 / 100.0; let balance_increase = (balance_increase / (SSC / 100)) as f32 / 100.0; - let token_symbol = &model.chain_info.token_symbol; + let token_symbol = &model.farmer_state.token_symbol; format!( "{current_balance:.2}+{balance_increase:.2} {token_symbol}" @@ -243,15 +158,16 @@ impl Component for RunningView { _root: Self::Root, _sender: ComponentSender, ) -> ComponentParts { + let node_view = NodeView::builder().launch(()).detach(); let farms = FactoryHashMap::builder() .launch(gtk::Box::default()) .detach(); let model = Self { - node_state: NodeState::default(), + node_view, + node_synced: false, farmer_state: FarmerState::default(), farms, - chain_info: ChainInfo::default(), }; let farms_box = model.farms.widget(); @@ -296,78 +212,64 @@ impl RunningView { ); } - self.node_state = NodeState { - best_block_number, - sync_state: SyncState::default(), - }; self.farmer_state = FarmerState { initial_reward_address_balance: reward_address_balance, reward_address_balance, - reward_address: raw_config.reward_address().to_string(), piece_cache_sync_progress: 0.0, + // TODO: Would be great to have `gemini-3g` in chain spec, but it is + // not available in there in clean form + reward_address_url: format!( + "https://explorer.subspace.network/#/{}/consensus/accounts/{}", + chain_info + .protocol_id + .strip_prefix("subspace-") + .unwrap_or(&chain_info.protocol_id), + raw_config.reward_address() + ), + token_symbol: chain_info.token_symbol.clone(), }; - self.chain_info = chain_info; + self.node_view.emit(NodeInput::Initialize { + best_block_number, + chain_info, + node_path: raw_config.node_path().clone(), + }); } - RunningInput::NodeNotification(node_notification) => match node_notification { - NodeNotification::SyncStateUpdate(mut sync_state) => { - if let SyncState::Syncing { - target: new_target, .. - } = &mut sync_state - { - *new_target = (*new_target).max(self.node_state.best_block_number); - - // Ensure target is never below current block - if let SyncState::Syncing { - target: old_target, .. - } = &self.node_state.sync_state - { - // If old target was within `MAX_IMPORTING_BLOCKS` from new target, keep old target - if old_target - .checked_sub(*new_target) - .map(|diff| diff <= MAX_IMPORTING_BLOCKS) - .unwrap_or_default() - { - *new_target = *old_target; - } + RunningInput::NodeNotification(node_notification) => { + self.node_view + .emit(NodeInput::NodeNotification(node_notification.clone())); + + match node_notification { + NodeNotification::SyncStateUpdate(sync_state) => { + let new_synced = sync_state.is_synced(); + if self.node_synced != new_synced { + self.farms + .broadcast(FarmWidgetInput::NodeSynced(new_synced)); } + self.node_synced = new_synced; } - - let old_synced = matches!(self.node_state.sync_state, SyncState::Idle); - let new_synced = matches!(sync_state, SyncState::Idle); - if old_synced != new_synced { - self.farms - .broadcast(FarmWidgetInput::NodeSynced(new_synced)); - } - self.node_state.sync_state = sync_state; - } - NodeNotification::BlockImported(imported_block) => { - self.node_state.best_block_number = imported_block.number; - if !matches!(self.node_state.sync_state, SyncState::Idle) { - // Do not count balance increase during sync as increase related to farming, - // but preserve accumulated diff - let previous_diff = self.farmer_state.reward_address_balance - - self.farmer_state.initial_reward_address_balance; - self.farmer_state.initial_reward_address_balance = - imported_block.reward_address_balance - previous_diff; - } - // In case balance decreased, subtract it from initial balance to ignore, this - // typically happens due to chain reorg when reward is "disappears" - if let Some(decreased_by) = self - .farmer_state - .reward_address_balance - .checked_sub(imported_block.reward_address_balance) - { - self.farmer_state.initial_reward_address_balance -= decreased_by; - } - self.farmer_state.reward_address_balance = - imported_block.reward_address_balance; - - // Ensure target is never below current block - if let SyncState::Syncing { target, .. } = &mut self.node_state.sync_state { - *target = (*target).max(self.node_state.best_block_number); + NodeNotification::BlockImported(imported_block) => { + if !self.node_synced { + // Do not count balance increase during sync as increase related to + // farming, but preserve accumulated diff + let previous_diff = self.farmer_state.reward_address_balance + - self.farmer_state.initial_reward_address_balance; + self.farmer_state.initial_reward_address_balance = + imported_block.reward_address_balance - previous_diff; + } + // In case balance decreased, subtract it from initial balance to ignore, + // this typically happens due to chain reorg when reward is "disappears" + if let Some(decreased_by) = self + .farmer_state + .reward_address_balance + .checked_sub(imported_block.reward_address_balance) + { + self.farmer_state.initial_reward_address_balance -= decreased_by; + } + self.farmer_state.reward_address_balance = + imported_block.reward_address_balance; } } - }, + } RunningInput::FarmerNotification(farmer_notification) => match farmer_notification { FarmerNotification::SectorUpdate { farm_index, diff --git a/src/frontend/running/node.rs b/src/frontend/running/node.rs new file mode 100644 index 0000000..3b5c7eb --- /dev/null +++ b/src/frontend/running/node.rs @@ -0,0 +1,309 @@ +use crate::backend::node::{ChainInfo, SyncKind, SyncState}; +use crate::backend::NodeNotification; +use bytesize::ByteSize; +use gtk::prelude::*; +use parking_lot::Mutex; +use relm4::prelude::*; +use relm4::{Sender, ShutdownReceiver}; +use relm4_icons::icon_name; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use subspace_core_primitives::BlockNumber; +use tracing::error; + +/// Maximum blocks to store in the import queue. +// HACK: This constant comes from Substrate's sync, but it is not public in there +const MAX_IMPORTING_BLOCKS: BlockNumber = 2048; +/// How frequently to check for free disk space +const FREE_DISK_SPACE_CHECK_INTERVAL: Duration = Duration::from_secs(5); +/// Free disk space below which warning must be shown +const FREE_DISK_SPACE_CHECK_WARNING_THRESHOLD: u64 = 10 * 1024 * 1024 * 1024; + +#[derive(Debug)] +pub enum NodeInput { + Initialize { + best_block_number: BlockNumber, + chain_info: ChainInfo, + node_path: PathBuf, + }, + NodeNotification(NodeNotification), +} + +#[derive(Debug)] +pub enum NodeCommandOutput { + FreeDiskSpace(ByteSize), +} + +#[derive(Debug)] +pub struct NodeView { + best_block_number: BlockNumber, + sync_state: SyncState, + free_disk_space: Option, + chain_name: String, + node_path: Arc>, +} + +#[relm4::component(pub)] +impl Component for NodeView { + type Init = (); + type Input = NodeInput; + type Output = (); + type CommandOutput = NodeCommandOutput; + + view! { + #[root] + gtk::Box { + set_height_request: 100, + set_orientation: gtk::Orientation::Vertical, + set_spacing: 10, + + gtk::Box { + gtk::Label { + add_css_class: "heading", + set_halign: gtk::Align::Start, + #[watch] + set_label: &model.chain_name, + }, + + gtk::Box { + set_halign: gtk::Align::End, + set_hexpand: true, + + gtk::Box { + set_spacing: 10, + #[watch] + set_tooltip: &format!( + "Free disk space: {} remaining", + model.free_disk_space + .map(|bytes| bytes.to_string_as(true)) + .unwrap_or_default() + ), + #[watch] + set_visible: model.free_disk_space + .map(|bytes| bytes.as_u64() <= FREE_DISK_SPACE_CHECK_WARNING_THRESHOLD) + .unwrap_or_default(), + + gtk::Image { + set_icon_name: Some(icon_name::SSD), + }, + + gtk::LevelBar { + add_css_class: "free-disk-space", + set_min_value: 0.1, + #[watch] + set_value: { + let free_space = model.free_disk_space + .map(|bytes| bytes.as_u64()) + .unwrap_or_default(); + free_space as f64 / FREE_DISK_SPACE_CHECK_WARNING_THRESHOLD as f64 + }, + set_width_request: 100, + }, + }, + }, + }, + + #[transition = "SlideUpDown"] + match model.sync_state { + SyncState::Unknown => gtk::Box { + gtk::Label { + #[watch] + set_label: &format!( + "Connecting to the network, best block #{}", + model.best_block_number + ), + } + }, + SyncState::Syncing { kind, target, speed } => gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 10, + + gtk::Box { + set_spacing: 5, + + gtk::Label { + set_halign: gtk::Align::Start, + + #[watch] + set_label: &{ + let kind = match kind { + SyncKind::Dsn => "Syncing from DSN", + SyncKind::Regular => "Regular sync", + }; + + format!( + "{} #{}/{}{}", + kind, + model.best_block_number, + target, + speed + .map(|speed| format!(", {:.2} blocks/s", speed)) + .unwrap_or_default(), + ) + }, + }, + + gtk::Spinner { + start: (), + }, + }, + + gtk::ProgressBar { + #[watch] + set_fraction: model.best_block_number as f64 / target as f64, + }, + }, + SyncState::Idle => gtk::Box { + gtk::Label { + #[watch] + set_label: &format!("Synced, best block #{}", model.best_block_number), + } + }, + }, + } + } + + fn init( + _init: Self::Init, + _root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let node_path = Arc::>::default(); + let model = Self { + best_block_number: 0, + sync_state: SyncState::default(), + free_disk_space: None, + chain_name: String::new(), + node_path: node_path.clone(), + }; + + let widgets = view_output!(); + + sender.command(move |sender, shutdown_receiver| async move { + Self::check_free_disk_space(sender, shutdown_receiver, node_path).await; + }); + + ComponentParts { model, widgets } + } + + fn update(&mut self, input: Self::Input, _sender: ComponentSender, _root: &Self::Root) { + self.process_input(input); + } + + fn update_cmd( + &mut self, + input: Self::CommandOutput, + _sender: ComponentSender, + _root: &Self::Root, + ) { + self.process_command(input); + } +} + +impl NodeView { + fn process_input(&mut self, input: NodeInput) { + match input { + NodeInput::Initialize { + best_block_number, + chain_info, + node_path, + } => { + self.best_block_number = best_block_number; + self.chain_name = format!( + "{} consensus node", + chain_info + .chain_name + .strip_prefix("Subspace ") + .unwrap_or(&chain_info.chain_name) + ); + *self.node_path.lock() = node_path; + } + NodeInput::NodeNotification(node_notification) => match node_notification { + NodeNotification::SyncStateUpdate(mut sync_state) => { + if let SyncState::Syncing { + target: new_target, .. + } = &mut sync_state + { + *new_target = (*new_target).max(self.best_block_number); + + // Ensure target is never below current block + if let SyncState::Syncing { + target: old_target, .. + } = &self.sync_state + { + // If old target was within `MAX_IMPORTING_BLOCKS` from new target, keep old target + if old_target + .checked_sub(*new_target) + .map(|diff| diff <= MAX_IMPORTING_BLOCKS) + .unwrap_or_default() + { + *new_target = *old_target; + } + } + } + self.sync_state = sync_state; + } + NodeNotification::BlockImported(imported_block) => { + self.best_block_number = imported_block.number; + // Ensure target is never below current block + if let SyncState::Syncing { target, .. } = &mut self.sync_state { + *target = (*target).max(self.best_block_number); + } + } + }, + } + } + + fn process_command(&mut self, command_output: NodeCommandOutput) { + match command_output { + NodeCommandOutput::FreeDiskSpace(bytes) => { + self.free_disk_space.replace(bytes); + } + } + } + + async fn check_free_disk_space( + sender: Sender, + shutdown_receiver: ShutdownReceiver, + node_path: Arc>, + ) { + shutdown_receiver + .register(async move { + loop { + let node_path = node_path.lock().clone(); + + if node_path == PathBuf::default() { + tokio::time::sleep(FREE_DISK_SPACE_CHECK_INTERVAL).await; + continue; + } + + match tokio::task::spawn_blocking(move || fs4::available_space(node_path)).await + { + Ok(Ok(free_disk_space)) => { + if sender + .send(NodeCommandOutput::FreeDiskSpace(ByteSize::b( + free_disk_space, + ))) + .is_err() + { + break; + } + } + Ok(Err(error)) => { + error!(%error, "Failed to check free disk space"); + break; + } + Err(error) => { + error!(%error, "Free disk space task panicked"); + break; + } + } + + tokio::time::sleep(FREE_DISK_SPACE_CHECK_INTERVAL).await; + } + }) + .drop_on_shutdown() + .await + } +}