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..824b447 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,11 +5,16 @@ use crate::bitcoin_config::ConfigEntry as BitcoinEntry; 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 p2poolv2_config::Config as P2PoolConfig; use std::path::PathBuf; +use tokio::sync::mpsc; /// Sidebar items labels pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[ @@ -31,6 +36,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, @@ -96,17 +106,44 @@ pub struct App { pub bitcoin_data: Vec, pub bitcoin_status_tab: usize, 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 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 (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, @@ -121,8 +158,134 @@ impl App { bitcoin_data: Vec::new(), bitcoin_status_tab: 0, 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, + 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_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,6 +305,46 @@ impl App { } if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; + 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)); + } + }); + } + } + } } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 65ecad5..874a783 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -8,8 +8,10 @@ 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/status_bar.rs b/src/components/status_bar.rs index 268346d..775d6f6 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -108,7 +108,7 @@ impl StatusBar { spans.extend(hint("Esc", "Back")); } } - CurrentScreen::BitcoinStatus => { + CurrentScreen::BitcoinStatus | CurrentScreen::P2PoolStatus => { spans.extend(hint("↑↓", "Navigate sidebar")); spans.extend(hint("←→", "Switch tab")); spans.extend(hint("q", "Quit")); 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..2ec9158 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod app; pub mod bitcoin_config; 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..0714090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,8 @@ 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, @@ -22,9 +23,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 +71,16 @@ where ::Error: Send + Sync + 'static, { loop { + 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; @@ -110,6 +120,22 @@ where k => sidebar_nav(k, app), }, + CurrentScreen::P2PoolStatus => match key.code { + KeyCode::Left => { + if app.p2pool_status_tab > 0 { + app.p2pool_status_tab -= 1; + } + AppAction::None + } + KeyCode::Right => { + if app.p2pool_status_tab < MAX_P2POOL_STATUS_TAB { + app.p2pool_status_tab += 1; + } + AppAction::None + } + k => sidebar_nav(k, app), + }, + CurrentScreen::BitcoinConfig => { if app.bitcoin_conf_path.is_some() { if app.bitcoin_config_view.sidebar_focused { 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__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/ui.rs b/src/ui.rs index 322542c..e9fbede 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; @@ -192,8 +194,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();