From ebd4365d64f0e78cd416c780d06333c504598520 Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Fri, 20 Mar 2026 00:02:00 +0100 Subject: [PATCH 1/2] feat(ev-dev): add interactive TUI dashboard with --tui flag - Integrate ratatui for terminal UI with blocks, logs, and accounts panels - Implement custom tracing layer to capture real-time log events - Add keyboard navigation (Tab for panel switch, arrows for scroll, q to quit) - Support coexistence of TUI and plain log output modes - Add crossterm for terminal event handling --- Cargo.lock | 422 +++++++++++++++++++++++++++- bin/ev-dev/Cargo.toml | 9 + bin/ev-dev/src/main.rs | 218 ++++++++++---- bin/ev-dev/src/tui/app.rs | 156 ++++++++++ bin/ev-dev/src/tui/events.rs | 16 ++ bin/ev-dev/src/tui/mod.rs | 72 +++++ bin/ev-dev/src/tui/tracing_layer.rs | 83 ++++++ bin/ev-dev/src/tui/ui.rs | 288 +++++++++++++++++++ 8 files changed, 1202 insertions(+), 62 deletions(-) create mode 100644 bin/ev-dev/src/tui/app.rs create mode 100644 bin/ev-dev/src/tui/events.rs create mode 100644 bin/ev-dev/src/tui/mod.rs create mode 100644 bin/ev-dev/src/tui/tracing_layer.rs create mode 100644 bin/ev-dev/src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index c5d2199..608726b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1406,6 +1406,15 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1528,15 +1537,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -2189,6 +2213,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix", @@ -2235,6 +2260,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "ctr" version = "0.9.2" @@ -2435,6 +2470,12 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -2909,6 +2950,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "ev-common" version = "0.1.0" @@ -2937,16 +2987,21 @@ dependencies = [ "alloy-primitives", "alloy-signer-local", "clap", + "crossterm", "ev-deployer", "ev-node", "evolve-ev-reth", "eyre", + "futures", + "ratatui", "reth-cli-util", "reth-ethereum-cli", + "reth-tracing", "serde_json", "tempfile", "tokio", "tracing", + "tracing-subscriber 0.3.23", ] [[package]] @@ -3221,6 +3276,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -3275,6 +3340,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -3292,6 +3368,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fixed-cache" version = "0.1.8" @@ -3336,6 +3418,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -4623,6 +4711,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -4844,6 +4938,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "mach2" version = "0.5.0" @@ -4905,6 +5009,21 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metrics" version = "0.24.3" @@ -5101,6 +5220,19 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -5195,6 +5327,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5541,6 +5684,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "p256" version = "0.13.2" @@ -5658,6 +5810,39 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pharos" version = "0.5.3" @@ -5668,17 +5853,47 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -5686,7 +5901,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5695,13 +5923,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -5926,8 +6163,8 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.11.0", "num-traits", "rand 0.9.2", @@ -6211,6 +6448,8 @@ dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", "ratatui-widgets", ] @@ -6246,6 +6485,26 @@ dependencies = [ "ratatui-core", ] +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + [[package]] name = "ratatui-widgets" version = "0.3.0" @@ -9125,7 +9384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74d1e5c1eaa44d39d537f668bc5c3409dc01e5c8be954da6c83370bbdf006457" dependencies = [ "bitvec", - "phf", + "phf 0.13.1", "revm-primitives", "serde", ] @@ -10243,6 +10502,69 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -11091,6 +11413,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "wasm-bindgen", @@ -11166,6 +11489,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -11394,6 +11726,78 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/bin/ev-dev/Cargo.toml b/bin/ev-dev/Cargo.toml index 5a10743..deed6ff 100644 --- a/bin/ev-dev/Cargo.toml +++ b/bin/ev-dev/Cargo.toml @@ -26,6 +26,9 @@ reth-ethereum-cli.workspace = true alloy-signer-local.workspace = true alloy-primitives.workspace = true +# Reth tracing (for Layers type) +reth-tracing.workspace = true + # Core dependencies eyre.workspace = true tracing.workspace = true @@ -33,6 +36,12 @@ tokio = { workspace = true, features = ["full"] } clap = { workspace = true, features = ["derive", "env"] } tempfile.workspace = true serde_json.workspace = true +futures.workspace = true + +# TUI +ratatui = "0.30" +crossterm = { version = "0.29", features = ["event-stream"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } [lints] workspace = true diff --git a/bin/ev-dev/src/main.rs b/bin/ev-dev/src/main.rs index 6eeef39..187f3a4 100644 --- a/bin/ev-dev/src/main.rs +++ b/bin/ev-dev/src/main.rs @@ -5,6 +5,8 @@ #![allow(missing_docs, rustdoc::missing_crate_level_docs)] +mod tui; + use alloy_signer_local::{coins_bip39::English, MnemonicBuilder}; use clap::Parser; use ev_deployer::{config::DeployConfig, genesis::merge_alloc, output::build_manifest}; @@ -15,6 +17,7 @@ use evolve_ev_reth::{ use reth_ethereum_cli::Cli; use std::{io::Write, path::PathBuf}; use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use ev_node::{EvolveArgs, EvolveChainSpecParser, EvolveNode}; @@ -60,6 +63,10 @@ struct EvDevArgs { /// Path to an ev-deployer TOML config to deploy contracts at genesis. #[arg(long, value_name = "PATH")] deploy_config: Option, + + /// Launch with terminal UI instead of plain log output + #[arg(long, default_value_t = false)] + tui: bool, } fn derive_keys(count: usize) -> Vec<(String, String)> { @@ -148,59 +155,11 @@ fn print_banner(args: &EvDevArgs, deploy_cfg: Option<&DeployConfig>) { println!(); } -fn main() { - reth_cli_util::sigsegv_handler::install(); - - if std::env::var_os("RUST_BACKTRACE").is_none() { - std::env::set_var("RUST_BACKTRACE", "1"); - } - - let dev_args = EvDevArgs::parse(); - - let deploy_cfg = dev_args.deploy_config.as_ref().map(|config_path| { - let mut cfg = DeployConfig::load(config_path) - .unwrap_or_else(|e| panic!("failed to load deploy config: {e}")); - - let genesis_chain_id = chain_id_from_genesis(); - if cfg.chain.chain_id != genesis_chain_id { - eprintln!( - "WARNING: deploy config chain_id ({}) differs from devnet genesis ({}), overriding to {}", - cfg.chain.chain_id, genesis_chain_id, genesis_chain_id - ); - cfg.chain.chain_id = genesis_chain_id; - } - cfg - }); - - if !dev_args.silent { - print_banner(&dev_args, deploy_cfg.as_ref()); - } - - let genesis_json = if let Some(ref cfg) = deploy_cfg { - let mut genesis: serde_json::Value = - serde_json::from_str(DEVNET_GENESIS).expect("valid genesis JSON"); - merge_alloc(cfg, &mut genesis, true).expect("failed to merge deploy config into genesis"); - serde_json::to_string(&genesis).expect("failed to serialize merged genesis") - } else { - DEVNET_GENESIS.to_string() - }; - - // Write genesis to a temp file that lives for the process duration - let mut genesis_file = - tempfile::NamedTempFile::new().expect("failed to create temp genesis file"); - genesis_file - .write_all(genesis_json.as_bytes()) - .expect("failed to write genesis"); - let genesis_path = genesis_file - .path() - .to_str() - .expect("valid path") - .to_string(); - - // Use a temp data directory so each run starts with clean state - let datadir = tempfile::TempDir::new().expect("failed to create temp data dir"); - let datadir_path = datadir.path().to_str().expect("valid path").to_string(); - +fn build_reth_args( + dev_args: &EvDevArgs, + genesis_path: String, + datadir_path: String, +) -> Vec { let mut args = vec![ "ev-dev".to_string(), "node".to_string(), @@ -236,6 +195,94 @@ fn main() { args.push(format!("{}s", dev_args.block_time)); } + args +} + +fn prepare_genesis( + deploy_cfg: &Option, +) -> (tempfile::NamedTempFile, tempfile::TempDir) { + let genesis_json = if let Some(ref cfg) = deploy_cfg { + let mut genesis: serde_json::Value = + serde_json::from_str(DEVNET_GENESIS).expect("valid genesis JSON"); + merge_alloc(cfg, &mut genesis, true).expect("failed to merge deploy config into genesis"); + serde_json::to_string(&genesis).expect("failed to serialize merged genesis") + } else { + DEVNET_GENESIS.to_string() + }; + + let mut genesis_file = + tempfile::NamedTempFile::new().expect("failed to create temp genesis file"); + genesis_file + .write_all(genesis_json.as_bytes()) + .expect("failed to write genesis"); + + let datadir = tempfile::TempDir::new().expect("failed to create temp data dir"); + + (genesis_file, datadir) +} + +fn load_deploy_config(dev_args: &EvDevArgs) -> Option { + dev_args.deploy_config.as_ref().map(|config_path| { + let mut cfg = DeployConfig::load(config_path) + .unwrap_or_else(|e| panic!("failed to load deploy config: {e}")); + + let genesis_chain_id = chain_id_from_genesis(); + if cfg.chain.chain_id != genesis_chain_id { + eprintln!( + "WARNING: deploy config chain_id ({}) differs from devnet genesis ({}), overriding to {}", + cfg.chain.chain_id, genesis_chain_id, genesis_chain_id + ); + cfg.chain.chain_id = genesis_chain_id; + } + cfg + }) +} + +fn deploy_contracts_list(deploy_cfg: &Option) -> Option> { + deploy_cfg.as_ref().map(|cfg| { + let manifest = build_manifest(cfg); + manifest + .as_object() + .map(|obj| { + obj.iter() + .map(|(name, addr)| (name.clone(), addr.as_str().unwrap_or("").to_string())) + .collect() + }) + .unwrap_or_default() + }) +} + +fn main() { + reth_cli_util::sigsegv_handler::install(); + + if std::env::var_os("RUST_BACKTRACE").is_none() { + std::env::set_var("RUST_BACKTRACE", "1"); + } + + let dev_args = EvDevArgs::parse(); + let deploy_cfg = load_deploy_config(&dev_args); + + if dev_args.tui { + run_with_tui(dev_args, deploy_cfg); + } else { + run_without_tui(dev_args, deploy_cfg); + } +} + +fn run_without_tui(dev_args: EvDevArgs, deploy_cfg: Option) { + if !dev_args.silent { + print_banner(&dev_args, deploy_cfg.as_ref()); + } + + let (genesis_file, datadir) = prepare_genesis(&deploy_cfg); + let genesis_path = genesis_file + .path() + .to_str() + .expect("valid path") + .to_string(); + let datadir_path = datadir.path().to_str().expect("valid path").to_string(); + let args = build_reth_args(&dev_args, genesis_path, datadir_path); + let cli = match Cli::::try_parse_from(args) { Ok(cli) => cli, Err(err) => { @@ -265,3 +312,68 @@ fn main() { std::process::exit(1); } } + +fn run_with_tui(dev_args: EvDevArgs, deploy_cfg: Option) { + let (log_tx, log_rx) = tokio::sync::mpsc::channel(10_000); + + // Install our tracing subscriber with the TUI layer BEFORE cli.run(). + // When reth's internal init_tracing calls try_init(), it will find a + // subscriber already installed and silently skip its own setup. + let tui_layer = tui::TuiTracingLayer::new(log_tx); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()); + + tracing_subscriber::registry() + .with(filter) + .with(tui_layer) + .init(); + + let chain_id = chain_id_from_genesis(); + let rpc_url = format!("http://{}:{}", dev_args.host, dev_args.port); + let block_time = dev_args.block_time; + let accounts = derive_keys(dev_args.accounts); + let contracts = deploy_contracts_list(&deploy_cfg); + + let app = tui::App::new(chain_id, rpc_url, block_time, accounts, contracts, log_rx); + + let (genesis_file, datadir) = prepare_genesis(&deploy_cfg); + let genesis_path = genesis_file + .path() + .to_str() + .expect("valid path") + .to_string(); + let datadir_path = datadir.path().to_str().expect("valid path").to_string(); + let args = build_reth_args(&dev_args, genesis_path, datadir_path); + + let cli = match Cli::::try_parse_from(args) { + Ok(cli) => cli, + Err(err) => { + eprintln!("{err}"); + std::process::exit(2); + } + }; + + if let Err(err) = cli.run(|builder, _evolve_args| async move { + info!("=== EV-DEV: Starting local development chain (TUI) ==="); + let _handle = builder + .node(EvolveNode::new()) + .extend_rpc_modules(move |ctx| { + let evolve_cfg = EvolveConfig::default(); + let evolve_txpool = + EvolveTxpoolApiImpl::new(ctx.pool().clone(), evolve_cfg.max_txpool_bytes); + ctx.modules.merge_configured(evolve_txpool.into_rpc())?; + Ok(()) + }) + .launch_with_debug_capabilities() + .await?; + + info!("=== EV-DEV: Local chain running - RPC ready ==="); + + tui::run(app).await?; + + Ok(()) + }) { + let _ = tui::restore_terminal(); + eprintln!("Error: {err:?}"); + std::process::exit(1); + } +} diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs new file mode 100644 index 0000000..d0a5b23 --- /dev/null +++ b/bin/ev-dev/src/tui/app.rs @@ -0,0 +1,156 @@ +use std::{collections::VecDeque, time::Instant}; + +use tokio::sync::mpsc; + +const MAX_LOGS: usize = 1000; +const MAX_BLOCKS: usize = 200; + +#[derive(Debug, Clone)] +pub(crate) struct BlockInfo { + pub(crate) number: u64, + pub(crate) hash: String, + pub(crate) tx_count: u64, + pub(crate) gas_used: u64, +} + +#[derive(Debug, Clone)] +pub(crate) struct LogEntry { + pub(crate) level: tracing::Level, + pub(crate) target: String, + pub(crate) message: String, + pub(crate) fields: Vec<(String, String)>, + pub(crate) timestamp: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Panel { + Blocks, + Logs, + Accounts, +} + +pub(crate) struct App { + // Static + pub(crate) chain_id: u64, + pub(crate) rpc_url: String, + pub(crate) block_time: u64, + pub(crate) accounts: Vec<(String, String)>, + pub(crate) deploy_contracts: Option>, + + // Dynamic + pub(crate) blocks: VecDeque, + pub(crate) logs: VecDeque, + pub(crate) current_block: u64, + pub(crate) start_time: Instant, + + // UI state + pub(crate) active_panel: Panel, + pub(crate) log_scroll: usize, + pub(crate) block_scroll: usize, + pub(crate) should_quit: bool, + + // Channel + pub(crate) log_rx: mpsc::Receiver, +} + +impl App { + pub(crate) fn new( + chain_id: u64, + rpc_url: String, + block_time: u64, + accounts: Vec<(String, String)>, + deploy_contracts: Option>, + log_rx: mpsc::Receiver, + ) -> Self { + Self { + chain_id, + rpc_url, + block_time, + accounts, + deploy_contracts, + blocks: VecDeque::new(), + logs: VecDeque::new(), + current_block: 0, + start_time: Instant::now(), + active_panel: Panel::Logs, + log_scroll: 0, + block_scroll: 0, + should_quit: false, + log_rx, + } + } + + pub(crate) fn drain_logs(&mut self) { + while let Ok(entry) = self.log_rx.try_recv() { + if entry.message == "built block" { + if let Some(block) = self.parse_block_from_fields(&entry.fields) { + self.current_block = block.number; + self.blocks.push_front(block); + if self.blocks.len() > MAX_BLOCKS { + self.blocks.pop_back(); + } + } + } + + self.logs.push_back(entry); + if self.logs.len() > MAX_LOGS { + self.logs.pop_front(); + } + } + } + + fn parse_block_from_fields(&self, fields: &[(String, String)]) -> Option { + let mut number = None; + let mut hash = String::new(); + let mut tx_count = 0; + let mut gas_used = 0; + + for (k, v) in fields { + match k.as_str() { + "block_number" => number = v.parse().ok(), + "block_hash" => { + let h = v.trim_matches('"'); + hash = if h.len() > 10 { + format!("{}..{}", &h[..6], &h[h.len() - 4..]) + } else { + h.to_string() + }; + } + "tx_count" => tx_count = v.parse().unwrap_or(0), + "gas_used" => gas_used = v.parse().unwrap_or(0), + _ => {} + } + } + + number.map(|n| BlockInfo { + number: n, + hash, + tx_count, + gas_used, + }) + } + + pub(crate) fn next_panel(&mut self) { + self.active_panel = match self.active_panel { + Panel::Blocks => Panel::Logs, + Panel::Logs => Panel::Accounts, + Panel::Accounts => Panel::Blocks, + }; + } + + pub(crate) fn scroll_up(&mut self) { + match self.active_panel { + Panel::Logs => self.log_scroll = self.log_scroll.saturating_add(1), + Panel::Blocks => self.block_scroll = self.block_scroll.saturating_add(1), + Panel::Accounts => {} + } + } + + pub(crate) fn scroll_down(&mut self) { + match self.active_panel { + Panel::Logs => self.log_scroll = self.log_scroll.saturating_sub(1), + Panel::Blocks => self.block_scroll = self.block_scroll.saturating_sub(1), + Panel::Accounts => {} + } + } +} diff --git a/bin/ev-dev/src/tui/events.rs b/bin/ev-dev/src/tui/events.rs new file mode 100644 index 0000000..5606ac8 --- /dev/null +++ b/bin/ev-dev/src/tui/events.rs @@ -0,0 +1,16 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::app::App; + +pub(crate) fn handle_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + KeyCode::Tab => app.next_panel(), + KeyCode::Up => app.scroll_up(), + KeyCode::Down => app.scroll_down(), + _ => {} + } +} diff --git a/bin/ev-dev/src/tui/mod.rs b/bin/ev-dev/src/tui/mod.rs new file mode 100644 index 0000000..b611a3a --- /dev/null +++ b/bin/ev-dev/src/tui/mod.rs @@ -0,0 +1,72 @@ +pub(crate) mod app; +mod events; +mod tracing_layer; +mod ui; + +pub(crate) use app::App; +pub(crate) use tracing_layer::TuiTracingLayer; + +use std::io::{self, stdout}; + +use crossterm::{ + event::{Event, EventStream}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use futures::StreamExt; +use ratatui::prelude::CrosstermBackend; + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + } +} + +pub(crate) async fn run(mut app: App) -> eyre::Result<()> { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + original_hook(info); + })); + + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + let _guard = TerminalGuard; + + let backend = CrosstermBackend::new(stdout()); + let mut terminal = ratatui::Terminal::new(backend)?; + + let mut event_stream = EventStream::new(); + let mut tick = tokio::time::interval(std::time::Duration::from_millis(100)); + + loop { + if app.should_quit { + break; + } + + tokio::select! { + _ = tick.tick() => { + app.drain_logs(); + terminal.draw(|frame| ui::draw(frame, &app))?; + } + maybe_event = event_stream.next() => { + if let Some(Ok(Event::Key(key))) = maybe_event { + events::handle_key(&mut app, key); + } + } + } + } + + // Terminal restored by TerminalGuard drop + Ok(()) +} + +pub(crate) fn restore_terminal() -> io::Result<()> { + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) +} diff --git a/bin/ev-dev/src/tui/tracing_layer.rs b/bin/ev-dev/src/tui/tracing_layer.rs new file mode 100644 index 0000000..7ea78d5 --- /dev/null +++ b/bin/ev-dev/src/tui/tracing_layer.rs @@ -0,0 +1,83 @@ +use std::time::Instant; + +use tokio::sync::mpsc; +use tracing::{ + field::{Field, Visit}, + Subscriber, +}; +use tracing_subscriber::{layer::Context, registry::LookupSpan, Layer}; + +use super::app::LogEntry; + +struct FieldCollector { + fields: Vec<(String, String)>, +} + +impl FieldCollector { + const fn new() -> Self { + Self { fields: Vec::new() } + } + + fn take_message(&mut self) -> String { + if let Some(pos) = self.fields.iter().position(|(k, _)| k == "message") { + self.fields.remove(pos).1 + } else { + String::new() + } + } +} + +impl Visit for FieldCollector { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.fields + .push((field.name().to_string(), format!("{:?}", value))); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.fields + .push((field.name().to_string(), value.to_string())); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.fields + .push((field.name().to_string(), value.to_string())); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.fields + .push((field.name().to_string(), value.to_string())); + } +} + +pub(crate) struct TuiTracingLayer { + tx: mpsc::Sender, +} + +impl TuiTracingLayer { + pub(crate) const fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } +} + +impl Layer for TuiTracingLayer +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + let mut collector = FieldCollector::new(); + event.record(&mut collector); + + let message = collector.take_message(); + let metadata = event.metadata(); + + let entry = LogEntry { + level: *metadata.level(), + target: metadata.target().to_string(), + message, + fields: collector.fields, + timestamp: Instant::now(), + }; + + let _ = self.tx.try_send(entry); + } +} diff --git a/bin/ev-dev/src/tui/ui.rs b/bin/ev-dev/src/tui/ui.rs new file mode 100644 index 0000000..fe3d783 --- /dev/null +++ b/bin/ev-dev/src/tui/ui.rs @@ -0,0 +1,288 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, + Frame, +}; + +use super::app::{App, Panel}; + +fn border_style(app: &App, panel: Panel) -> Style { + if app.active_panel == panel { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } +} + +const fn level_color(level: &tracing::Level) -> Color { + match *level { + tracing::Level::ERROR => Color::Red, + tracing::Level::WARN => Color::Yellow, + tracing::Level::INFO => Color::Green, + tracing::Level::DEBUG | tracing::Level::TRACE => Color::DarkGray, + } +} + +fn format_uptime(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { + format!("{h}h{m:02}m{s:02}s") + } else if m > 0 { + format!("{m}m{s:02}s") + } else { + format!("{s}s") + } +} + +fn format_gas(gas: u64) -> String { + if gas >= 1_000_000 { + format!("{:.1}M", gas as f64 / 1_000_000.0) + } else if gas >= 1_000 { + format!("{:.1}k", gas as f64 / 1_000.0) + } else { + gas.to_string() + } +} + +pub(crate) fn draw(frame: &mut Frame<'_>, app: &App) { + let area = frame.area(); + + let outer = Layout::vertical([ + Constraint::Length(3), // header + Constraint::Min(6), // main content + Constraint::Length(3), // footer + ]) + .split(area); + + draw_header(frame, app, outer[0]); + draw_main(frame, app, outer[1]); + draw_footer(frame, app, outer[2]); +} + +fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { + let block_time_str = if app.block_time == 0 { + "auto".to_string() + } else { + format!("{}s", app.block_time) + }; + + let text = Line::from(vec![ + Span::styled(" Chain: ", Style::default().fg(Color::DarkGray)), + Span::styled(app.chain_id.to_string(), Style::default().fg(Color::White)), + Span::styled(" RPC: ", Style::default().fg(Color::DarkGray)), + Span::styled(&app.rpc_url, Style::default().fg(Color::Cyan)), + Span::styled(" Block: ", Style::default().fg(Color::DarkGray)), + Span::styled(block_time_str, Style::default().fg(Color::White)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .title(" ev-dev ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .border_style(Style::default().fg(Color::Cyan)); + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); +} + +fn draw_main(frame: &mut Frame<'_>, app: &App, area: Rect) { + let main_split = Layout::vertical([ + Constraint::Percentage(45), // top row (blocks + accounts) + Constraint::Percentage(55), // logs + ]) + .split(area); + + let top_split = Layout::horizontal([ + Constraint::Percentage(55), // blocks + Constraint::Percentage(45), // accounts + ]) + .split(main_split[0]); + + draw_blocks(frame, app, top_split[0]); + draw_accounts(frame, app, top_split[1]); + draw_logs(frame, app, main_split[1]); +} + +fn draw_blocks(frame: &mut Frame<'_>, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Blocks ") + .border_style(border_style(app, Panel::Blocks)); + + let header = Row::new(vec![ + Cell::from("Block").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Hash").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Txs").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Gas").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .style(Style::default().fg(Color::DarkGray)); + + let rows: Vec> = app + .blocks + .iter() + .skip(app.block_scroll) + .map(|b| { + Row::new(vec![ + Cell::from(format!("#{}", b.number)), + Cell::from(b.hash.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(format!("{}", b.tx_count)), + Cell::from(format_gas(b.gas_used)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(5), + Constraint::Min(6), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(block) + .row_highlight_style(Style::default().fg(Color::Cyan)); + + frame.render_widget(table, area); +} + +fn draw_accounts(frame: &mut Frame<'_>, app: &App, area: Rect) { + let mut items: Vec> = app + .accounts + .iter() + .enumerate() + .map(|(i, (addr, _key))| { + let truncated = if addr.len() > 10 { + format!("{}..{}", &addr[..6], &addr[addr.len() - 4..]) + } else { + addr.clone() + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("({i}) "), Style::default().fg(Color::DarkGray)), + Span::styled(truncated, Style::default().fg(Color::White)), + Span::styled(" 1000000 ETH", Style::default().fg(Color::Green)), + ])) + }) + .collect(); + + if let Some(ref contracts) = app.deploy_contracts { + items.push(ListItem::new(Line::from(""))); + items.push(ListItem::new(Line::from(Span::styled( + "Genesis Contracts", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )))); + for (name, addr) in contracts { + let truncated = if addr.len() > 10 { + format!("{}..{}", &addr[..6], &addr[addr.len() - 4..]) + } else { + addr.clone() + }; + items.push(ListItem::new(Line::from(vec![ + Span::styled(format!("{name:18} "), Style::default().fg(Color::DarkGray)), + Span::styled(truncated, Style::default().fg(Color::White)), + ]))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .title(" Accounts ") + .border_style(border_style(app, Panel::Accounts)); + + let list = List::new(items).block(block); + frame.render_widget(list, area); +} + +fn draw_logs(frame: &mut Frame<'_>, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Logs ") + .border_style(border_style(app, Panel::Logs)); + + let inner_height = area.height.saturating_sub(2) as usize; + let total = app.logs.len(); + + let end = total.saturating_sub(app.log_scroll); + let start = end.saturating_sub(inner_height); + + let items: Vec> = app + .logs + .iter() + .skip(start) + .take(end.saturating_sub(start)) + .map(|entry| { + let color = level_color(&entry.level); + let level_str = format!("{:5}", entry.level); + let elapsed = entry.timestamp.elapsed().as_secs(); + let ts = format!("{elapsed:>4}s"); + + let target_short = entry.target.rsplit("::").next().unwrap_or(&entry.target); + + let mut spans = vec![ + Span::styled(ts, Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(level_str, Style::default().fg(color)), + Span::raw(" "), + Span::styled( + format!("{target_short:>16} "), + Style::default().fg(Color::DarkGray), + ), + Span::styled(entry.message.clone(), Style::default().fg(Color::White)), + ]; + + for (k, v) in &entry.fields { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{k}="), + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled(v.clone(), Style::default().fg(Color::Gray))); + } + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(items).block(block); + frame.render_widget(list, area); +} + +fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { + let uptime = app.start_time.elapsed().as_secs(); + + let text = Line::from(vec![ + Span::styled(" Up: ", Style::default().fg(Color::DarkGray)), + Span::styled(format_uptime(uptime), Style::default().fg(Color::White)), + Span::styled(" Block: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("#{}", app.current_block), + Style::default().fg(Color::Cyan), + ), + Span::styled(" | ", Style::default().fg(Color::DarkGray)), + Span::styled("[q]", Style::default().fg(Color::Yellow)), + Span::styled("uit ", Style::default().fg(Color::DarkGray)), + Span::styled("[Tab]", Style::default().fg(Color::Yellow)), + Span::styled("focus ", Style::default().fg(Color::DarkGray)), + Span::styled("[", Style::default().fg(Color::Yellow)), + Span::styled("Up/Down", Style::default().fg(Color::Yellow)), + Span::styled("]", Style::default().fg(Color::Yellow)), + Span::styled("scroll", Style::default().fg(Color::DarkGray)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); +} From e0c333054bb94afadb3e0178499ab20d906a8a26 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 20 Mar 2026 15:41:09 +0100 Subject: [PATCH 2/2] feat(ev-dev): add real-time balance polling to TUI dashboard --- Cargo.lock | 1 + bin/ev-dev/Cargo.toml | 1 + bin/ev-dev/src/main.rs | 12 ++++++- bin/ev-dev/src/tui/app.rs | 71 ++++++++++++++++++++++++++++++++++++++- bin/ev-dev/src/tui/mod.rs | 3 +- bin/ev-dev/src/tui/ui.rs | 7 +++- 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 608726b..587e1d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2985,6 +2985,7 @@ name = "ev-dev" version = "0.1.0" dependencies = [ "alloy-primitives", + "alloy-provider", "alloy-signer-local", "clap", "crossterm", diff --git a/bin/ev-dev/Cargo.toml b/bin/ev-dev/Cargo.toml index deed6ff..57115c7 100644 --- a/bin/ev-dev/Cargo.toml +++ b/bin/ev-dev/Cargo.toml @@ -25,6 +25,7 @@ reth-ethereum-cli.workspace = true # Alloy dependencies alloy-signer-local.workspace = true alloy-primitives.workspace = true +alloy-provider.workspace = true # Reth tracing (for Layers type) reth-tracing.workspace = true diff --git a/bin/ev-dev/src/main.rs b/bin/ev-dev/src/main.rs index 187f3a4..4c8b660 100644 --- a/bin/ev-dev/src/main.rs +++ b/bin/ev-dev/src/main.rs @@ -333,7 +333,16 @@ fn run_with_tui(dev_args: EvDevArgs, deploy_cfg: Option) { let accounts = derive_keys(dev_args.accounts); let contracts = deploy_contracts_list(&deploy_cfg); - let app = tui::App::new(chain_id, rpc_url, block_time, accounts, contracts, log_rx); + let (balance_tx, balance_rx) = tokio::sync::mpsc::channel(16); + let app = tui::App::new( + chain_id, + rpc_url.clone(), + block_time, + accounts.clone(), + contracts, + log_rx, + balance_rx, + ); let (genesis_file, datadir) = prepare_genesis(&deploy_cfg); let genesis_path = genesis_file @@ -368,6 +377,7 @@ fn run_with_tui(dev_args: EvDevArgs, deploy_cfg: Option) { info!("=== EV-DEV: Local chain running - RPC ready ==="); + tui::spawn_balance_poller(rpc_url, accounts, balance_tx); tui::run(app).await?; Ok(()) diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs index d0a5b23..a0ac2a5 100644 --- a/bin/ev-dev/src/tui/app.rs +++ b/bin/ev-dev/src/tui/app.rs @@ -1,5 +1,6 @@ use std::{collections::VecDeque, time::Instant}; +use alloy_primitives::{Address, U256}; use tokio::sync::mpsc; const MAX_LOGS: usize = 1000; @@ -42,6 +43,7 @@ pub(crate) struct App { pub(crate) logs: VecDeque, pub(crate) current_block: u64, pub(crate) start_time: Instant, + pub(crate) balances: Vec, // UI state pub(crate) active_panel: Panel, @@ -49,8 +51,9 @@ pub(crate) struct App { pub(crate) block_scroll: usize, pub(crate) should_quit: bool, - // Channel + // Channels pub(crate) log_rx: mpsc::Receiver, + pub(crate) balance_rx: mpsc::Receiver>, } impl App { @@ -61,7 +64,10 @@ impl App { accounts: Vec<(String, String)>, deploy_contracts: Option>, log_rx: mpsc::Receiver, + balance_rx: mpsc::Receiver>, ) -> Self { + let initial_balance = "1000000 ETH".to_string(); + let balances = vec![initial_balance; accounts.len()]; Self { chain_id, rpc_url, @@ -72,11 +78,19 @@ impl App { logs: VecDeque::new(), current_block: 0, start_time: Instant::now(), + balances, active_panel: Panel::Logs, log_scroll: 0, block_scroll: 0, should_quit: false, log_rx, + balance_rx, + } + } + + pub(crate) fn drain_balances(&mut self) { + while let Ok(new_balances) = self.balance_rx.try_recv() { + self.balances = new_balances; } } @@ -154,3 +168,58 @@ impl App { } } } + +fn format_ether(wei: U256) -> String { + let ether_unit = U256::from(10u64).pow(U256::from(18)); + let whole = wei / ether_unit; + let remainder = wei % ether_unit; + + let frac_digits = 4; + let frac_unit = U256::from(10u64).pow(U256::from(18 - frac_digits)); + let frac = remainder / frac_unit; + + let frac_val: u64 = frac.try_into().unwrap_or(0); + let formatted = format!("{whole}.{frac_val:0>4}"); + // Trim trailing zeros but keep at least one decimal + let trimmed = formatted.trim_end_matches('0'); + let trimmed = trimmed.trim_end_matches('.'); + format!("{trimmed} ETH") +} + +pub(crate) fn spawn_balance_poller( + rpc_url: String, + accounts: Vec<(String, String)>, + tx: mpsc::Sender>, +) { + let addresses: Vec
= accounts + .iter() + .filter_map(|(addr, _)| addr.parse().ok()) + .collect(); + + tokio::spawn(async move { + use alloy_provider::{Provider, ProviderBuilder}; + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + loop { + interval.tick().await; + + let provider = match ProviderBuilder::new() + .connect_http(rpc_url.parse().expect("valid RPC URL")) + { + provider => provider, + }; + + let mut balances = Vec::with_capacity(addresses.len()); + for addr in &addresses { + match provider.get_balance(*addr).await { + Ok(bal) => balances.push(format_ether(bal)), + Err(_) => balances.push("? ETH".to_string()), + } + } + + if tx.send(balances).await.is_err() { + break; + } + } + }); +} diff --git a/bin/ev-dev/src/tui/mod.rs b/bin/ev-dev/src/tui/mod.rs index b611a3a..083eff3 100644 --- a/bin/ev-dev/src/tui/mod.rs +++ b/bin/ev-dev/src/tui/mod.rs @@ -3,7 +3,7 @@ mod events; mod tracing_layer; mod ui; -pub(crate) use app::App; +pub(crate) use app::{spawn_balance_poller, App}; pub(crate) use tracing_layer::TuiTracingLayer; use std::io::{self, stdout}; @@ -51,6 +51,7 @@ pub(crate) async fn run(mut app: App) -> eyre::Result<()> { tokio::select! { _ = tick.tick() => { app.drain_logs(); + app.drain_balances(); terminal.draw(|frame| ui::draw(frame, &app))?; } maybe_event = event_stream.next() => { diff --git a/bin/ev-dev/src/tui/ui.rs b/bin/ev-dev/src/tui/ui.rs index fe3d783..3006f70 100644 --- a/bin/ev-dev/src/tui/ui.rs +++ b/bin/ev-dev/src/tui/ui.rs @@ -165,10 +165,15 @@ fn draw_accounts(frame: &mut Frame<'_>, app: &App, area: Rect) { } else { addr.clone() }; + let balance = app + .balances + .get(i) + .cloned() + .unwrap_or_else(|| "? ETH".to_string()); ListItem::new(Line::from(vec![ Span::styled(format!("({i}) "), Style::default().fg(Color::DarkGray)), Span::styled(truncated, Style::default().fg(Color::White)), - Span::styled(" 1000000 ETH", Style::default().fg(Color::Green)), + Span::styled(format!(" {balance}"), Style::default().fg(Color::Green)), ])) }) .collect();