diff --git a/Cargo.lock b/Cargo.lock index a15efaa..f2f8133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -227,6 +237,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -264,6 +280,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -490,6 +515,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deltae" version = "0.3.2" @@ -555,7 +586,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -638,7 +669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -744,6 +775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -763,6 +795,23 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -782,7 +831,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -804,8 +857,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -815,9 +870,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -962,6 +1019,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -976,6 +1039,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -998,6 +1062,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -1353,6 +1418,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -1417,6 +1488,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1463,7 +1559,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1631,19 +1727,27 @@ name = "pdm" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "bitcoin", "config 0.15.19", "crossterm", "directories", + "futures-util", "insta", + "mockito", "p2poolv2_config", "ratatui", + "reqwest", "serde", + "serde_json", "serial_test", "tempfile", + "tokio", + "tokio-tungstenite", "toml 0.8.23", "toml_edit", "unicode-width", + "url", ] [[package]] @@ -1722,7 +1826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1786,6 +1890,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1805,6 +1918,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -1832,7 +2000,39 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1840,6 +2040,18 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "ratatui" @@ -1984,7 +2196,9 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1999,6 +2213,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -2006,6 +2222,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2013,6 +2230,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -2074,6 +2292,12 @@ dependencies = [ "ordered-multimap 0.7.3", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2093,7 +2317,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2103,6 +2327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2115,6 +2340,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2178,7 +2404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes", - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -2332,6 +2558,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2540,7 +2777,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2695,6 +2932,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -2743,6 +2995,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2949,6 +3217,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3020,6 +3308,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3207,6 +3501,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3653,6 +3975,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 7b690d0..f8a5ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,17 @@ unicode-width = "0.2" p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2poolv2_config" } bitcoin = "0.32.5" toml_edit = "0.22" +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +base64 = "0.22.1" +futures-util = "0.3" +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +url = "2" +serde_json = "1.0.135" [dev-dependencies] insta = "1.44.3" serial_test = "3" tempfile = "3" +mockito = "1.7.2" +serde_json = "1.0.149" diff --git a/config/config.toml b/config/config.toml new file mode 100644 index 0000000..6ec999a --- /dev/null +++ b/config/config.toml @@ -0,0 +1,7 @@ +[api] +base_url = "http://127.0.0.1:46884" +# fallback_base_url = "http://127.0.0.1:46885" +host = "127.0.0.1" +port = 46884 +auth_user = "p2pool" +auth_pass = "p2pool" diff --git a/src/app.rs b/src/app.rs index 8452e30..e5ab966 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,13 +3,26 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::bitcoin_config::ConfigEntry as BitcoinEntry; +use crate::bitcoin_logs::{self, BitcoinLogSnapshot}; +use crate::components::bitcoin_client::{BitcoinChainInfo, BitcoinClient}; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; +use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo, SharesResponse}; use crate::components::p2pool_config_view::P2PoolConfigView; +use crate::components::p2pool_websocket::{ + LiveP2PoolEvent, LivePeerEvent, LiveShare, P2PoolWebSocketClient, +}; use crate::components::settings_view::SettingsView; use crate::settings::Settings; +use anyhow::Result; +use base64::{Engine as _, engine::general_purpose}; use p2poolv2_config::Config as P2PoolConfig; +use std::io::Write; use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +const BITCOIN_LOG_REFRESH_INTERVAL: Duration = Duration::from_secs(1); /// Sidebar items labels pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[ @@ -31,6 +44,11 @@ pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peer pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1; +/// Tab labels for the P2Pool Status view +pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Shares", "Peers Info"]; + +pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1; + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum CurrentScreen { Home, @@ -49,6 +67,8 @@ pub enum CurrentScreen { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExplorerTrigger { BitcoinConfig, + BitcoinCoreDataDir, + BitcoinCoreLogFile, P2PoolConfig, /// The `usize` is the settings field index (0–`FIELD_COUNT - 1`). Settings(usize), @@ -80,6 +100,24 @@ pub enum AppAction { OpenExplorerForSettings(usize), // Clear a settings field by index, setting it back to None ClearSettingsField(usize), + RefreshBitcoinLogs, + ToggleBitcoinLogAutoScroll, + SetBitcoinLogDataDir(PathBuf), + SetBitcoinLogFile(PathBuf), + CopyBitcoinLogs, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BitcoinLogInputMode { + Search, + LogFilePath, + DataDirPath, +} + +#[derive(Debug)] +pub struct BitcoinLogReadMessage { + pub path: PathBuf, + pub result: Result, } pub struct App { @@ -95,18 +133,63 @@ pub struct App { pub p2pool_config: Option, pub bitcoin_data: Vec, pub bitcoin_status_tab: usize, + pub bitcoin_chain_info: Option, + pub bitcoin_chain_info_error: Option, + pub bitcoin_log_path: Option, + pub bitcoin_log_lines: Vec, + pub bitcoin_log_status: String, + pub bitcoin_log_filter: String, + pub bitcoin_log_scroll: u16, + pub bitcoin_log_auto_scroll: bool, + pub bitcoin_log_input_mode: Option, + pub bitcoin_log_input: String, + pub bitcoin_log_refresh_in_progress: bool, + pub bitcoin_log_last_refresh: Option, pub settings: Settings, + pub p2pool_client: P2PoolClient, + pub p2pool_websocket_client: P2PoolWebSocketClient, /// Cached value of the `HOME` environment variable, used for path display. /// Populated once at startup to avoid repeated syscalls during rendering. pub home_dir: String, /// Cached result of `settings::config_dir()`, used to display the default /// settings storage path without repeated env-var lookups during rendering. pub config_dir: PathBuf, + pub p2pool_status_tab: usize, + pub chain_info: Option, + pub p2pool_chain_info_error: Option, + pub share_info: Option, + pub p2pool_share_info_error: Option, + pub peer_info: Option>, + pub p2pool_peer_info_error: Option, + pub live_shares: Vec, + pub live_peer_events: Vec, + pub p2pool_live_error: Option, + pub p2pool_live_stream_started: bool, + pub bitcoin_chain_info_tx: mpsc::UnboundedSender>, + pub bitcoin_chain_info_rx: mpsc::UnboundedReceiver>, + pub bitcoin_log_tx: mpsc::UnboundedSender, + pub bitcoin_log_rx: mpsc::UnboundedReceiver, + pub p2pool_live_tx: mpsc::UnboundedSender>, + pub p2pool_live_rx: mpsc::UnboundedReceiver>, + // async channel to receive chain info updates from the background task that + // fetches it when the P2Pool Status screen is opened. + pub chain_info_tx: mpsc::UnboundedSender>, + pub chain_info_rx: mpsc::UnboundedReceiver>, + pub share_info_tx: mpsc::UnboundedSender>, + pub share_info_rx: mpsc::UnboundedReceiver>, + pub peer_info_tx: mpsc::UnboundedSender>>, + pub peer_info_rx: mpsc::UnboundedReceiver>>, } impl App { #[must_use] pub fn new() -> App { + let (chain_info_tx, chain_info_rx) = mpsc::unbounded_channel(); + let (bitcoin_chain_info_tx, bitcoin_chain_info_rx) = mpsc::unbounded_channel(); + let (bitcoin_log_tx, bitcoin_log_rx) = mpsc::unbounded_channel(); + let (peer_info_tx, peer_info_rx) = mpsc::unbounded_channel(); + let (share_info_tx, share_info_rx) = mpsc::unbounded_channel(); + let (p2pool_live_tx, p2pool_live_rx) = mpsc::unbounded_channel(); App { current_screen: CurrentScreen::Home, sidebar_index: 0, @@ -120,9 +203,320 @@ impl App { p2pool_config: None, bitcoin_data: Vec::new(), bitcoin_status_tab: 0, + bitcoin_chain_info: None, + bitcoin_chain_info_error: None, + bitcoin_log_path: None, + bitcoin_log_lines: Vec::new(), + bitcoin_log_status: "No Bitcoin Core debug.log found.".to_string(), + bitcoin_log_filter: String::new(), + bitcoin_log_scroll: 0, + bitcoin_log_auto_scroll: true, + bitcoin_log_input_mode: None, + bitcoin_log_input: String::new(), + bitcoin_log_refresh_in_progress: false, + bitcoin_log_last_refresh: None, settings: Settings::default(), + p2pool_client: P2PoolClient::new(), + p2pool_websocket_client: P2PoolWebSocketClient::new(), home_dir: std::env::var("HOME").unwrap_or_default(), config_dir: crate::settings::config_dir().unwrap_or_default(), + p2pool_status_tab: 0, + chain_info: None, + p2pool_chain_info_error: None, + share_info: None, + p2pool_share_info_error: None, + peer_info: None, + p2pool_peer_info_error: None, + live_shares: Vec::new(), + live_peer_events: Vec::new(), + p2pool_live_error: None, + p2pool_live_stream_started: false, + bitcoin_chain_info_tx, + bitcoin_chain_info_rx, + bitcoin_log_tx, + bitcoin_log_rx, + p2pool_live_tx, + p2pool_live_rx, + chain_info_tx, + chain_info_rx, + share_info_tx, + share_info_rx, + peer_info_tx, + peer_info_rx, + } + } + + #[must_use] + pub fn new_with_client(client: P2PoolClient) -> App { + let mut app = App::new(); + app.p2pool_websocket_client = client.websocket_client(); + app.p2pool_client = client; + app + } + + /// Non-blocking result handler + pub fn poll_chain_info(&mut self) { + while let Ok(result) = self.chain_info_rx.try_recv() { + match result { + Ok(info) => { + self.chain_info = Some(info); + self.p2pool_chain_info_error = None; + } + Err(e) => { + self.chain_info = None; + self.p2pool_chain_info_error = Some(e.to_string()); + } + } + } + } + + pub fn poll_bitcoin_chain_info(&mut self) { + while let Ok(result) = self.bitcoin_chain_info_rx.try_recv() { + match result { + Ok(info) => { + self.bitcoin_chain_info = Some(info); + self.bitcoin_chain_info_error = None; + } + Err(e) => { + self.bitcoin_chain_info = None; + self.bitcoin_chain_info_error = Some(e.to_string()); + } + } + } + } + + pub fn resolve_bitcoin_log_path(&mut self) { + self.bitcoin_log_path = bitcoin_logs::resolve_log_path(&self.settings, &self.bitcoin_data); + if let Some(path) = &self.bitcoin_log_path { + self.bitcoin_log_status = format!("Ready to read {}", path.display()); + } else { + self.bitcoin_log_status = + "No Bitcoin Core debug.log found. Choose a log file or data directory.".to_string(); + } + } + + pub fn poll_bitcoin_logs(&mut self) { + while let Ok(message) = self.bitcoin_log_rx.try_recv() { + if Some(&message.path) != self.bitcoin_log_path.as_ref() { + continue; + } + + self.bitcoin_log_refresh_in_progress = false; + + match message.result { + Ok(snapshot) => { + let line_count = snapshot.lines.len(); + self.bitcoin_log_lines = snapshot.lines; + self.bitcoin_log_status = if line_count == 0 { + format!("{} is empty.", snapshot.path.display()) + } else { + format!( + "Showing {line_count} recent lines from {}", + snapshot.path.display() + ) + }; + self.bitcoin_log_last_refresh = Some(Instant::now()); + if self.bitcoin_log_auto_scroll { + self.bitcoin_log_scroll = 0; + } + } + Err(error) => { + self.bitcoin_log_status = format!( + "Log file unavailable. Bitcoin Core may not be running or debug.log has not been created: {error}" + ); + self.bitcoin_log_last_refresh = Some(Instant::now()); + } + } + } + } + + pub fn maybe_refresh_bitcoin_logs(&mut self) { + if self.current_screen != CurrentScreen::BitcoinStatus || self.bitcoin_status_tab != 2 { + return; + } + + let refresh_due = self + .bitcoin_log_last_refresh + .is_none_or(|last_refresh| last_refresh.elapsed() >= BITCOIN_LOG_REFRESH_INTERVAL); + + if refresh_due { + self.refresh_bitcoin_logs(); + } + } + + pub fn refresh_bitcoin_logs(&mut self) { + if self.bitcoin_log_refresh_in_progress { + return; + } + + if self.bitcoin_log_path.is_none() { + self.resolve_bitcoin_log_path(); + } + + let Some(path) = self.bitcoin_log_path.clone() else { + self.bitcoin_log_status = + "No Bitcoin Core debug.log found. Choose a log file or data directory.".to_string(); + self.bitcoin_log_lines.clear(); + return; + }; + + self.bitcoin_log_status = format!("Reading {}", path.display()); + self.bitcoin_log_refresh_in_progress = true; + + let tx = self.bitcoin_log_tx.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let read_path = path.clone(); + let result = tokio::task::spawn_blocking(move || { + bitcoin_logs::read_log_snapshot(&read_path, bitcoin_logs::DEFAULT_MAX_LOG_LINES) + }) + .await + .unwrap_or_else(|error| Err(anyhow::anyhow!("log reader task failed: {error}"))); + let _ = tx.send(BitcoinLogReadMessage { path, result }); + }); + } else { + let result = + bitcoin_logs::read_log_snapshot(&path, bitcoin_logs::DEFAULT_MAX_LOG_LINES); + let _ = self + .bitcoin_log_tx + .send(BitcoinLogReadMessage { path, result }); + } + } + + pub fn set_bitcoin_log_data_dir(&mut self, path: PathBuf) { + let data_dir = bitcoin_logs::expand_path(&path); + let log_path = bitcoin_logs::best_log_path_for_data_dir(&data_dir, &self.bitcoin_data); + self.settings.bitcoin_core_data_dir = Some(data_dir); + self.settings.bitcoin_core_log_path = None; + self.bitcoin_log_path = Some(log_path); + self.reset_bitcoin_log_reader(); + } + + pub fn set_bitcoin_log_file(&mut self, path: PathBuf) { + let log_path = bitcoin_logs::expand_path(&path); + self.settings.bitcoin_core_log_path = Some(log_path.clone()); + self.settings.bitcoin_core_data_dir = None; + self.bitcoin_log_path = Some(log_path); + self.reset_bitcoin_log_reader(); + } + + pub fn reset_bitcoin_log_reader(&mut self) { + self.bitcoin_log_lines.clear(); + self.bitcoin_log_scroll = 0; + self.bitcoin_log_refresh_in_progress = false; + self.bitcoin_log_last_refresh = None; + self.bitcoin_log_status = "Ready to read Bitcoin Core logs.".to_string(); + } + + #[must_use] + pub fn filtered_bitcoin_log_lines(&self) -> Vec<&str> { + let filter = self.bitcoin_log_filter.trim().to_ascii_lowercase(); + if filter.is_empty() { + return self.bitcoin_log_lines.iter().map(String::as_str).collect(); + } + + self.bitcoin_log_lines + .iter() + .filter(|line| line.to_ascii_lowercase().contains(&filter)) + .map(String::as_str) + .collect() + } + + pub fn copy_filtered_bitcoin_logs(&mut self) { + let text = self.filtered_bitcoin_log_lines().join("\n"); + if text.is_empty() { + self.bitcoin_log_status = "No Bitcoin Core log lines to copy.".to_string(); + return; + } + + match copy_to_terminal_clipboard(&text) { + Ok(()) => { + self.bitcoin_log_status = format!("Copied {} log lines.", text.lines().count()); + } + Err(error) => { + self.bitcoin_log_status = format!("Failed to copy logs: {error}"); + } + } + } + + pub fn poll_peer_info(&mut self) { + while let Ok(result) = self.peer_info_rx.try_recv() { + match result { + Ok(info) => { + self.peer_info = Some(info); + self.p2pool_peer_info_error = None; + } + Err(e) => { + self.peer_info = None; + self.p2pool_peer_info_error = Some(e.to_string()); + } + } + } + } + + pub fn poll_share_info(&mut self) { + while let Ok(result) = self.share_info_rx.try_recv() { + match result { + Ok(info) => { + self.share_info = Some(info); + self.p2pool_share_info_error = None; + } + Err(e) => { + self.share_info = None; + self.p2pool_share_info_error = Some(e.to_string()); + } + } + } + } + + pub fn poll_live_p2pool_events(&mut self) { + while let Ok(result) = self.p2pool_live_rx.try_recv() { + match result { + Ok(LiveP2PoolEvent::Share(share)) => { + Self::push_limited(&mut self.live_shares, share, 50); + self.p2pool_live_error = None; + } + Ok(LiveP2PoolEvent::Peer(peer_event)) => { + self.apply_live_peer_event(&peer_event); + Self::push_limited(&mut self.live_peer_events, peer_event, 50); + self.p2pool_live_error = None; + } + Err(e) => { + self.p2pool_live_error = Some(e.to_string()); + self.p2pool_live_stream_started = false; + } + } + } + } + + pub fn poll_live_shares(&mut self) { + self.poll_live_p2pool_events(); + } + + fn push_limited(items: &mut Vec, item: T, max_len: usize) { + items.push(item); + if items.len() > max_len { + let extra = items.len() - max_len; + items.drain(0..extra); + } + } + + fn apply_live_peer_event(&mut self, event: &LivePeerEvent) { + if event.status.eq_ignore_ascii_case("disconnected") { + if let Some(peers) = &mut self.peer_info { + peers.retain(|peer| peer.peer_id != event.peer_id); + } + return; + } + + let peers = self.peer_info.get_or_insert_with(Vec::new); + if let Some(peer) = peers.iter_mut().find(|peer| peer.peer_id == event.peer_id) { + peer.status = Some(event.status.clone()); + } else { + peers.push(PeerInfo { + peer_id: event.peer_id.clone(), + status: Some(event.status.clone()), + }); } } @@ -142,9 +536,82 @@ impl App { } if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; + if self.current_screen == CurrentScreen::BitcoinStatus { + self.fetch_bitcoin_chain_info(); + self.resolve_bitcoin_log_path(); + } + if self.current_screen == CurrentScreen::P2PoolStatus { + let chain_client = self.p2pool_client.clone(); + let chain_tx = self.chain_info_tx.clone(); + let share_client = self.p2pool_client.clone(); + let share_tx = self.share_info_tx.clone(); + let peer_client = self.p2pool_client.clone(); + let peer_tx = self.peer_info_tx.clone(); + let websocket_client = self.p2pool_websocket_client.clone(); + let live_tx = self.p2pool_live_tx.clone(); + let start_live_stream = !self.p2pool_live_stream_started; + + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = chain_client.fetch_chain_info().await; + let _ = chain_tx.send(res.map_err(anyhow::Error::from)); + }); + + handle.spawn(async move { + let res = share_client.fetch_recent_shares(10).await; + let _ = share_tx.send(res.map_err(anyhow::Error::from)); + }); + + handle.spawn(async move { + let res = peer_client.fetch_peer_info().await; + let _ = peer_tx.send(res.map_err(anyhow::Error::from)); + }); + + if start_live_stream { + self.p2pool_live_stream_started = true; + handle.spawn(async move { + if let Err(error) = websocket_client + .subscribe_live_events(live_tx.clone()) + .await + { + let _ = live_tx.send(Err(error)); + } + }); + } + } + } + } + } + + fn fetch_bitcoin_chain_info(&mut self) { + self.bitcoin_chain_info = None; + self.bitcoin_chain_info_error = None; + + if self.bitcoin_conf_path.is_none() { + return; + } + + let client = BitcoinClient::from_config_entries(&self.bitcoin_data); + let tx = self.bitcoin_chain_info_tx.clone(); + + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = client.fetch_chain_info().await; + let _ = tx.send(res); + }); } } } + +fn copy_to_terminal_clipboard(text: &str) -> Result<()> { + let encoded = general_purpose::STANDARD.encode(text.as_bytes()); + let sequence = format!("\x1b]52;c;{encoded}\x07"); + let mut stdout = std::io::stdout(); + stdout.write_all(sequence.as_bytes())?; + stdout.flush()?; + Ok(()) +} + impl Default for App { fn default() -> Self { Self::new() diff --git a/src/bitcoin_logs.rs b/src/bitcoin_logs.rs new file mode 100644 index 0000000..c91b9fb --- /dev/null +++ b/src/bitcoin_logs.rs @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::bitcoin_config::ConfigEntry; +use crate::settings::Settings; +use anyhow::{Context, Result}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_MAX_LOG_LINES: usize = 300; + +const READ_CHUNK_SIZE: u64 = 8 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BitcoinLogSnapshot { + pub path: PathBuf, + pub lines: Vec, + pub file_size: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BitcoinLogNetwork { + Mainnet, + Testnet, + Testnet4, + Signet, + Regtest, +} + +pub fn read_log_snapshot(path: &Path, max_lines: usize) -> Result { + let lines = read_recent_log_lines(path, max_lines)?; + let file_size = std::fs::metadata(path) + .with_context(|| format!("could not read metadata for {}", path.display()))? + .len(); + + Ok(BitcoinLogSnapshot { + path: path.to_path_buf(), + lines, + file_size, + }) +} + +pub fn read_recent_log_lines(path: &Path, max_lines: usize) -> Result> { + if max_lines == 0 { + return Ok(Vec::new()); + } + + let mut file = + File::open(path).with_context(|| format!("could not open {}", path.display()))?; + let file_len = file + .metadata() + .with_context(|| format!("could not read metadata for {}", path.display()))? + .len(); + + if file_len == 0 { + return Ok(Vec::new()); + } + + let mut position = file_len; + let mut newline_count = 0usize; + let mut chunks: Vec> = Vec::new(); + + while position > 0 && newline_count <= max_lines { + let read_size = READ_CHUNK_SIZE.min(position); + position -= read_size; + + file.seek(SeekFrom::Start(position)) + .with_context(|| format!("could not seek {}", path.display()))?; + + let mut chunk = vec![0u8; read_size as usize]; + file.read_exact(&mut chunk) + .with_context(|| format!("could not read {}", path.display()))?; + + newline_count += chunk.iter().filter(|byte| **byte == b'\n').count(); + chunks.push(chunk); + } + + let total_len = chunks.iter().map(Vec::len).sum(); + let mut bytes = Vec::with_capacity(total_len); + for chunk in chunks.into_iter().rev() { + bytes.extend(chunk); + } + + let text = String::from_utf8_lossy(&bytes); + let mut lines: Vec = text.lines().map(ToOwned::to_owned).collect(); + + if lines.len() > max_lines { + lines = lines.split_off(lines.len() - max_lines); + } + + lines.reverse(); + Ok(lines) +} + +pub fn resolve_log_path(settings: &Settings, entries: &[ConfigEntry]) -> Option { + if let Some(path) = &settings.bitcoin_core_log_path { + return Some(expand_path(path)); + } + + if let Some(data_dir) = &settings.bitcoin_core_data_dir { + return Some(best_log_path_for_data_dir(&expand_path(data_dir), entries)); + } + + if let Some(data_dir) = configured_data_dir(entries) { + return Some(best_log_path_for_data_dir(&data_dir, entries)); + } + + default_data_dirs() + .into_iter() + .find_map(|data_dir| existing_log_path_for_data_dir(&data_dir, entries)) +} + +pub fn best_log_path_for_data_dir(data_dir: &Path, entries: &[ConfigEntry]) -> PathBuf { + existing_log_path_for_data_dir(data_dir, entries).unwrap_or_else(|| { + log_path_candidates_for_data_dir(data_dir, entries) + .into_iter() + .next() + .unwrap_or_else(|| data_dir.join("debug.log")) + }) +} + +pub fn log_path_candidates_for_data_dir(data_dir: &Path, entries: &[ConfigEntry]) -> Vec { + let data_dir = expand_path(data_dir); + let network = network_from_entries(entries); + let mut suffixes = Vec::new(); + + match network { + BitcoinLogNetwork::Mainnet => { + suffixes.push(PathBuf::from("debug.log")); + } + BitcoinLogNetwork::Testnet => { + suffixes.push(PathBuf::from("testnet3/debug.log")); + suffixes.push(PathBuf::from("testnet/debug.log")); + } + BitcoinLogNetwork::Testnet4 => { + suffixes.push(PathBuf::from("testnet4/debug.log")); + } + BitcoinLogNetwork::Signet => { + suffixes.push(PathBuf::from("signet/debug.log")); + } + BitcoinLogNetwork::Regtest => { + suffixes.push(PathBuf::from("regtest/debug.log")); + } + } + + // Also support selecting the network-specific directory itself and common + // layouts that differ from the currently selected chain. + suffixes.extend([ + PathBuf::from("debug.log"), + PathBuf::from("testnet3/debug.log"), + PathBuf::from("testnet4/debug.log"), + PathBuf::from("testnet/debug.log"), + PathBuf::from("signet/debug.log"), + PathBuf::from("regtest/debug.log"), + ]); + + let mut candidates = Vec::new(); + for suffix in suffixes { + let candidate = data_dir.join(suffix); + if !candidates.iter().any(|path| path == &candidate) { + candidates.push(candidate); + } + } + + candidates +} + +pub fn expand_path(path: &Path) -> PathBuf { + let raw = path.to_string_lossy(); + expand_path_str(raw.trim()) +} + +pub fn expand_path_str(raw: &str) -> PathBuf { + let mut expanded = raw.to_string(); + + if expanded == "~" || expanded.starts_with("~/") { + if let Some(home) = home_dir() { + let suffix = expanded.trim_start_matches('~').trim_start_matches('/'); + expanded = home.join(suffix).to_string_lossy().into_owned(); + } + } + + if expanded.contains("%APPDATA%") + && let Some(appdata) = std::env::var_os("APPDATA") + { + expanded = expanded.replace("%APPDATA%", &appdata.to_string_lossy()); + } + + PathBuf::from(expanded) +} + +fn existing_log_path_for_data_dir(data_dir: &Path, entries: &[ConfigEntry]) -> Option { + log_path_candidates_for_data_dir(data_dir, entries) + .into_iter() + .find(|path| path.is_file()) +} + +fn configured_data_dir(entries: &[ConfigEntry]) -> Option { + entry_value(entries, "datadir") + .map(expand_path_str) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn default_data_dirs() -> Vec { + let mut dirs = Vec::new(); + + if let Some(home) = home_dir() { + dirs.push(home.join(".bitcoin")); + dirs.push(home.join("Library/Application Support/Bitcoin")); + } + + if let Some(appdata) = std::env::var_os("APPDATA") { + dirs.push(PathBuf::from(appdata).join("Bitcoin")); + } else if let Some(user_profile) = std::env::var_os("USERPROFILE") { + dirs.push( + PathBuf::from(user_profile) + .join("AppData") + .join("Roaming") + .join("Bitcoin"), + ); + } + + dirs +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn entry_value<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a str> { + entries + .iter() + .find(|entry| entry.enabled && entry.key == key && !entry.value.trim().is_empty()) + .map(|entry| entry.value.trim()) +} + +fn network_from_entries(entries: &[ConfigEntry]) -> BitcoinLogNetwork { + if bool_entry(entries, "regtest") { + return BitcoinLogNetwork::Regtest; + } + if bool_entry(entries, "signet") { + return BitcoinLogNetwork::Signet; + } + if bool_entry(entries, "testnet4") { + return BitcoinLogNetwork::Testnet4; + } + if bool_entry(entries, "testnet") { + return BitcoinLogNetwork::Testnet; + } + + match entry_value(entries, "chain") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "test" | "testnet" | "testnet3" => BitcoinLogNetwork::Testnet, + "testnet4" => BitcoinLogNetwork::Testnet4, + "signet" => BitcoinLogNetwork::Signet, + "regtest" => BitcoinLogNetwork::Regtest, + _ => BitcoinLogNetwork::Mainnet, + } +} + +fn bool_entry(entries: &[ConfigEntry], key: &str) -> bool { + matches!( + entry_value(entries, key) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn entry(key: &str, value: &str) -> ConfigEntry { + ConfigEntry { + key: key.to_string(), + value: value.to_string(), + schema: None, + enabled: true, + section: None, + } + } + + #[test] + fn reads_recent_lines_newest_first() { + let dir = tempdir().unwrap(); + let path = dir.path().join("debug.log"); + std::fs::write( + &path, + "2026-01-01T00:00:00Z first\n2026-01-01T00:00:01Z second\n2026-01-01T00:00:02Z third\n", + ) + .unwrap(); + + let lines = read_recent_log_lines(&path, 2).unwrap(); + + assert_eq!( + lines, + vec![ + "2026-01-01T00:00:02Z third".to_string(), + "2026-01-01T00:00:01Z second".to_string(), + ] + ); + } + + #[test] + fn resolves_direct_log_path_from_settings() { + let settings = Settings { + bitcoin_core_log_path: Some(PathBuf::from("/tmp/bitcoin/debug.log")), + ..Default::default() + }; + + assert_eq!( + resolve_log_path(&settings, &[]), + Some(PathBuf::from("/tmp/bitcoin/debug.log")) + ); + } + + #[test] + fn prefers_network_layout_for_configured_data_dir() { + let entries = vec![entry("signet", "1")]; + let path = best_log_path_for_data_dir(Path::new("/tmp/bitcoin"), &entries); + + assert_eq!(path, PathBuf::from("/tmp/bitcoin/signet/debug.log")); + } + + #[test] + fn finds_existing_testnet4_log() { + let dir = tempdir().unwrap(); + let log_dir = dir.path().join("testnet4"); + std::fs::create_dir_all(&log_dir).unwrap(); + std::fs::write(log_dir.join("debug.log"), "hello\n").unwrap(); + + let entries = vec![entry("chain", "testnet4")]; + let path = existing_log_path_for_data_dir(dir.path(), &entries).unwrap(); + + assert_eq!(path, log_dir.join("debug.log")); + } +} diff --git a/src/components/bitcoin_client.rs b/src/components/bitcoin_client.rs new file mode 100644 index 0000000..19f6083 --- /dev/null +++ b/src/components/bitcoin_client.rs @@ -0,0 +1,434 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::bitcoin_config::ConfigEntry; +use anyhow::{Context, Result, anyhow, bail}; +use reqwest::Client; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::{path::PathBuf, time::Duration}; + +const REQUEST_TIMEOUT_SECONDS: u64 = 10; + +#[derive(Debug, Clone)] +pub struct BitcoinClient { + client: Client, + url: String, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BitcoinChainInfo { + pub network: String, + pub block_height: u64, + pub best_block_hash: String, + pub verification_progress: Option, + pub initial_block_download: Option, + pub connection_count: Option, + pub connected_peer_addresses: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BitcoinNetwork { + Mainnet, + Testnet, + Testnet4, + Signet, + Regtest, +} + +#[derive(Debug, Deserialize)] +struct BlockchainInfoResponse { + chain: String, + blocks: u64, + bestblockhash: String, + verificationprogress: Option, + initialblockdownload: Option, +} + +#[derive(Debug, Deserialize)] +struct PeerInfoResponse { + addr: Option, +} + +#[derive(Debug, Serialize)] +struct RpcRequest<'a> { + jsonrpc: &'static str, + id: &'static str, + method: &'a str, + params: &'static [Value], +} + +#[derive(Debug, Deserialize)] +struct RpcResponse { + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct RpcError { + code: i64, + message: String, +} + +impl BitcoinClient { + #[must_use] + pub fn from_config_entries(entries: &[ConfigEntry]) -> Self { + let network = network_from_entries(entries); + let port = entry_value(entries, "rpcport") + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| default_rpc_port(network)); + let host = entry_value(entries, "rpcbind").unwrap_or("127.0.0.1"); + let url = rpc_url(host, port); + let auth_credentials = rpc_auth(entries, network); + + Self { + client: build_client(), + url, + auth_credentials, + } + } + + pub async fn fetch_chain_info(&self) -> Result { + let chain_info: BlockchainInfoResponse = self.rpc_call("getblockchaininfo").await?; + let connection_count = self.rpc_call("getconnectioncount").await.ok(); + let connected_peer_addresses = self.fetch_connected_peer_addresses().await?; + + Ok(BitcoinChainInfo { + network: display_network(&chain_info.chain).to_string(), + block_height: chain_info.blocks, + best_block_hash: chain_info.bestblockhash, + verification_progress: chain_info.verificationprogress, + initial_block_download: chain_info.initialblockdownload, + connection_count, + connected_peer_addresses, + }) + } + + async fn fetch_connected_peer_addresses(&self) -> Result> { + let peers: Vec = self.rpc_call("getpeerinfo").await?; + + Ok(peers + .into_iter() + .filter_map(|peer| { + let address = peer.addr?.trim().to_string(); + (!address.is_empty()).then_some(address) + }) + .collect()) + } + + async fn rpc_call(&self, method: &str) -> Result + where + T: DeserializeOwned, + { + let request = RpcRequest { + jsonrpc: "1.0", + id: "pdm", + method, + params: &[], + }; + + let mut builder = self.client.post(&self.url).json(&request); + if let Some((user, pass)) = &self.auth_credentials { + builder = builder.basic_auth(user, Some(pass)); + } + + let response = builder + .send() + .await + .with_context(|| format!("could not connect to Bitcoin Core at {}", self.url))? + .error_for_status() + .context("Bitcoin Core RPC returned an HTTP error")? + .json::>() + .await + .context("Bitcoin Core RPC returned an invalid response")?; + + if let Some(error) = response.error { + bail!("Bitcoin Core RPC error {}: {}", error.code, error.message); + } + + response + .result + .ok_or_else(|| anyhow!("Bitcoin Core RPC response did not include a result")) + } +} + +fn build_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) + .build() + .expect("Failed to build reqwest client") +} + +fn entry_value<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a str> { + entries + .iter() + .find(|entry| entry.enabled && entry.key == key && !entry.value.trim().is_empty()) + .map(|entry| entry.value.trim()) +} + +fn network_from_entries(entries: &[ConfigEntry]) -> BitcoinNetwork { + if bool_entry(entries, "regtest") { + return BitcoinNetwork::Regtest; + } + if bool_entry(entries, "signet") { + return BitcoinNetwork::Signet; + } + if bool_entry(entries, "testnet4") { + return BitcoinNetwork::Testnet4; + } + if bool_entry(entries, "testnet") { + return BitcoinNetwork::Testnet; + } + + match entry_value(entries, "chain") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "test" | "testnet" | "testnet3" => BitcoinNetwork::Testnet, + "testnet4" => BitcoinNetwork::Testnet4, + "signet" => BitcoinNetwork::Signet, + "regtest" => BitcoinNetwork::Regtest, + _ => BitcoinNetwork::Mainnet, + } +} + +fn bool_entry(entries: &[ConfigEntry], key: &str) -> bool { + matches!( + entry_value(entries, key) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +fn default_rpc_port(network: BitcoinNetwork) -> u16 { + match network { + BitcoinNetwork::Mainnet => 8332, + BitcoinNetwork::Testnet => 18332, + BitcoinNetwork::Testnet4 => 48332, + BitcoinNetwork::Signet => 38332, + BitcoinNetwork::Regtest => 18443, + } +} + +fn rpc_url(host: &str, port: u16) -> String { + let host = host.trim().trim_matches('/'); + if host.starts_with("http://") || host.starts_with("https://") { + return host.to_string(); + } + if has_explicit_port(host) { + return format!("http://{host}"); + } + if host.contains(':') && !host.starts_with('[') { + return format!("http://[{host}]:{port}"); + } + format!("http://{host}:{port}") +} + +fn has_explicit_port(host: &str) -> bool { + if let Some(end_bracket) = host.find(']') { + return host[end_bracket + 1..].starts_with(':'); + } + + host.matches(':').count() == 1 + && host + .rsplit_once(':') + .is_some_and(|(_, port)| port.parse::().is_ok()) +} + +fn rpc_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Option<(String, String)> { + if let (Some(user), Some(pass)) = ( + entry_value(entries, "rpcuser"), + entry_value(entries, "rpcpassword"), + ) { + return Some((user.to_string(), pass.to_string())); + } + + read_cookie_auth(entries, network).ok() +} + +fn read_cookie_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Result<(String, String)> { + let cookie_path = cookie_path(entries, network); + let content = std::fs::read_to_string(&cookie_path) + .with_context(|| format!("could not read RPC cookie at {}", cookie_path.display()))?; + let (user, pass) = content + .trim() + .split_once(':') + .ok_or_else(|| anyhow!("RPC cookie did not contain username and password"))?; + + Ok((user.to_string(), pass.to_string())) +} + +fn cookie_path(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { + if let Some(path) = entry_value(entries, "rpccookiefile") { + let configured = PathBuf::from(path); + if configured.is_absolute() { + return configured; + } + return data_dir(entries, network).join(configured); + } + + data_dir(entries, network).join(".cookie") +} + +fn data_dir(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { + let base = entry_value(entries, "datadir") + .map(PathBuf::from) + .or_else(default_data_dir) + .unwrap_or_default(); + + match network { + BitcoinNetwork::Mainnet => base, + BitcoinNetwork::Testnet => base.join("testnet3"), + BitcoinNetwork::Testnet4 => base.join("testnet4"), + BitcoinNetwork::Signet => base.join("signet"), + BitcoinNetwork::Regtest => base.join("regtest"), + } +} + +fn default_data_dir() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".bitcoin")) +} + +fn display_network(chain: &str) -> &str { + match chain { + "main" => "mainnet", + "test" | "testnet" | "testnet3" | "testnet4" => "testnet", + "signet" => "signet", + "regtest" => "regtest", + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::{Matcher, Server}; + use serde_json::json; + + fn entry(key: &str, value: &str) -> ConfigEntry { + ConfigEntry { + key: key.to_string(), + value: value.to_string(), + schema: None, + enabled: true, + section: None, + } + } + + #[test] + fn builds_default_mainnet_endpoint() { + let client = BitcoinClient::from_config_entries(&[]); + + assert_eq!(client.url, "http://127.0.0.1:8332"); + } + + #[test] + fn uses_configured_rpc_port_and_auth() { + let entries = vec![ + entry("rpcport", "18443"), + entry("rpcuser", "alice"), + entry("rpcpassword", "secret"), + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:18443"); + assert_eq!( + client.auth_credentials, + Some(("alice".to_string(), "secret".to_string())) + ); + } + + #[test] + fn detects_network_from_chain_setting() { + let entries = vec![entry("chain", "testnet4")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:48332"); + } + + #[test] + fn preserves_rpcbind_with_explicit_port() { + let entries = vec![entry("rpcbind", "127.0.0.1:18443")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:18443"); + } + + #[tokio::test] + async fn fetch_chain_info_success() { + let mut server = Server::new_async().await; + + let chain_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 850_000u64, + "bestblockhash": "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", + "verificationprogress": 0.9999, + "initialblockdownload": false + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + let connections_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getconnectioncount".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "result": 8u64, "error": null, "id": "pdm" }).to_string()) + .create(); + let peers_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getpeerinfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": [ + { "addr": "192.0.2.1:8333" }, + { "addr": "203.0.113.5:8333" } + ], + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.network, "mainnet"); + assert_eq!(result.block_height, 850_000); + assert_eq!( + result.best_block_hash, + "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + ); + assert_eq!(result.verification_progress, Some(0.9999)); + assert_eq!(result.initial_block_download, Some(false)); + assert_eq!(result.connection_count, Some(8)); + assert_eq!( + result.connected_peer_addresses, + vec!["192.0.2.1:8333".to_string(), "203.0.113.5:8333".to_string(),] + ); + chain_mock.assert(); + connections_mock.assert(); + peers_mock.assert(); + } +} diff --git a/src/components/bitcoin_status_view.rs b/src/components/bitcoin_status_view.rs index c2b3017..eb89270 100644 --- a/src/components/bitcoin_status_view.rs +++ b/src/components/bitcoin_status_view.rs @@ -2,8 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::app::{App, BITCOIN_STATUS_TABS}; +use crate::app::{App, AppAction, BITCOIN_STATUS_TABS, BitcoinLogInputMode, ExplorerTrigger}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ prelude::*, widgets::{Block, Borders, Paragraph, Tabs, Wrap}, @@ -43,13 +44,7 @@ impl BitcoinStatusView { let content_area = outer[1]; match app.bitcoin_status_tab { // Chain Info - 0 => { - let text = "Chain Info"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 0 => Self::render_chain_info(f, app, content_area), // System 1 => { let text = "System"; @@ -59,24 +54,394 @@ impl BitcoinStatusView { f.render_widget(p, content_area); } // Logs - 2 => { - let text = "Logs"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 2 => Self::render_logs(f, app, content_area), // Peers - 3 => { - let text = "Peers"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 3 => Self::render_peers(f, app, content_area), _ => {} } } + + pub fn handle_logs_input(app: &mut App, key: KeyEvent) -> AppAction { + if let Some(mode) = app.bitcoin_log_input_mode { + return Self::handle_logs_text_input(app, key, mode); + } + + match key.code { + KeyCode::Char('/') => { + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::Search); + app.bitcoin_log_input = app.bitcoin_log_filter.clone(); + AppAction::None + } + KeyCode::Char('p') => { + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::LogFilePath); + app.bitcoin_log_input = app + .bitcoin_log_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + AppAction::None + } + KeyCode::Char('g') | KeyCode::Char('d') => { + app.bitcoin_log_input_mode = Some(BitcoinLogInputMode::DataDirPath); + app.bitcoin_log_input = app + .settings + .bitcoin_core_data_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + AppAction::None + } + KeyCode::Char('b') => AppAction::OpenExplorer(ExplorerTrigger::BitcoinCoreLogFile), + KeyCode::Char('o') => AppAction::OpenExplorer(ExplorerTrigger::BitcoinCoreDataDir), + KeyCode::Char('r') => AppAction::RefreshBitcoinLogs, + KeyCode::Char('a') => AppAction::ToggleBitcoinLogAutoScroll, + KeyCode::Char('c') => AppAction::CopyBitcoinLogs, + KeyCode::Esc if !app.bitcoin_log_filter.is_empty() => { + app.bitcoin_log_filter.clear(); + app.bitcoin_log_scroll = 0; + AppAction::None + } + KeyCode::Up => { + app.bitcoin_log_scroll = app.bitcoin_log_scroll.saturating_sub(1); + app.bitcoin_log_auto_scroll = app.bitcoin_log_scroll == 0; + AppAction::None + } + KeyCode::Down => { + app.bitcoin_log_scroll = Self::next_scroll(app, 1); + app.bitcoin_log_auto_scroll = false; + AppAction::None + } + KeyCode::PageUp => { + app.bitcoin_log_scroll = app.bitcoin_log_scroll.saturating_sub(10); + app.bitcoin_log_auto_scroll = app.bitcoin_log_scroll == 0; + AppAction::None + } + KeyCode::PageDown => { + app.bitcoin_log_scroll = Self::next_scroll(app, 10); + app.bitcoin_log_auto_scroll = false; + AppAction::None + } + KeyCode::Home => { + app.bitcoin_log_scroll = 0; + app.bitcoin_log_auto_scroll = true; + AppAction::None + } + KeyCode::End => { + app.bitcoin_log_scroll = Self::max_scroll(app); + app.bitcoin_log_auto_scroll = false; + AppAction::None + } + _ => AppAction::None, + } + } + + fn handle_logs_text_input( + app: &mut App, + key: KeyEvent, + mode: BitcoinLogInputMode, + ) -> AppAction { + match key.code { + KeyCode::Enter => { + let input = app.bitcoin_log_input.trim().to_string(); + app.bitcoin_log_input.clear(); + app.bitcoin_log_input_mode = None; + + match mode { + BitcoinLogInputMode::Search => { + app.bitcoin_log_filter = input; + app.bitcoin_log_scroll = 0; + AppAction::None + } + BitcoinLogInputMode::LogFilePath if input.is_empty() => { + app.bitcoin_log_status = "Log file path cannot be empty.".to_string(); + AppAction::None + } + BitcoinLogInputMode::LogFilePath => { + AppAction::SetBitcoinLogFile(std::path::PathBuf::from(input)) + } + BitcoinLogInputMode::DataDirPath if input.is_empty() => { + app.bitcoin_log_status = + "Bitcoin Core data directory cannot be empty.".to_string(); + AppAction::None + } + BitcoinLogInputMode::DataDirPath => { + AppAction::SetBitcoinLogDataDir(std::path::PathBuf::from(input)) + } + } + } + KeyCode::Esc => { + app.bitcoin_log_input.clear(); + app.bitcoin_log_input_mode = None; + AppAction::None + } + KeyCode::Backspace => { + app.bitcoin_log_input.pop(); + AppAction::None + } + KeyCode::Char(ch) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + app.bitcoin_log_input.push(ch); + AppAction::None + } + _ => AppAction::None, + } + } + + fn render_chain_info(f: &mut Frame, app: &App, area: Rect) { + let text = if app.bitcoin_conf_path.is_none() { + vec![Line::from(Span::styled( + "Select a bitcoin.conf file to load Bitcoin Core chain info.", + Style::default().fg(Color::DarkGray), + ))] + } else if let Some(info) = &app.bitcoin_chain_info { + vec![ + Line::from(format!("Network : {}", info.network)), + Line::from(format!("Block Height : {}", info.block_height)), + Line::from(format!("Best Block Hash : {}", info.best_block_hash)), + Line::from(format!( + "Verification Progress : {}", + Self::format_verification_progress(info.verification_progress) + )), + Line::from(format!( + "Initial Block Download : {}", + Self::format_optional_bool(info.initial_block_download) + )), + Line::from(format!( + "Connection Count : {}", + Self::format_optional_u64(info.connection_count) + )), + ] + } else if let Some(err) = &app.bitcoin_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch Bitcoin chain info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading Bitcoin chain info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Chain Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_peers(f: &mut Frame, app: &App, area: Rect) { + let text = if app.bitcoin_conf_path.is_none() { + vec![Line::from(Span::styled( + "Select a bitcoin.conf file to load Bitcoin Core peer info.", + Style::default().fg(Color::DarkGray), + ))] + } else if let Some(info) = &app.bitcoin_chain_info { + let mut lines = Vec::with_capacity(info.connected_peer_addresses.len() + 3); + lines.push(Line::from(format!( + "Connected Peers: {}", + info.connected_peer_addresses.len() + ))); + lines.push(Line::from("")); + lines.push(Line::from("Peer Addresses:")); + + if info.connected_peer_addresses.is_empty() { + lines.push(Line::from("None")); + } else { + lines.extend( + info.connected_peer_addresses + .iter() + .map(|address| Line::from(format!("* {address}"))), + ); + } + + lines + } else if let Some(err) = &app.bitcoin_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch Bitcoin peer info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading Bitcoin peer info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Peers ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_logs(f: &mut Frame, app: &App, area: Rect) { + let constraints = if app.bitcoin_log_input_mode.is_some() { + vec![ + Constraint::Length(6), + Constraint::Length(3), + Constraint::Min(0), + ] + } else { + vec![Constraint::Length(6), Constraint::Min(0)] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let path = app + .bitcoin_log_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| "(not configured)".to_string()); + let filtered_count = app.filtered_bitcoin_log_lines().len(); + let total_count = app.bitcoin_log_lines.len(); + let filter = if app.bitcoin_log_filter.trim().is_empty() { + "(none)".to_string() + } else { + app.bitcoin_log_filter.clone() + }; + let auto_scroll = if app.bitcoin_log_auto_scroll { + "on" + } else { + "off" + }; + + let summary = vec![ + Line::from(vec![ + Span::styled("Current log file path: ", Style::default().fg(Color::Gray)), + Span::raw(path), + ]), + Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Color::Gray)), + Span::raw(app.bitcoin_log_status.clone()), + ]), + Line::from(vec![ + Span::styled("Filter: ", Style::default().fg(Color::Gray)), + Span::raw(filter), + Span::styled(" Auto-scroll: ", Style::default().fg(Color::Gray)), + Span::raw(auto_scroll), + Span::styled(" Lines: ", Style::default().fg(Color::Gray)), + Span::raw(format!("{filtered_count}/{total_count}")), + ]), + Self::log_controls_line(), + ]; + + let summary_panel = Paragraph::new(summary) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Bitcoin Core Logs "), + ) + .wrap(Wrap { trim: false }); + f.render_widget(summary_panel, chunks[0]); + + let log_area = if app.bitcoin_log_input_mode.is_some() { + let input_panel = Paragraph::new(app.bitcoin_log_input.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title(Self::input_title(app.bitcoin_log_input_mode)), + ) + .style(Style::default().fg(Color::White)); + f.render_widget(input_panel, chunks[1]); + chunks[2] + } else { + chunks[1] + }; + + let log_lines = Self::log_lines(app); + let log_panel = Paragraph::new(log_lines) + .block(Block::default().borders(Borders::ALL).title(" debug.log ")) + .style(Style::default().bg(Color::Black).fg(Color::LightGreen)) + .scroll((app.bitcoin_log_scroll, 0)); + f.render_widget(log_panel, log_area); + } + + fn log_controls_line() -> Line<'static> { + Line::from(vec![ + Span::styled("[b] Browse log ", Style::default().fg(Color::Cyan)), + Span::styled("[o] Data dir ", Style::default().fg(Color::Cyan)), + Span::styled("[p] Path ", Style::default().fg(Color::Cyan)), + Span::styled("[g] Dir path ", Style::default().fg(Color::Cyan)), + Span::styled("[/] Search ", Style::default().fg(Color::Cyan)), + Span::styled("[r] Refresh ", Style::default().fg(Color::Cyan)), + Span::styled("[a] Auto ", Style::default().fg(Color::Cyan)), + Span::styled("[c] Copy", Style::default().fg(Color::Cyan)), + ]) + } + + fn input_title(mode: Option) -> &'static str { + match mode { + Some(BitcoinLogInputMode::Search) => " Search/filter logs ", + Some(BitcoinLogInputMode::LogFilePath) => " Bitcoin Core debug.log path ", + Some(BitcoinLogInputMode::DataDirPath) => " Bitcoin Core data directory ", + None => " Input ", + } + } + + fn log_lines(app: &App) -> Vec> { + if app.bitcoin_log_path.is_none() { + return vec![Line::from(Span::styled( + "No Bitcoin Core debug.log found. Choose a log file or Bitcoin data directory.", + Style::default().fg(Color::Yellow), + ))]; + } + + if app.bitcoin_log_lines.is_empty() { + return vec![Line::from(Span::styled( + app.bitcoin_log_status.clone(), + Style::default().fg(Color::DarkGray), + ))]; + } + + let filtered = app.filtered_bitcoin_log_lines(); + if filtered.is_empty() { + return vec![Line::from(Span::styled( + "No log entries match the current filter.", + Style::default().fg(Color::DarkGray), + ))]; + } + + filtered + .into_iter() + .map(|line| Line::from(Span::raw(line.to_string()))) + .collect() + } + + fn max_scroll(app: &App) -> u16 { + app.filtered_bitcoin_log_lines() + .len() + .saturating_sub(1) + .min(u16::MAX as usize) as u16 + } + + fn next_scroll(app: &App, delta: u16) -> u16 { + app.bitcoin_log_scroll + .saturating_add(delta) + .min(Self::max_scroll(app)) + } + + fn format_verification_progress(progress: Option) -> String { + progress.map_or_else(|| "-".to_string(), |value| format!("{:.2}%", value * 100.0)) + } + + fn format_optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "-", + } + } + + fn format_optional_u64(value: Option) -> String { + value.map_or_else(|| "-".to_string(), |value| value.to_string()) + } } impl Default for BitcoinStatusView { diff --git a/src/components/mod.rs b/src/components/mod.rs index 65ecad5..e55d93a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,14 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod bitcoin_client; pub mod bitcoin_config_view; pub mod bitcoin_status_view; pub mod file_explorer; pub mod home_view; pub mod ln_config_view; pub mod ln_status_view; +pub mod p2pool_client; pub mod p2pool_config_view; pub mod p2pool_status_view; +pub mod p2pool_websocket; pub mod settings_view; pub mod shares_market_view; pub mod status_bar; diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs new file mode 100644 index 0000000..d063760 --- /dev/null +++ b/src/components/p2pool_client.rs @@ -0,0 +1,522 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::components::p2pool_websocket::P2PoolWebSocketClient; +use crate::config::{ApiConfig, load_api_config}; +use reqwest::Client; +use serde::Deserialize; +use serde::Deserializer; +use serde::de::DeserializeOwned; +use std::time::Duration; + +const REQUEST_TIMEOUT_SECONDS: u64 = 10; + +#[derive(Debug, Clone)] +pub struct P2PoolClient { + client: Client, + base_url: String, + fallback_base_url: Option, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChainInfo { + pub genesis_blockhash: Option, + pub chain_tip_height: Option, + pub total_work: String, + pub chain_tip_blockhash: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PeerInfo { + pub peer_id: String, + pub status: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ShareInfo { + pub blockhash: String, + pub prev_blockhash: String, + pub height: u64, + pub miner_address: String, + pub timestamp: u64, + #[serde(deserialize_with = "deserialize_string_or_number")] + pub bits: String, + #[serde(default)] + pub uncles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UncleInfo { + pub blockhash: String, + pub prev_blockhash: String, + pub miner_address: String, + pub timestamp: u64, + pub height: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SharesResponse { + pub from_height: u64, + pub to_height: u64, + #[serde(default)] + pub shares: Vec, +} + +fn build_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) + .build() + .expect("Failed to build reqwest client") +} + +impl P2PoolClient { + pub fn new() -> Self { + Self::from_config(load_api_config().unwrap_or_default()) + } + + fn from_config(config: ApiConfig) -> Self { + let client = P2PoolClient::with_base_url(&config.base_url); + + let client = if let Some(fallback) = &config.fallback_base_url { + client.with_fallback_base_url(fallback) + } else { + client + }; + + if let Some((user, pass)) = config.auth_user.zip(config.auth_pass) { + client.with_auth(user, pass) + } else { + client + } + } + + pub fn with_base_url(base_url: impl Into) -> Self { + Self { + client: build_client(), + base_url: base_url.into(), + fallback_base_url: None, + auth_credentials: None, + } + } + + pub fn with_client(client: Client, base_url: impl Into) -> Self { + Self { + client, + base_url: base_url.into(), + fallback_base_url: None, + auth_credentials: None, + } + } + + pub fn with_auth(mut self, user: String, pass: String) -> Self { + self.auth_credentials = Some((user, pass)); + self + } + + pub fn with_fallback_base_url(mut self, fallback_base_url: impl Into) -> Self { + self.fallback_base_url = Some(fallback_base_url.into()); + self + } + + pub fn websocket_client(&self) -> P2PoolWebSocketClient { + let mut client = P2PoolWebSocketClient::with_base_url(self.base_url.clone()); + if let Some((user, pass)) = &self.auth_credentials { + client = client.with_auth(user.clone(), pass.clone()); + } + if let Some(fallback_base_url) = &self.fallback_base_url { + client = client.with_fallback_base_url(fallback_base_url.clone()); + } + client + } + + pub async fn fetch_chain_info(&self) -> Result { + self.fetch_json_with_fallback("/chain_info", &[]).await + } + + pub async fn fetch_peer_info(&self) -> Result, reqwest::Error> { + self.fetch_json_with_fallback("/peers", &[]).await + } + + pub async fn fetch_recent_shares(&self, num: u16) -> Result { + self.fetch_json_with_fallback("/shares", &[("num", num.min(100))]) + .await + } + + async fn fetch_json_with_fallback( + &self, + path: &str, + query: &[(&str, u16)], + ) -> Result + where + T: DeserializeOwned, + { + match self + .fetch_json_from_base_url(&self.base_url, path, query, true) + .await + { + Ok(data) => Ok(data), + Err(error) => { + if self.should_try_fallback(&error) { + if let Some(fallback_base_url) = &self.fallback_base_url { + return self + .fetch_json_from_base_url(fallback_base_url, path, query, false) + .await; + } + } + Err(error) + } + } + } + + async fn fetch_json_from_base_url( + &self, + base_url: &str, + path: &str, + query: &[(&str, u16)], + use_auth: bool, + ) -> Result + where + T: DeserializeOwned, + { + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + let mut request = self.client.get(url); + + if !query.is_empty() { + request = request.query(query); + } + + if use_auth { + if let Some((user, pass)) = &self.auth_credentials { + request = request.basic_auth(user, Some(pass)); + } + } + + let response = request.send().await?.error_for_status()?; + response.json::().await + } + + fn should_try_fallback(&self, error: &reqwest::Error) -> bool { + self.fallback_base_url.is_some() && (error.is_connect() || error.is_timeout()) + } +} + +impl Default for P2PoolClient { + fn default() -> Self { + Self::new() + } +} + +fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(u64), + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(value) => Ok(value), + StringOrNumber::Number(value) => Ok(value.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::{Matcher, Server}; + use serde_json::json; + + const PRIMARY_BASE_URL: &str = "http://127.0.0.1:46884"; + const FALLBACK_BASE_URL: &str = "http://127.0.0.1:46885"; + + fn api_config(fallback_base_url: Option<&str>) -> ApiConfig { + ApiConfig { + base_url: PRIMARY_BASE_URL.to_string(), + fallback_base_url: fallback_base_url.map(str::to_string), + auth_user: None, + auth_pass: None, + } + } + + #[test] + fn explicit_base_url_does_not_enable_network_fallback() { + let config = api_config(None); + let client = P2PoolClient::from_config(config); + + assert_eq!(client.base_url, PRIMARY_BASE_URL); + assert_eq!(client.fallback_base_url, None); + } + + #[test] + fn fallback_base_url_can_be_configured() { + let config = api_config(Some(FALLBACK_BASE_URL)); + let client = P2PoolClient::from_config(config); + + assert_eq!(client.fallback_base_url.as_deref(), Some(FALLBACK_BASE_URL)); + } + + #[tokio::test] + async fn test_fetch_chain_info_success() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "genesis_blockhash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "chain_tip_height": 850_000u64, + "total_work": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chain_tip_blockhash": "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.chain_tip_height, Some(850_000)); + assert_eq!( + result.total_work, + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ); + assert_eq!( + result.genesis_blockhash.unwrap(), + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + ); + assert_eq!( + result.chain_tip_blockhash.unwrap(), + "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + ); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_chain_info_sends_basic_auth() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/chain_info") + .match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "total_work": "abc" }).to_string()) + .create(); + + let client = + P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into()); + + client.fetch_chain_info().await.unwrap(); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_success() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!([ + { + "peer_id": "12D3KooWPeerOne", + "status": "Connected" + } + ]) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_peer_info().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].peer_id, "12D3KooWPeerOne"); + assert_eq!(result[0].status.as_deref(), Some("Connected")); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_accepts_missing_status() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!([{ "peer_id": "12D3KooWPeerOne" }]).to_string()) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_peer_info().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].peer_id, "12D3KooWPeerOne"); + assert_eq!(result[0].status, None); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_sends_basic_auth() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!([]).to_string()) + .create(); + + let client = + P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into()); + + client.fetch_peer_info().await.unwrap(); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_recent_shares_success() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/shares") + .match_query(Matcher::UrlEncoded("num".into(), "2".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "from_height": 41, + "to_height": 42, + "shares": [ + { + "blockhash": "0000share", + "prev_blockhash": "ffffprev", + "height": 42, + "miner_address": "miner-address", + "timestamp": 1700000000u64, + "bits": "1d00ffff", + "uncles": [ + { + "blockhash": "0000uncle", + "prev_blockhash": "ffffuncleprev", + "miner_address": "uncle-miner", + "timestamp": 1699999999u64, + "height": 41 + } + ] + } + ] + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_recent_shares(2).await.unwrap(); + + assert_eq!(result.from_height, 41); + assert_eq!(result.to_height, 42); + assert_eq!(result.shares.len(), 1); + assert_eq!(result.shares[0].height, 42); + assert_eq!(result.shares[0].uncles.len(), 1); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_recent_shares_accepts_numeric_bits() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/shares") + .match_query(Matcher::UrlEncoded("num".into(), "1".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "from_height": 42, + "to_height": 42, + "shares": [ + { + "blockhash": "0000share", + "prev_blockhash": "ffffprev", + "height": 42, + "miner_address": "miner-address", + "timestamp": 1700000000u64, + "bits": 454130449, + "uncles": [] + } + ] + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_recent_shares(1).await.unwrap(); + + assert_eq!(result.shares[0].bits, "454130449"); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_chain_info_errors_on_http_500() { + let mut server = Server::new_async().await; + + server.mock("GET", "/chain_info").with_status(500).create(); + + let client = P2PoolClient::with_base_url(server.url()); + assert!(client.fetch_chain_info().await.is_err()); + } + + #[tokio::test] + async fn test_fetch_chain_info_returns_error_on_missing_required_field() { + let mut server = Server::new_async().await; + + server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "chain_tip_height": 100 }).to_string()) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + assert!(client.fetch_chain_info().await.is_err()); + } + + #[tokio::test] + async fn test_with_client_can_be_injected_for_isolated_tests() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "genesis_blockhash": null, + "chain_tip_height": 1, + "total_work": "abc", + "chain_tip_blockhash": null + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_client(build_client(), server.url()); + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.chain_tip_height, Some(1)); + assert_eq!(result.total_work, "abc"); + mock.assert(); + } +} diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index e4fab69..2479dd0 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -2,15 +2,26 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::app::App; +use crate::app::{App, P2POOL_STATUS_TABS}; use ratatui::{ prelude::*, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap}, }; +use std::collections::HashSet; #[derive(Debug, Clone)] pub struct P2PoolStatusView; +#[derive(Debug)] +struct ShareTableEntry { + height: u64, + blockhash: String, + miner: String, + bits: String, + timestamp: u64, + uncles: usize, +} + impl P2PoolStatusView { #[must_use] pub fn new() -> Self { @@ -18,13 +29,394 @@ impl P2PoolStatusView { } // P2Pool Status - pub fn render(f: &mut Frame, _app: &mut App, area: Rect) { - let p = Paragraph::new("P2Pool Status").block( - Block::default() - .borders(Borders::ALL) - .title(" P2Pool Status "), - ); - f.render_widget(p, area); + pub fn render(f: &mut Frame, app: &App, area: Rect) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // Tabs bar + Constraint::Min(0), // Content area + ]) + .split(area); + + let tabs = Tabs::new(P2POOL_STATUS_TABS.to_vec()) + .block(Block::default().borders(Borders::ALL).title(" Info ")) + .select(app.p2pool_status_tab) + .highlight_style(Style::default().bg(Color::Gray).fg(Color::Black)); + + f.render_widget(tabs, outer[0]); + + match app.p2pool_status_tab { + 0 => Self::render_chain_info(f, app, outer[1]), + 1 => Self::render_share_info(f, app, outer[1]), + 2 => Self::render_peer_info(f, app, outer[1]), + _ => {} + } + } + + fn render_chain_info(f: &mut Frame, app: &App, area: Rect) { + let text = if let Some(info) = &app.chain_info { + vec![ + Line::from(format!( + "Genesis Blockhash : {}", + info.genesis_blockhash.as_deref().unwrap_or("-") + )), + Line::from(format!( + "Chain Tip Height : {}", + info.chain_tip_height.unwrap_or(0) + )), + Line::from(format!( + "Chain Tip Blockhash : {}", + info.chain_tip_blockhash.as_deref().unwrap_or("-") + )), + Line::from(format!("Total Work : {}", info.total_work)), + ] + } else if let Some(err) = &app.p2pool_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch chain info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading chain info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Chain Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_share_info(f: &mut Frame, app: &App, area: Rect) { + let mut rows = Self::share_rows(app); + if rows.is_empty() { + rows.push(Self::message_row(Self::share_empty_message(app))); + } + + let header = Row::new([ + "Height", + "Blockhash", + "Miner", + "Difficulty", + "Time", + "Uncles", + ]) + .style( + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + ) + .bottom_margin(1); + let widths = [ + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Min(12), + Constraint::Length(6), + ]; + + let table = Table::new(rows, widths) + .block(Block::default().borders(Borders::ALL).title(" Shares ")) + .header(header) + .column_spacing(1) + .style(Style::default().fg(Color::White)); + + f.render_widget(table, area); + } + + fn render_peer_info(f: &mut Frame, app: &App, area: Rect) { + let mut text = if let Some(peers) = &app.peer_info { + if peers.is_empty() { + vec![Line::from(Span::styled( + "No connected peers", + Style::default().fg(Color::DarkGray), + ))] + } else { + let mut lines = Vec::with_capacity(peers.len() + 2); + lines.push(Line::from(format!( + "Connected Peers : {}", + peers.len() + ))); + lines.push(Line::from("")); + + for peer in peers { + lines.push(Line::from(format!( + "{} ({})", + peer.peer_id, + peer.status.as_deref().unwrap_or("Connected") + ))); + } + + lines + } + } else if let Some(err) = &app.p2pool_peer_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch peer info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading peer info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + if let Some(err) = &app.p2pool_live_error { + text.push(Line::from("")); + text.push(Line::from(Span::styled( + format!("Live stream error: {err}"), + Style::default().fg(Color::Red), + ))); + } + + if !app.live_peer_events.is_empty() { + text.push(Line::from("")); + text.push(Line::from(Span::styled( + "Live Peer Events", + Style::default().add_modifier(Modifier::BOLD), + ))); + for event in app.live_peer_events.iter().rev().take(8) { + text.push(Line::from(format!( + "{}: {}", + event.status, + Self::short_value(&event.peer_id, 42) + ))); + } + } + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Peers Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn short_value(value: &str, max_len: usize) -> String { + if value.len() <= max_len { + return value.to_string(); + } + + if max_len <= 3 { + return value.chars().take(max_len).collect(); + } + + let head_len = (max_len - 3) / 2; + let tail_len = max_len - 3 - head_len; + let head: String = value.chars().take(head_len).collect(); + let tail: String = value + .chars() + .rev() + .take(tail_len) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}...{tail}") + } + + fn share_rows(app: &App) -> Vec> { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + + for share in app.live_shares.iter().rev() { + seen.insert(share.blockhash.clone()); + entries.push(ShareTableEntry { + height: share.height, + blockhash: share.blockhash.clone(), + miner: share.miner_address.clone(), + bits: share.bits.clone(), + timestamp: share.timestamp, + uncles: share.uncles.len(), + }); + } + + if let Some(info) = &app.share_info { + for share in info.shares.iter().rev() { + if seen.insert(share.blockhash.clone()) { + entries.push(ShareTableEntry { + height: share.height, + blockhash: share.blockhash.clone(), + miner: share.miner_address.clone(), + bits: share.bits.clone(), + timestamp: share.timestamp, + uncles: share.uncles.len(), + }); + } + } + } + + entries.sort_by(|left, right| { + right + .height + .cmp(&left.height) + .then_with(|| right.timestamp.cmp(&left.timestamp)) + }); + + entries + .into_iter() + .take(50) + .map(|entry| { + Self::share_row( + entry.height, + &entry.blockhash, + &entry.miner, + &entry.bits, + entry.timestamp, + entry.uncles, + ) + }) + .collect() + } + + fn share_row( + height: u64, + blockhash: &str, + miner: &str, + bits: &str, + timestamp: u64, + uncles: usize, + ) -> Row<'static> { + Row::new(vec![ + Cell::from(height.to_string()), + Self::chip(Self::short_value(blockhash, 10)), + Self::chip(Self::short_value(miner, 10)), + Cell::from(Self::format_difficulty(bits)), + Cell::from(Self::format_timestamp(timestamp)), + Cell::from(uncles.to_string()), + ]) + .height(1) + } + + fn message_row(message: String) -> Row<'static> { + Row::new(vec![ + Cell::from(Span::styled(message, Style::default().fg(Color::DarkGray))), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]) + } + + fn share_empty_message(app: &App) -> String { + if let Some(err) = &app.p2pool_share_info_error { + return format!("Recent shares unavailable: {}", Self::short_value(err, 64)); + } + + if let Some(err) = &app.p2pool_live_error { + return format!("Live shares unavailable: {}", Self::short_value(err, 64)); + } + + "Waiting for share data...".to_string() + } + + fn chip(value: String) -> Cell<'static> { + Cell::from(Span::styled( + value, + Style::default().fg(Color::Gray).bg(Color::Black), + )) + } + + fn format_difficulty(bits: &str) -> String { + let Some(bits) = Self::parse_bits(bits) else { + return Self::short_value(bits, 10); + }; + + let exponent = (bits >> 24) as i32; + let mantissa = bits & 0x00ff_ffff; + if mantissa == 0 { + return "-".to_string(); + } + + let difficulty = (0x00ff_ff_u32 as f64 / mantissa as f64) * 256_f64.powi(0x1d - exponent); + if !difficulty.is_finite() || difficulty <= 0.0 { + return "-".to_string(); + } + + if difficulty >= 100.0 { + return Self::format_integer_with_commas(difficulty.round() as u64); + } + + if difficulty >= 1.0 { + return format!("{difficulty:.2}"); + } + + format!("{difficulty:.4}") + } + + fn parse_bits(bits: &str) -> Option { + let value = bits.trim(); + if value.is_empty() { + return None; + } + + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + return u32::from_str_radix(hex, 16).ok(); + } + + if value + .chars() + .any(|c| c.is_ascii_hexdigit() && c.is_ascii_alphabetic()) + { + return u32::from_str_radix(value, 16).ok(); + } + + value.parse::().ok() + } + + fn format_integer_with_commas(value: u64) -> String { + let digits = value.to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + formatted.push(','); + } + formatted.push(digit); + } + formatted.chars().rev().collect() + } + + fn format_timestamp(timestamp: u64) -> String { + let timestamp = if timestamp > 10_000_000_000 { + timestamp / 1_000 + } else { + timestamp + }; + let days = (timestamp / 86_400) as i64; + let seconds = timestamp % 86_400; + let hour = seconds / 3_600; + let minute = (seconds % 3_600) / 60; + let second = seconds % 60; + let (year, month, day) = Self::civil_from_days(days); + let suffix = if hour < 12 { "AM" } else { "PM" }; + let hour = match hour % 12 { + 0 => 12, + value => value, + }; + + format!("{month}/{day}/{year}, {hour}:{minute:02}:{second:02} {suffix}") + } + + fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_param = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_param + 2) / 5 + 1; + let month = month_param + if month_param < 10 { 3 } else { -9 }; + let year = year + if month <= 2 { 1 } else { 0 }; + + (year as i32, month as u32, day as u32) } } diff --git a/src/components/p2pool_websocket.rs b/src/components/p2pool_websocket.rs new file mode 100644 index 0000000..fa3f967 --- /dev/null +++ b/src/components/p2pool_websocket.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::config::{ApiConfig, load_api_config}; +use anyhow::{Context, Result}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Deserializer}; +use tokio::sync::mpsc; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use url::Url; + +#[derive(Debug, Clone)] +pub struct P2PoolWebSocketClient { + base_url: String, + fallback_base_url: Option, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ShareEventData { + pub blockhash: String, + pub prev_blockhash: String, + pub height: u64, + pub miner_address: String, + pub timestamp: u64, + #[serde(deserialize_with = "deserialize_string_or_number")] + pub bits: String, + #[serde(default)] + pub uncles: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PeerEventData { + pub peer_id: String, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "topic", content = "data")] +pub enum WebSocketEvent { + #[serde(rename = "Share")] + Share(ShareEventData), + #[serde(rename = "Peer")] + Peer(PeerEventData), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveShare { + pub blockhash: String, + pub prev_blockhash: String, + pub height: u64, + pub miner_address: String, + pub timestamp: u64, + pub bits: String, + pub uncles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LivePeerEvent { + pub peer_id: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LiveP2PoolEvent { + Share(LiveShare), + Peer(LivePeerEvent), +} + +impl P2PoolWebSocketClient { + pub fn new() -> Self { + Self::from_config(load_api_config().unwrap_or_default()) + } + + fn from_config(config: ApiConfig) -> Self { + let client = P2PoolWebSocketClient::with_base_url(&config.base_url); + + let client = if let Some(fallback) = &config.fallback_base_url { + client.with_fallback_base_url(fallback) + } else { + client + }; + + if let Some((user, pass)) = config.auth_user.zip(config.auth_pass) { + client.with_auth(user, pass) + } else { + client + } + } + + pub fn with_base_url(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + fallback_base_url: None, + auth_credentials: None, + } + } + + pub fn with_auth(mut self, user: String, pass: String) -> Self { + self.auth_credentials = Some((user, pass)); + self + } + + pub fn with_fallback_base_url(mut self, fallback_base_url: impl Into) -> Self { + self.fallback_base_url = Some(fallback_base_url.into()); + self + } + + fn ws_url(&self, path: &str) -> Result { + self.ws_url_from_base_url(&self.base_url, path) + } + + fn ws_url_from_base_url(&self, base_url: &str, path: &str) -> Result { + let mut url = Url::parse(base_url) + .with_context(|| format!("Failed to parse base URL: {base_url}"))?; + + match url.scheme() { + "http" => url.set_scheme("ws").unwrap(), + "https" => url.set_scheme("wss").unwrap(), + _ => {} + } + + url.set_path(path); + Ok(url) + } + + fn ws_url_with_auth(&self, path: &str) -> Result { + let mut url = self.ws_url(path)?; + if let Some((user, pass)) = &self.auth_credentials { + let token = STANDARD.encode(format!("{}:{}", user, pass)); + url.query_pairs_mut().append_pair("token", &token); + } + Ok(url) + } + + pub async fn subscribe_live_events( + &self, + tx: mpsc::UnboundedSender>, + ) -> anyhow::Result<()> { + let url = self.ws_url_with_auth("/ws")?; + match self.subscribe_live_events_at(url, tx.clone()).await { + Ok(()) => Ok(()), + Err(primary_error) => { + if let Some(fallback_base_url) = &self.fallback_base_url { + let fallback_url = self.ws_url_from_base_url(fallback_base_url, "/ws")?; + if self + .subscribe_live_events_at(fallback_url, tx) + .await + .is_ok() + { + return Ok(()); + } + } + Err(primary_error) + } + } + } + + async fn subscribe_live_events_at( + &self, + url: Url, + tx: mpsc::UnboundedSender>, + ) -> anyhow::Result<()> { + let (stream, _) = connect_async(url.as_str()).await?; + let (mut write, mut read) = stream.split(); + + for topic in ["shares", "peers"] { + let subscribe_message = serde_json::json!({ + "action": "subscribe", + "topic": topic, + }) + .to_string(); + write.send(Message::Text(subscribe_message)).await?; + } + + while let Some(message_result) = read.next().await { + let message = message_result?; + if let Message::Text(text) = message { + match serde_json::from_str::(&text) { + Ok(WebSocketEvent::Share(data)) => { + let live_share = LiveShare { + blockhash: data.blockhash, + prev_blockhash: data.prev_blockhash, + height: data.height, + miner_address: data.miner_address, + timestamp: data.timestamp, + bits: data.bits, + uncles: data.uncles, + }; + let _ = tx.send(Ok(LiveP2PoolEvent::Share(live_share))); + } + Ok(WebSocketEvent::Peer(data)) => { + let live_peer = LivePeerEvent { + peer_id: data.peer_id, + status: data.status, + }; + let _ = tx.send(Ok(LiveP2PoolEvent::Peer(live_peer))); + } + Err(error) => { + let _ = tx.send(Err(anyhow::Error::new(error))); + } + } + } + } + + Ok(()) + } +} + +impl Default for P2PoolWebSocketClient { + fn default() -> Self { + Self::new() + } +} + +fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(u64), + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(value) => Ok(value), + StringOrNumber::Number(value) => Ok(value.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ws_url_converts_http_to_ws_and_encodes_auth_token() { + let client = P2PoolWebSocketClient::with_base_url("http://127.0.0.1:46884") + .with_auth("user".into(), "password".into()); + + let url = client.ws_url_with_auth("/ws").unwrap(); + + assert_eq!( + url.as_str(), + "ws://127.0.0.1:46884/ws?token=dXNlcjpwYXNzd29yZA%3D%3D" + ); + } + + #[test] + fn ws_url_converts_https_fallback_to_wss() { + let client = P2PoolWebSocketClient::with_base_url("https://127.0.0.1:46884"); + + let url = client.ws_url("/ws").unwrap(); + + assert_eq!(url.as_str(), "wss://127.0.0.1:46884/ws"); + } + + #[test] + fn websocket_event_accepts_share_messages() { + let event: WebSocketEvent = serde_json::from_value(serde_json::json!({ + "topic": "Share", + "data": { + "blockhash": "0000", + "prev_blockhash": "ffff", + "height": 42, + "miner_address": "miner", + "timestamp": 1700000000, + "bits": "1d00ffff", + "uncles": ["aaaa"] + } + })) + .unwrap(); + + assert!(matches!(event, WebSocketEvent::Share(_))); + } + + #[test] + fn websocket_event_accepts_numeric_bits() { + let event: WebSocketEvent = serde_json::from_value(serde_json::json!({ + "topic": "Share", + "data": { + "blockhash": "0000", + "prev_blockhash": "ffff", + "height": 42, + "miner_address": "miner", + "timestamp": 1700000000, + "bits": 454130449, + "uncles": [] + } + })) + .unwrap(); + + let WebSocketEvent::Share(data) = event else { + panic!("expected share event"); + }; + assert_eq!(data.bits, "454130449"); + } + + #[test] + fn websocket_event_accepts_peer_messages() { + let event: WebSocketEvent = serde_json::from_value(serde_json::json!({ + "topic": "Peer", + "data": { + "peer_id": "12D3KooWPeerOne", + "status": "Connected" + } + })) + .unwrap(); + + assert!(matches!(event, WebSocketEvent::Peer(_))); + } +} diff --git a/src/components/settings_view.rs b/src/components/settings_view.rs index 58b02c6..5b05c53 100644 --- a/src/components/settings_view.rs +++ b/src/components/settings_view.rs @@ -10,7 +10,7 @@ use ratatui::{ }; /// Number of settings fields. -pub const FIELD_COUNT: usize = 5; +pub const FIELD_COUNT: usize = 7; /// Describes how a settings field behaves when Enter is pressed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,6 +27,8 @@ pub const FIELDS: [(&str, FieldKind); FIELD_COUNT] = [ ("P2Pool config path", FieldKind::FilePicker), ("LN config path", FieldKind::FilePicker), ("Shares Market config path", FieldKind::FilePicker), + ("Bitcoin Core data directory", FieldKind::DirectoryPicker), + ("Bitcoin Core log file", FieldKind::FilePicker), ("Settings directory", FieldKind::DirectoryPicker), ]; @@ -96,6 +98,14 @@ impl SettingsView { .shares_market_conf_path .as_ref() .map(|p| p.to_string_lossy().into_owned()), + app.settings + .bitcoin_core_data_dir + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + app.settings + .bitcoin_core_log_path + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), app.settings .settings_dir_override .as_ref() @@ -114,7 +124,7 @@ impl SettingsView { .add_modifier(Modifier::BOLD), ), None => { - if idx == 4 { + if idx == 6 { let path = if app.config_dir.as_os_str().is_empty() { "(unknown)".to_string() } else { @@ -272,6 +282,9 @@ mod tests { app.settings.p2pool_conf_path = Some(std::path::PathBuf::from("/tmp/p2pool.toml")); app.settings.ln_conf_path = Some(std::path::PathBuf::from("/tmp/ln.conf")); app.settings.shares_market_conf_path = Some(std::path::PathBuf::from("/tmp/shares.conf")); + app.settings.bitcoin_core_data_dir = Some(std::path::PathBuf::from("/tmp/bitcoin")); + app.settings.bitcoin_core_log_path = + Some(std::path::PathBuf::from("/tmp/bitcoin/debug.log")); app.settings.settings_dir_override = Some(std::path::PathBuf::from("/custom/dir")); app.settings_view.sidebar_focused = false; @@ -297,7 +310,7 @@ mod tests { #[test] #[serial_test::serial] - fn render_field4_shows_default_config_dir_when_no_override() { + fn render_field6_shows_default_config_dir_when_no_override() { use crate::app::App; use ratatui::Terminal; use ratatui::backend::TestBackend; diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index 268346d..59f6f76 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -90,7 +90,9 @@ impl StatusBar { 1 => s.p2pool_conf_path.is_some(), 2 => s.ln_conf_path.is_some(), 3 => s.shares_market_conf_path.is_some(), - 4 => s.settings_dir_override.is_some(), + 4 => s.bitcoin_core_data_dir.is_some(), + 5 => s.bitcoin_core_log_path.is_some(), + 6 => s.settings_dir_override.is_some(), _ => false, }; spans.extend(hint("↑↓", "Navigate")); @@ -108,7 +110,22 @@ impl StatusBar { spans.extend(hint("Esc", "Back")); } } - CurrentScreen::BitcoinStatus => { + CurrentScreen::BitcoinStatus if app.bitcoin_status_tab == 2 => { + if app.bitcoin_log_input_mode.is_some() { + spans.extend(hint("Enter", "Apply")); + spans.extend(hint("Esc", "Cancel")); + spans.extend(hint("⌫", "Delete")); + } else { + spans.extend(hint("↑↓", "Scroll logs")); + spans.extend(hint("←→", "Switch tab")); + spans.extend(hint("b", "Browse log")); + spans.extend(hint("o", "Browse dir")); + spans.extend(hint("/", "Search")); + spans.extend(hint("r", "Refresh")); + spans.extend(hint("q", "Quit")); + } + } + CurrentScreen::BitcoinStatus | CurrentScreen::P2PoolStatus => { spans.extend(hint("↑↓", "Navigate sidebar")); spans.extend(hint("←→", "Switch tab")); spans.extend(hint("q", "Quit")); @@ -358,9 +375,32 @@ mod tests { let mut app = App::new(); app.current_screen = CurrentScreen::Settings; app.settings_view.sidebar_focused = false; - app.settings_view.selected_index = 4; + app.settings_view.selected_index = 6; app.settings.settings_dir_override = Some(std::path::PathBuf::from("/custom/dir")); let output = render_status_bar(&app); assert!(output.contains("Clear")); } + + #[test] + fn settings_content_bitcoin_core_data_dir_field_set_shows_clear() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = false; + app.settings_view.selected_index = 4; + app.settings.bitcoin_core_data_dir = Some(std::path::PathBuf::from("/tmp/bitcoin")); + let output = render_status_bar(&app); + assert!(output.contains("Clear")); + } + + #[test] + fn settings_content_bitcoin_core_log_path_field_set_shows_clear() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = false; + app.settings_view.selected_index = 5; + app.settings.bitcoin_core_log_path = + Some(std::path::PathBuf::from("/tmp/bitcoin/debug.log")); + let output = render_status_bar(&app); + assert!(output.contains("Clear")); + } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a70f086 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use anyhow::Result; +use config::{Config, File}; + +const DEFAULT_API_HOST: &str = "127.0.0.1"; +const DEFAULT_API_PORT: u16 = 9332; + +#[derive(Debug, Clone)] +pub struct ApiConfig { + pub base_url: String, + pub fallback_base_url: Option, + pub auth_user: Option, + pub auth_pass: Option, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: format!("http://{}:{}", DEFAULT_API_HOST, DEFAULT_API_PORT), + fallback_base_url: None, + auth_user: None, + auth_pass: None, + } + } +} + +pub fn load_api_config() -> Result { + let settings = Config::builder() + .add_source(File::with_name("config/config").required(false)) + .add_source( + File::with_name(concat!(env!("CARGO_MANIFEST_DIR"), "/config/config")).required(false), + ) + .build()?; + + let host: String = settings + .get("api.host") + .unwrap_or_else(|_| DEFAULT_API_HOST.to_string()); + let port: u16 = settings.get("api.port").unwrap_or(DEFAULT_API_PORT); + let base_url: String = settings + .get("api.base_url") + .unwrap_or_else(|_| format!("http://{}:{}", host, port)); + let fallback_base_url: Option = settings + .get("api.fallback_base_url") + .ok() + .filter(|url: &String| !url.trim().is_empty()); + let auth_user: Option = settings.get("api.auth_user").ok(); + let auth_pass: Option = settings.get("api.auth_pass").ok(); + + Ok(ApiConfig { + base_url, + fallback_base_url, + auth_user, + auth_pass, + }) +} diff --git a/src/lib.rs b/src/lib.rs index 82d6883..f472861 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,9 @@ pub mod app; pub mod bitcoin_config; +pub mod bitcoin_logs; pub mod components; +pub mod config; pub mod p2poolv2_config; pub mod settings; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 0771b47..c584a42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,12 +4,16 @@ use p2poolv2_config::Config as P2PoolConfig; use pdm::app::{ - App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_SIDEBAR_INDEX, + App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_P2POOL_STATUS_TAB, + MAX_SIDEBAR_INDEX, }; use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, }; -use pdm::components::settings_view::{FIELDS, FieldKind}; +use pdm::components::{ + bitcoin_status_view::BitcoinStatusView, + settings_view::{FIELDS, FieldKind}, +}; use pdm::p2poolv2_config::{apply_edit as apply_p2pool_edit, flatten_config}; use pdm::settings::{load_settings, save_settings}; use pdm::ui; @@ -22,9 +26,10 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Terminal, backend::Backend, backend::CrosstermBackend}; -use std::io; +use std::{io, time::Duration}; -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { // Setup Terminal enable_raw_mode()?; let mut stdout = io::stdout(); @@ -69,8 +74,19 @@ where ::Error: Send + Sync + 'static, { loop { + app.poll_bitcoin_chain_info(); + app.poll_bitcoin_logs(); + app.maybe_refresh_bitcoin_logs(); + app.poll_chain_info(); + app.poll_share_info(); + app.poll_peer_info(); + app.poll_live_p2pool_events(); terminal.draw(|f| ui::ui(f, app))?; + if !event::poll(Duration::from_millis(250))? { + continue; + } + if let Event::Key(key) = event::read()? { if key.kind != KeyEventKind::Press { continue; @@ -83,7 +99,10 @@ where && app.bitcoin_config_view.editing) || (app.current_screen == CurrentScreen::P2PoolConfig && !app.p2pool_config_view.sidebar_focused - && app.p2pool_config_view.editing); + && app.p2pool_config_view.editing) + || (app.current_screen == CurrentScreen::BitcoinStatus + && app.bitcoin_status_tab == 2 + && app.bitcoin_log_input_mode.is_some()); if (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c')) || (!text_input_active && key.code == KeyCode::Char('q')) @@ -94,16 +113,47 @@ where let action = match app.current_screen { CurrentScreen::FileExplorer => app.explorer.handle_input(key), - CurrentScreen::BitcoinStatus => match key.code { + CurrentScreen::BitcoinStatus => { + if app.bitcoin_status_tab == 2 && app.bitcoin_log_input_mode.is_some() { + BitcoinStatusView::handle_logs_input(app, key) + } else { + match key.code { + KeyCode::Left => { + if app.bitcoin_status_tab > 0 { + app.bitcoin_status_tab -= 1; + if app.bitcoin_status_tab == 2 { + app.refresh_bitcoin_logs(); + } + } + AppAction::None + } + KeyCode::Right => { + if app.bitcoin_status_tab < MAX_BITCOIN_STATUS_TAB { + app.bitcoin_status_tab += 1; + if app.bitcoin_status_tab == 2 { + app.refresh_bitcoin_logs(); + } + } + AppAction::None + } + _ if app.bitcoin_status_tab == 2 => { + BitcoinStatusView::handle_logs_input(app, key) + } + k => sidebar_nav(k, app), + } + } + } + + CurrentScreen::P2PoolStatus => match key.code { KeyCode::Left => { - if app.bitcoin_status_tab > 0 { - app.bitcoin_status_tab -= 1; + if app.p2pool_status_tab > 0 { + app.p2pool_status_tab -= 1; } AppAction::None } KeyCode::Right => { - if app.bitcoin_status_tab < MAX_BITCOIN_STATUS_TAB { - app.bitcoin_status_tab += 1; + if app.p2pool_status_tab < MAX_P2POOL_STATUS_TAB { + app.p2pool_status_tab += 1; } AppAction::None } @@ -219,6 +269,8 @@ fn bootstrap_from_settings(app: &mut App) { } } } + + app.resolve_bitcoin_log_path(); } // Logic Handler @@ -230,10 +282,12 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { AppAction::ToggleMenu => app.toggle_menu(), AppAction::OpenExplorer(trigger) => { - if app.explorer.allow_dir_select { - app.explorer.allow_dir_select = false; - app.explorer.load_directory(); + let dir_select = matches!(trigger, ExplorerTrigger::BitcoinCoreDataDir); + if app.explorer.allow_dir_select != dir_select { + app.explorer.allow_dir_select = dir_select; } + prepare_explorer_start_dir(app, &trigger); + app.explorer.load_directory(); app.explorer_trigger = Some(trigger); app.current_screen = CurrentScreen::FileExplorer; } @@ -298,6 +352,26 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } app.current_screen = CurrentScreen::P2PoolConfig; } + ExplorerTrigger::BitcoinCoreDataDir => { + app.set_bitcoin_log_data_dir(path.clone()); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 2; + app.refresh_bitcoin_logs(); + } + ExplorerTrigger::BitcoinCoreLogFile => { + app.set_bitcoin_log_file(path.clone()); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 2; + app.refresh_bitcoin_logs(); + } ExplorerTrigger::BitcoinConfig => match parse_bitcoin_config(&path) { Ok(entries) => { const MIN_KNOWN_KEYS: usize = 1; @@ -321,6 +395,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.settings_view.save_error = Some(save_error.clone()); app.bitcoin_config_view.warning_message = Some(save_error); } + app.resolve_bitcoin_log_path(); } else { app.bitcoin_config_view.warning_message = Some( "File does not appear to be a Bitcoin config. Select another file." @@ -353,6 +428,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.bitcoin_config_view.dirty = false; app.bitcoin_config_view.warning_message = None; app.settings.bitcoin_conf_path = Some(path.clone()); + app.resolve_bitcoin_log_path(); } else { app.settings_view.save_error = Some( "File does not appear to be a Bitcoin config." @@ -391,7 +467,9 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { }, 2 => app.settings.ln_conf_path = Some(path.clone()), 3 => app.settings.shares_market_conf_path = Some(path.clone()), - 4 => app.settings.settings_dir_override = Some(path.clone()), + 4 => app.set_bitcoin_log_data_dir(path.clone()), + 5 => app.set_bitcoin_log_file(path.clone()), + 6 => app.settings.settings_dir_override = Some(path.clone()), _ => {} } if should_save { @@ -434,6 +512,7 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { app.settings.bitcoin_conf_path = None; app.bitcoin_conf_path = None; app.bitcoin_data.clear(); + app.resolve_bitcoin_log_path(); } 1 => { app.settings.p2pool_conf_path = None; @@ -442,7 +521,17 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } 2 => app.settings.ln_conf_path = None, 3 => app.settings.shares_market_conf_path = None, - 4 => app.settings.settings_dir_override = None, + 4 => { + app.settings.bitcoin_core_data_dir = None; + app.resolve_bitcoin_log_path(); + app.reset_bitcoin_log_reader(); + } + 5 => { + app.settings.bitcoin_core_log_path = None; + app.resolve_bitcoin_log_path(); + app.reset_bitcoin_log_reader(); + } + 6 => app.settings.settings_dir_override = None, _ => {} } app.settings_view.save_error = None; @@ -480,12 +569,67 @@ fn handle_action(action: AppAction, app: &mut App) -> Result> { } } + AppAction::RefreshBitcoinLogs => { + app.refresh_bitcoin_logs(); + } + + AppAction::ToggleBitcoinLogAutoScroll => { + app.bitcoin_log_auto_scroll = !app.bitcoin_log_auto_scroll; + if app.bitcoin_log_auto_scroll { + app.bitcoin_log_scroll = 0; + } + } + + AppAction::SetBitcoinLogDataDir(path) => { + app.set_bitcoin_log_data_dir(path); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.refresh_bitcoin_logs(); + } + + AppAction::SetBitcoinLogFile(path) => { + app.set_bitcoin_log_file(path); + app.settings_view.save_error = None; + if let Err(e) = save_settings(&app.settings) { + app.settings_view.save_error = Some(format!("Save failed: {e}")); + } + app.refresh_bitcoin_logs(); + } + + AppAction::CopyBitcoinLogs => { + app.copy_filtered_bitcoin_logs(); + } + AppAction::None => {} } Ok(ControlFlow::Continue(())) } +fn prepare_explorer_start_dir(app: &mut App, trigger: &ExplorerTrigger) { + let start_dir = match trigger { + ExplorerTrigger::BitcoinCoreDataDir => app + .settings + .bitcoin_core_data_dir + .as_ref() + .filter(|path| path.is_dir()) + .cloned(), + ExplorerTrigger::BitcoinCoreLogFile => app + .bitcoin_log_path + .as_ref() + .and_then(|path| path.parent()) + .filter(|path| path.is_dir()) + .map(std::path::Path::to_path_buf), + _ => None, + }; + + if let Some(dir) = start_dir { + app.explorer.current_dir = dir; + } +} + /// Matches the TOML type of an existing item and parses the new string /// value into that same type. This prevents numeric/bool fields from /// being written back as quoted strings (e.g. port = "3333"). @@ -1228,7 +1372,7 @@ port = 46884 // Write a minimal but syntactically valid TOML file; P2PoolConfig::load // may fail to parse it, but bootstrap_from_settings should at least set // app.p2pool_conf_path regardless of whether the config is parseable. - let cfg = write_valid_p2pool_toml(&path); + write_valid_p2pool_toml(&path); let mut app = App::new(); app.settings.p2pool_conf_path = Some(path.clone()); @@ -1268,7 +1412,7 @@ port = 46884 let dir = tempdir().unwrap(); redirect_saves_to(&dir); let path = dir.path().join("p2pool.toml"); - let cfg = write_valid_p2pool_toml(&path); + write_valid_p2pool_toml(&path); let mut app = App::new(); app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); @@ -1315,13 +1459,61 @@ port = 46884 assert!(app.settings.p2pool_conf_path.is_none()); assert!(app.settings.ln_conf_path.is_none()); assert!(app.settings.shares_market_conf_path.is_none()); + assert!(app.settings.bitcoin_core_data_dir.is_none()); + assert!(app.settings.bitcoin_core_log_path.is_none()); assert!(app.settings.settings_dir_override.is_none()); assert_eq!(app.current_screen, CurrentScreen::Settings); } #[test] #[serial] - fn file_selected_for_settings_field_4_sets_dir_override() { + fn file_selected_for_settings_field_4_sets_bitcoin_core_data_dir() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let bitcoin_dir = tempdir().unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(4)); + run( + AppAction::FileSelected(bitcoin_dir.path().to_path_buf()), + &mut app, + ); + + assert_eq!( + app.settings.bitcoin_core_data_dir, + Some(bitcoin_dir.path().to_path_buf()) + ); + assert_eq!( + app.bitcoin_log_path, + Some(bitcoin_dir.path().join("debug.log")) + ); + assert_eq!(app.current_screen, CurrentScreen::Settings); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_5_sets_bitcoin_core_log_path() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let path = dir.path().join("debug.log"); + std::fs::write(&path, "log\n").unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(5)); + run(AppAction::FileSelected(path.clone()), &mut app); + + assert_eq!(app.settings.bitcoin_core_log_path, Some(path.clone())); + assert_eq!(app.bitcoin_log_path, Some(path)); + assert_eq!(app.current_screen, CurrentScreen::Settings); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_6_sets_dir_override() { use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -1330,7 +1522,7 @@ port = 46884 let settings_dir = tempdir().unwrap(); let mut app = App::new(); - app.explorer_trigger = Some(ExplorerTrigger::Settings(4)); + app.explorer_trigger = Some(ExplorerTrigger::Settings(6)); run( AppAction::FileSelected(settings_dir.path().to_path_buf()), &mut app, @@ -1354,6 +1546,14 @@ port = 46884 assert_eq!(app.current_screen, CurrentScreen::FileExplorer); } + #[test] + fn open_explorer_for_settings_field6_enables_dir_select() { + let mut app = App::new(); + run(AppAction::OpenExplorerForSettings(6), &mut app); + assert!(app.explorer.allow_dir_select); + assert_eq!(app.current_screen, CurrentScreen::FileExplorer); + } + #[test] fn open_explorer_for_settings_non_dir_field_disables_dir_select() { let mut app = App::new(); @@ -1407,8 +1607,12 @@ port = 46884 app.settings.p2pool_conf_path = Some(PathBuf::from("/tmp/p2pool.toml")); app.settings.ln_conf_path = Some(PathBuf::from("/tmp/ln.conf")); app.settings.shares_market_conf_path = Some(PathBuf::from("/tmp/shares.conf")); + app.settings.bitcoin_core_data_dir = Some(PathBuf::from("/tmp/bitcoin")); + app.settings.bitcoin_core_log_path = Some(PathBuf::from("/tmp/bitcoin/debug.log")); + app.settings.settings_dir_override = Some(PathBuf::from("/tmp/pdm")); app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); app.p2pool_conf_path = Some(PathBuf::from("/tmp/p2pool.toml")); + app.bitcoin_log_path = Some(PathBuf::from("/tmp/bitcoin/debug.log")); run(AppAction::ClearSettingsField(0), &mut app); assert!(app.settings.bitcoin_conf_path.is_none()); @@ -1425,6 +1629,15 @@ port = 46884 run(AppAction::ClearSettingsField(3), &mut app); assert!(app.settings.shares_market_conf_path.is_none()); + + run(AppAction::ClearSettingsField(4), &mut app); + assert!(app.settings.bitcoin_core_data_dir.is_none()); + + run(AppAction::ClearSettingsField(5), &mut app); + assert!(app.settings.bitcoin_core_log_path.is_none()); + + run(AppAction::ClearSettingsField(6), &mut app); + assert!(app.settings.settings_dir_override.is_none()); } #[test] diff --git a/src/settings.rs b/src/settings.rs index db0f780..d4966f1 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -20,6 +20,10 @@ use std::path::PathBuf; pub struct Settings { /// Path to the Bitcoin Core config file (bitcoin.conf) pub bitcoin_conf_path: Option, + /// Path to the Bitcoin Core data directory. + pub bitcoin_core_data_dir: Option, + /// Direct path to the Bitcoin Core debug log file. + pub bitcoin_core_log_path: Option, /// Path to the p2poolv2 config file pub p2pool_conf_path: Option, /// Path to the Lightning Network config file @@ -119,6 +123,8 @@ mod tests { fn default_settings_has_no_paths() { let s = Settings::default(); assert!(s.bitcoin_conf_path.is_none()); + assert!(s.bitcoin_core_data_dir.is_none()); + assert!(s.bitcoin_core_log_path.is_none()); assert!(s.p2pool_conf_path.is_none()); assert!(s.ln_conf_path.is_none()); assert!(s.shares_market_conf_path.is_none()); @@ -132,6 +138,8 @@ mod tests { let path = dir.path().join("settings.toml"); let settings = Settings { bitcoin_conf_path: Some(PathBuf::from("/tmp/bitcoin.conf")), + bitcoin_core_data_dir: Some(PathBuf::from("/tmp/bitcoin")), + bitcoin_core_log_path: Some(PathBuf::from("/tmp/bitcoin/debug.log")), p2pool_conf_path: Some(PathBuf::from("/tmp/p2pool.toml")), ..Default::default() }; @@ -141,6 +149,8 @@ mod tests { let loaded: Settings = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(loaded.bitcoin_conf_path, settings.bitcoin_conf_path); + assert_eq!(loaded.bitcoin_core_data_dir, settings.bitcoin_core_data_dir); + assert_eq!(loaded.bitcoin_core_log_path, settings.bitcoin_core_log_path); assert_eq!(loaded.p2pool_conf_path, settings.p2pool_conf_path); assert!(loaded.ln_conf_path.is_none()); } @@ -214,6 +224,8 @@ mod tests { // No settings.toml written let settings = load_settings(); assert!(settings.bitcoin_conf_path.is_none()); + assert!(settings.bitcoin_core_data_dir.is_none()); + assert!(settings.bitcoin_core_log_path.is_none()); } #[test] @@ -224,6 +236,8 @@ mod tests { std::fs::write(dir.path().join("settings.toml"), "not valid toml :::").unwrap(); let settings = load_settings(); assert!(settings.bitcoin_conf_path.is_none()); + assert!(settings.bitcoin_core_data_dir.is_none()); + assert!(settings.bitcoin_core_log_path.is_none()); } #[test] @@ -243,6 +257,32 @@ mod tests { ); } + #[test] + #[serial_test::serial] + fn load_settings_reads_bitcoin_core_log_paths() { + let dir = tempfile::tempdir().unwrap(); + set_config_dir(&dir); + std::fs::write( + dir.path().join("settings.toml"), + r#" +bitcoin_core_data_dir = "/tmp/bitcoin" +bitcoin_core_log_path = "/tmp/bitcoin/debug.log" +"#, + ) + .unwrap(); + + let settings = load_settings(); + + assert_eq!( + settings.bitcoin_core_data_dir, + Some(PathBuf::from("/tmp/bitcoin")) + ); + assert_eq!( + settings.bitcoin_core_log_path, + Some(PathBuf::from("/tmp/bitcoin/debug.log")) + ); + } + #[test] fn settings_dir_override_field_serializes() { let settings = Settings { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap index ea4c542..4463830 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 409 expression: terminal.backend() --- TestBackend { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new deleted file mode 100644 index ea4c542..0000000 --- a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new +++ /dev/null @@ -1,52 +0,0 @@ ---- -source: src/ui.rs -assertion_line: 409 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 20 }, - content: [ - "┌ PDM ──────────────────┐┌ Bitcoin Config ─────────────────────────────────────┐", - "│Home ││Press [Enter] to select a bitcoin.conf file │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 19, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap index 1a0eafc..5593cb3 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 149 expression: terminal.backend() --- TestBackend { @@ -10,9 +11,9 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Chain Info │", - "│LN Config ││ │", + "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", + "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core chain│", + "│LN Config ││info. │", "│LN Status ││ │", "│Shares Market ││ │", "│Settings ││ │", @@ -37,6 +38,10 @@ TestBackend { x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 79, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap index 0d5432c..f3b7f19 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_logs_render.snap @@ -10,14 +10,14 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Logs │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", - "│ ││ │", + "│P2Pool Config │┌ Bitcoin Core Logs ──────────────────────────────────┐", + "│P2Pool Status ││Current log file path: (not configured) │", + "│LN Config ││Status: No Bitcoin Core debug.log found. Choose a log│", + "│LN Status ││file or data directory. │", + "│Shares Market ││Filter: (none) Auto-scroll: on Lines: 0/0 │", + "│Settings │└─────────────────────────────────────────────────────┘", + "│ │┌ debug.log ──────────────────────────────────────────┐", + "│ ││No Bitcoin Core debug.log found. Choose a log file or│", "│ ││ │", "│ ││ │", "│ ││ │", @@ -29,7 +29,7 @@ TestBackend { "│ ││ │", "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", + " ↑↓ Scroll logs ←→ Switch tab b Browse log o Browse dir / Search ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, @@ -37,13 +37,54 @@ TestBackend { x: 53, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 49, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 34, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 8, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 34, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 8, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 55, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 57, y: 8, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 66, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 10, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 11, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 26, y: 11, fg: Yellow, bg: Black, underline: Reset, modifier: NONE, + x: 79, y: 11, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 12, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 13, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 14, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 15, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 16, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 17, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 18, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 18, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 19, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 20, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 20, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 21, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, + x: 0, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 25, y: 22, fg: LightGreen, bg: Black, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 27, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 40, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 43, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 50, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 18, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 22, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 35, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 38, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 51, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 54, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 67, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 70, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 79, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, ] }, scrollback: Buffer { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap index 8bb00f5..3cd08fc 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_tab_peers_render.snap @@ -10,9 +10,9 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Peers │", - "│LN Config ││ │", + "│P2Pool Config │┌ Peers ──────────────────────────────────────────────┐", + "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core peer │", + "│LN Config ││info. │", "│LN Status ││ │", "│Shares Market ││ │", "│Settings ││ │", @@ -37,6 +37,10 @@ TestBackend { x: 61, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 78, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap index cea0888..2facef3 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 423 expression: terminal.backend() --- TestBackend { diff --git a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new deleted file mode 100644 index cea0888..0000000 --- a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new +++ /dev/null @@ -1,54 +0,0 @@ ---- -source: src/ui.rs -assertion_line: 423 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 20 }, - content: [ - "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Chain Info │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 27, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 40, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 43, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 50, y: 19, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 3c98693..3f096dd 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -1,18 +1,18 @@ --- source: src/ui.rs -assertion_line: 190 +assertion_line: 219 expression: terminal.backend() --- TestBackend { buffer: Buffer { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ - "┌ PDM ──────────────────┐┌ P2Pool Status ──────────────────────────────────────┐", - "│Home ││P2Pool Status │", + "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", + "│Home ││ Chain Info │ Shares │ Peers Info │", "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", + "│Bitcoin Status │└─────────────────────────────────────────────────────┘", + "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", + "│P2Pool Status ││Loading chain info... │", "│LN Config ││ │", "│LN Status ││ │", "│Shares Market ││ │", @@ -30,19 +30,23 @@ TestBackend { "│ ││ │", "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", + " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 5, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 47, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 27, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 40, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 43, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 50, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, ] }, scrollback: Buffer { diff --git a/src/snapshots/pdm__ui__tests__settings_screen_render.snap b/src/snapshots/pdm__ui__tests__settings_screen_render.snap index 406ff9a..ecb7205 100644 --- a/src/snapshots/pdm__ui__tests__settings_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__settings_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 245 expression: terminal.backend() --- TestBackend { @@ -16,7 +15,11 @@ TestBackend { "│LN Config ││(not set) │", "│LN Status ││Shares Market config path │", "│Shares Market ││(not set) │", - "│Settings ││Settings directory │", + "│Settings ││Bitcoin Core data directory │", + "│ ││(not set) │", + "│ ││Bitcoin Core log file │", + "│ ││(not set) │", + "│ ││Settings directory │", "│ ││/pdm/test-config │", "│ ││ │", "│ ││ │", @@ -25,10 +28,6 @@ TestBackend { "│ ││ │", "│ ││ │", "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", " ↑↓ Navigate sidebar Enter Focus settings ", ], @@ -77,27 +76,29 @@ TestBackend { x: 24, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 9, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 9, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, - x: 44, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 53, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 9, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 10, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 42, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 10, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 11, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 11, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 11, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 12, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 12, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 13, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 13, fg: Gray, bg: Reset, underline: Reset, modifier: NONE, + x: 44, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 13, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 14, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, - x: 26, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 42, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 79, y: 14, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 25, y: 15, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, diff --git a/src/ui.rs b/src/ui.rs index 322542c..4478d00 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -112,6 +112,8 @@ pub fn ui(f: &mut Frame, app: &mut App) { mod tests { use super::*; use crate::app::App; + use crate::components::p2pool_client::P2PoolClient; + use mockito::Server; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -165,6 +167,10 @@ mod tests { app.sidebar_index = 2; app.toggle_menu(); app.bitcoin_status_tab = 2; + app.bitcoin_log_path = None; + app.bitcoin_log_lines.clear(); + app.bitcoin_log_status = + "No Bitcoin Core debug.log found. Choose a log file or data directory.".to_string(); terminal.draw(|f| ui(f, &mut app)).unwrap(); insta::assert_debug_snapshot!(terminal.backend()); } @@ -192,8 +198,25 @@ mod tests { #[test] fn test_p2pool_status_screen_render() { + let mut server = Server::new(); + let _mock = server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "genesis_blockhash": null, + "chain_tip_height": 1, + "total_work": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chain_tip_blockhash": null + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let mut app = App::new_with_client(client); let mut terminal = make_terminal(); - let mut app = App::new(); app.sidebar_index = 4; app.toggle_menu(); terminal.draw(|f| ui(f, &mut app)).unwrap();