From e66008f3417a4ef4debeebc9ccf1a874f9e780bf Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 2 Oct 2025 16:54:27 -0700 Subject: [PATCH 01/19] initial check for process summary screen This check in offers alpha support for per process metrics, you can view threads, process CPU usage over time, IO, memory, CPU time, parent process, command, uptime and journal entries. This is unfinished but all major functionality is available and I wanted to make it available to feedback and testing. --- .gitignore | 4 + Cargo.lock | 499 ++++++- socktop/src/app.rs | 309 ++++- socktop/src/ui/modal.rs | 1272 +++++++++++++++++- socktop/src/ui/processes.rs | 229 +++- socktop/src/ui/theme.rs | 9 + socktop_agent/Cargo.toml | 2 + socktop_agent/src/cache.rs | 95 ++ socktop_agent/src/lib.rs | 17 + socktop_agent/src/metrics.rs | 662 ++++++++- socktop_agent/src/state.rs | 19 + socktop_agent/src/types.rs | 73 + socktop_agent/src/ws.rs | 88 ++ socktop_agent/tests/cache_tests.rs | 132 ++ socktop_agent/tests/process_details.rs | 89 ++ socktop_connector/src/connector.rs | 60 +- socktop_connector/src/connector_impl.rs | 17 +- socktop_connector/src/lib.rs | 3 +- socktop_connector/src/networking/requests.rs | 34 + socktop_connector/src/types.rs | 83 ++ socktop_connector/src/wasm/requests.rs | 25 +- 21 files changed, 3625 insertions(+), 96 deletions(-) create mode 100644 socktop_agent/src/cache.rs create mode 100644 socktop_agent/src/lib.rs create mode 100644 socktop_agent/tests/cache_tests.rs create mode 100644 socktop_agent/tests/process_details.rs diff --git a/.gitignore b/.gitignore index c8dfcec..1370721 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /target .vscode/ /socktop-wasm-test/target + +# Documentation files from development sessions (context-specific, not for public repo) +/OPTIMIZATION_PROCESS_DETAILS.md +/THREAD_SUPPORT.md diff --git a/Cargo.lock b/Cargo.lock index 330e942..a20b12b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,7 +137,7 @@ dependencies = [ "axum-core", "axum-macros", "base64", - "bytes", + "bytes 1.10.1", "futures-util", "http", "http-body", @@ -172,7 +172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", - "bytes", + "bytes 1.10.1", "futures-util", "http", "http-body", @@ -204,7 +204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1ad46c3ec4e12f4a4b6835e173ba21c25e484c9d02b49770bf006ce5367c036" dependencies = [ "arc-swap", - "bytes", + "bytes 1.10.1", "futures-util", "http", "http-body", @@ -227,7 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.1", "libc", "miniz_oxide", "object", @@ -247,7 +247,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -264,6 +264,12 @@ dependencies = [ "which", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -302,6 +308,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + [[package]] name = "bytes" version = "1.10.1" @@ -343,6 +359,12 @@ dependencies = [ "nom", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.1" @@ -373,6 +395,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "cmake" version = "0.1.54" @@ -389,7 +420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", - "cfg-if", + "cfg-if 1.0.1", "itoa", "rustversion", "ryu", @@ -427,7 +458,37 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", +] + +[[package]] +name = "crossbeam-queue" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c979cd6cfe72335896575c6b5688da489e420d36a27a0b9eb0c73db574b4a4b" +dependencies = [ + "crossbeam-utils 0.6.6", +] + +[[package]] +name = "crossbeam-utils" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04973fa96e96579258a5091af6003abde64af786b860f18622b82e026cca60e6" +dependencies = [ + "cfg-if 0.1.10", + "lazy_static", +] + +[[package]] +name = "crossbeam-utils" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" +dependencies = [ + "autocfg", + "cfg-if 0.1.10", + "lazy_static", ] [[package]] @@ -436,14 +497,14 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", "libc", "mio 0.8.11", - "parking_lot", + "parking_lot 0.12.4", "signal-hook", "signal-hook-mio", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -452,14 +513,14 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", "mio 1.0.4", - "parking_lot", + "parking_lot 0.12.4", "rustix 0.38.44", "signal-hook", "signal-hook-mio", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -468,7 +529,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ - "winapi", + "winapi 0.3.9", ] [[package]] @@ -553,7 +614,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "dirs-sys-next", ] @@ -565,7 +626,7 @@ checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", "redox_users", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -662,6 +723,28 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fuchsia-zircon" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" +dependencies = [ + "bitflags 1.3.2", + "fuchsia-zircon-sys", +] + +[[package]] +name = "fuchsia-zircon-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + [[package]] name = "futures" version = "0.3.31" @@ -767,7 +850,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "libc", "wasi 0.11.1+wasi-snapshot-preview1", ] @@ -778,7 +861,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", @@ -818,7 +901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", - "bytes", + "bytes 1.10.1", "fnv", "futures-core", "futures-sink", @@ -847,6 +930,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "home" version = "0.5.11" @@ -864,7 +953,7 @@ checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" dependencies = [ "libc", "match_cfg", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -873,7 +962,7 @@ version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ - "bytes", + "bytes 1.10.1", "fnv", "itoa", ] @@ -884,7 +973,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "bytes", + "bytes 1.10.1", "http", ] @@ -894,7 +983,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-core", "http", "http-body", @@ -919,7 +1008,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-channel", "futures-util", "h2", @@ -929,7 +1018,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "smallvec", + "smallvec 1.15.1", "tokio", ] @@ -939,7 +1028,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-core", "http", "http-body", @@ -1010,7 +1099,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec", + "smallvec 1.15.1", "zerovec", ] @@ -1072,7 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", - "smallvec", + "smallvec 1.15.1", "utf8_iter", ] @@ -1131,8 +1220,17 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags", - "cfg-if", + "bitflags 2.9.1", + "cfg-if 1.0.1", + "libc", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ "libc", ] @@ -1180,6 +1278,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1213,7 +1321,7 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "windows-targets 0.53.3", ] @@ -1223,7 +1331,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags", + "bitflags 2.9.1", "libc", ] @@ -1245,6 +1353,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "lock_api" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4da24a77a3d8a6d4862d95f72e6fdb9c09a643ecdb402d754004a557f2bec75" +dependencies = [ + "scopeguard", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -1300,6 +1417,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + [[package]] name = "memchr" version = "2.7.5" @@ -1327,6 +1450,25 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" +dependencies = [ + "cfg-if 0.1.10", + "fuchsia-zircon", + "fuchsia-zircon-sys", + "iovec", + "kernel32-sys", + "libc", + "log", + "miow 0.2.2", + "net2", + "slab", + "winapi 0.2.8", +] + [[package]] name = "mio" version = "0.8.11" @@ -1351,12 +1493,67 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mio-named-pipes" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0840c1c50fd55e521b247f949c241c9997709f23bd7f023b9762cd561e935656" +dependencies = [ + "log", + "mio 0.6.23", + "miow 0.3.7", + "winapi 0.3.9", +] + +[[package]] +name = "mio-uds" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" +dependencies = [ + "iovec", + "libc", + "mio 0.6.23", +] + +[[package]] +name = "miow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" +dependencies = [ + "kernel32-sys", + "net2", + "winapi 0.2.8", + "ws2_32-sys", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "multimap" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "net2" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "winapi 0.3.9", +] + [[package]] name = "nom" version = "7.1.3" @@ -1373,7 +1570,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" dependencies = [ - "winapi", + "winapi 0.3.9", ] [[package]] @@ -1383,7 +1580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ "overload", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -1401,13 +1598,23 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "nvml-wrapper" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5" dependencies = [ - "bitflags", + "bitflags 2.9.1", "libloading", "nvml-wrapper-sys", "static_assertions", @@ -1430,7 +1637,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -1464,14 +1671,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking_lot" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" +dependencies = [ + "lock_api 0.3.4", + "parking_lot_core 0.6.3", + "rustc_version", +] + [[package]] name = "parking_lot" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.4.13", + "parking_lot_core 0.9.11", +] + +[[package]] +name = "parking_lot_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66b810a62be75176a80873726630147a5ca780cd33921e0b5709033e66b0a" +dependencies = [ + "cfg-if 0.1.10", + "cloudabi", + "libc", + "redox_syscall 0.1.57", + "rustc_version", + "smallvec 0.6.14", + "winapi 0.3.9", ] [[package]] @@ -1480,10 +1713,10 @@ version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "libc", - "redox_syscall", - "smallvec", + "redox_syscall 0.5.17", + "smallvec 1.15.1", "windows-targets 0.52.6", ] @@ -1627,7 +1860,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ - "bytes", + "bytes 1.10.1", "prost-derive", ] @@ -1788,7 +2021,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cassowary", "compact_str", "crossterm 0.28.1", @@ -1816,13 +2049,19 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -1887,7 +2126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.1", "getrandom 0.2.16", "libc", "untrusted", @@ -1906,13 +2145,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1925,7 +2173,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -2028,6 +2276,21 @@ dependencies = [ "untrusted", ] +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.219" @@ -2088,7 +2351,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "cpufeatures", "digest", ] @@ -2145,6 +2408,15 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2205,6 +2477,7 @@ dependencies = [ "tempfile", "time", "tokio", + "tokio-process", "tokio-tungstenite 0.21.0", "tonic-build", "tracing", @@ -2387,7 +2660,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", ] [[package]] @@ -2438,11 +2711,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", - "bytes", + "bytes 1.10.1", "io-uring", "libc", "mio 1.0.4", - "parking_lot", + "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", "slab", @@ -2451,6 +2724,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tokio-executor" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb2d1b8f4548dbf5e1f7818512e9c406860678f29c300cdf0ebac72d1a3a1671" +dependencies = [ + "crossbeam-utils 0.7.2", + "futures 0.1.31", +] + +[[package]] +name = "tokio-io" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" +dependencies = [ + "bytes 0.4.12", + "futures 0.1.31", + "log", +] + [[package]] name = "tokio-macros" version = "2.5.0" @@ -2462,6 +2756,44 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-process" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382d90f43fa31caebe5d3bc6cfd854963394fff3b8cb59d5146607aaae7e7e43" +dependencies = [ + "crossbeam-queue", + "futures 0.1.31", + "lazy_static", + "libc", + "log", + "mio 0.6.23", + "mio-named-pipes", + "tokio-io", + "tokio-reactor", + "tokio-signal", + "winapi 0.3.9", +] + +[[package]] +name = "tokio-reactor" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09bc590ec4ba8ba87652da2068d150dcada2cfa2e07faae270a5e0409aa51351" +dependencies = [ + "crossbeam-utils 0.7.2", + "futures 0.1.31", + "lazy_static", + "log", + "mio 0.6.23", + "num_cpus", + "parking_lot 0.9.0", + "slab", + "tokio-executor", + "tokio-io", + "tokio-sync", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -2482,6 +2814,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-signal" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c34c6e548f101053321cba3da7cbb87a610b85555884c41b07da2eb91aff12" +dependencies = [ + "futures 0.1.31", + "libc", + "mio 0.6.23", + "mio-uds", + "signal-hook-registry", + "tokio-executor", + "tokio-io", + "tokio-reactor", + "winapi 0.3.9", +] + +[[package]] +name = "tokio-sync" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfe50152bc8164fcc456dab7891fa9bf8beaf01c5ee7e1dd43a397c3cf87dee" +dependencies = [ + "fnv", + "futures 0.1.31", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -2515,7 +2874,7 @@ version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-core", "futures-sink", "pin-project-lite", @@ -2632,7 +2991,7 @@ dependencies = [ "once_cell", "regex", "sharded-slab", - "smallvec", + "smallvec 1.15.1", "thread_local", "tracing", "tracing-core", @@ -2646,7 +3005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" dependencies = [ "byteorder", - "bytes", + "bytes 1.10.1", "data-encoding", "http", "httparse", @@ -2665,7 +3024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" dependencies = [ "byteorder", - "bytes", + "bytes 1.10.1", "data-encoding", "http", "httparse", @@ -2784,7 +3143,7 @@ version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -2810,7 +3169,7 @@ version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "js-sys", "once_cell", "wasm-bindgen", @@ -2871,6 +3230,12 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + [[package]] name = "winapi" version = "0.3.9" @@ -2881,6 +3246,12 @@ dependencies = [ "winapi-x86_64-pc-windows-gnu", ] +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" @@ -3307,7 +3678,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -3317,7 +3688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f902b4592b911109e7352bcfec7b754b07ec71e514d7dfa280eaef924c1cb08" dependencies = [ "chrono", - "futures", + "futures 0.3.31", "log", "serde", "thiserror 2.0.12", @@ -3343,6 +3714,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "ws2_32-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 5974111..71e47d0 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -17,7 +17,7 @@ use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Rect}, }; -use tokio::time::sleep; +use tokio::time::{sleep, timeout}; use crate::history::{PerCoreHistory, push_capped}; use crate::retry::{RetryTiming, compute_retry_timing}; @@ -28,7 +28,9 @@ use crate::ui::cpu::{ per_core_handle_scrollbar_mouse, }; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; -use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse}; +use crate::ui::processes::{ + ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection, +}; use crate::ui::{ disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, swap::draw_swap, @@ -76,12 +78,32 @@ pub struct App { pub procs_sort_by: ProcSortBy, last_procs_area: Option, + // Process selection state + pub selected_process_pid: Option, + pub selected_process_index: Option, // Index in the visible/sorted list + prev_selected_process_pid: Option, // Track previous selection to detect changes + last_procs_poll: Instant, last_disks_poll: Instant, procs_interval: Duration, disks_interval: Duration, metrics_interval: Duration, + // Process details polling + pub process_details: Option, + pub journal_entries: Option, + pub process_cpu_history: VecDeque, // CPU history for sparkline (last 60 samples) + pub process_mem_history: VecDeque, // Memory usage history in bytes (last 60 samples) + pub process_io_read_history: VecDeque, // Disk read DELTA history in bytes (last 60 samples) + pub process_io_write_history: VecDeque, // Disk write DELTA history in bytes (last 60 samples) + last_io_read_bytes: Option, // Previous read bytes for delta calculation + last_io_write_bytes: Option, // Previous write bytes for delta calculation + pub process_details_unsupported: bool, // Track if agent doesn't support process details + last_process_details_poll: Instant, + last_journal_poll: Instant, + process_details_interval: Duration, + journal_interval: Duration, + // For reconnects ws_url: String, tls_ca: Option, @@ -120,6 +142,9 @@ impl App { procs_drag: None, procs_sort_by: ProcSortBy::CpuDesc, last_procs_area: None, + selected_process_pid: None, + selected_process_index: None, + prev_selected_process_pid: None, last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -129,6 +154,23 @@ impl App { procs_interval: Duration::from_secs(2), disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), + process_details: None, + journal_entries: None, + process_cpu_history: VecDeque::with_capacity(600), + process_mem_history: VecDeque::with_capacity(600), + process_io_read_history: VecDeque::with_capacity(600), + process_io_write_history: VecDeque::with_capacity(600), + last_io_read_bytes: None, + last_io_write_bytes: None, + process_details_unsupported: false, + last_process_details_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + last_journal_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + process_details_interval: Duration::from_millis(500), + journal_interval: Duration::from_secs(5), ws_url: String::new(), tls_ca: None, verify_hostname: false, @@ -565,14 +607,42 @@ impl App { continue; // Skip normal key processing } ModalAction::Cancel | ModalAction::Dismiss => { + // If ProcessDetails modal was dismissed, clear the data to save resources + if let Some(crate::ui::modal::ModalType::ProcessDetails { + .. + }) = self.modal_manager.current_modal() + { + self.clear_process_details(); + } // Modal was dismissed, continue to normal processing } ModalAction::Confirm => { // Handle confirmation action here if needed in the future } + ModalAction::SwitchToParentProcess(_current_pid) => { + // Get parent PID from current process details + if let Some(details) = &self.process_details { + if let Some(parent_pid) = details.process.parent_pid { + // Clear current process details + self.clear_process_details(); + // Update selected process to parent + self.selected_process_pid = Some(parent_pid); + // Open modal for parent process + self.modal_manager.push_modal( + crate::ui::modal::ModalType::ProcessDetails { + pid: parent_pid, + }, + ); + } + } + continue; + } + ModalAction::Handled => { + // Modal consumed the key, don't pass to main window + continue; + } ModalAction::None => { - // Modal is still active but didn't consume the key - continue; // Skip normal key processing + // Modal didn't handle the key, pass through to normal handling } } } @@ -603,7 +673,49 @@ impl App { .split(rows[1]); let content = per_core_content_area(top[1]); - per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize); + // First try process selection (only handles arrows if a process is selected) + let process_handled = if let Some(p_area) = self.last_procs_area { + let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) + let total_rows = self + .last_metrics + .as_ref() + .map(|m| m.top_processes.len()) + .unwrap_or(0); + processes_handle_key_with_selection( + &mut self.procs_scroll_offset, + &mut self.selected_process_pid, + &mut self.selected_process_index, + k, + page, + total_rows, + self.last_metrics.as_ref(), + ) + } else { + false + }; + + // If process selection didn't handle it, use CPU scrolling + if !process_handled { + per_core_handle_key( + &mut self.per_core_scroll, + k, + content.height as usize, + ); + } + + // Check if process selection changed and clear details if so + if self.selected_process_pid != self.prev_selected_process_pid { + self.clear_process_details(); + self.prev_selected_process_pid = self.selected_process_pid; + } + + // Check if Enter was pressed with a process selected + if process_handled && k.code == KeyCode::Enter { + if let Some(selected_pid) = self.selected_process_pid { + self.modal_manager + .push_modal(ModalType::ProcessDetails { pid: selected_pid }); + } + } let total_rows = self .last_metrics @@ -615,14 +727,13 @@ impl App { total_rows, content.height as usize, ); - - if let Some(p_area) = self.last_procs_area { - // page size = visible rows (inner height minus header = 1) - let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) - processes_handle_key(&mut self.procs_scroll_offset, k, page); - } } Event::Mouse(m) => { + // If modal is active, don't handle mouse events on the main window + if self.modal_manager.is_active() { + continue; + } + // Layout to get areas let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); @@ -671,18 +782,32 @@ impl App { content.height as usize, ); - // Processes table: sort by column on header click + // Processes table: sort by column on header click and handle row selection if let (Some(mm), Some(p_area)) = (self.last_metrics.as_ref(), self.last_procs_area) - && let Some(new_sort) = processes_handle_mouse( - &mut self.procs_scroll_offset, - &mut self.procs_drag, - m, - p_area, - mm.top_processes.len(), - ) { - self.procs_sort_by = new_sort; + use crate::ui::processes::ProcessMouseParams; + if let Some(new_sort) = + processes_handle_mouse_with_selection(ProcessMouseParams { + scroll_offset: &mut self.procs_scroll_offset, + selected_process_pid: &mut self.selected_process_pid, + selected_process_index: &mut self.selected_process_index, + drag: &mut self.procs_drag, + mouse: m, + area: p_area, + total_rows: mm.top_processes.len(), + metrics: self.last_metrics.as_ref(), + sort_by: self.procs_sort_by, + }) + { + self.procs_sort_by = new_sort; + } + } + + // Check if process selection changed via mouse and clear details if so + if self.selected_process_pid != self.prev_selected_process_pid { + self.clear_process_details(); + self.prev_selected_process_pid = self.selected_process_pid; } } Event::Resize(_, _) => {} @@ -732,6 +857,99 @@ impl App { } self.last_disks_poll = Instant::now(); } + + // Poll process details when modal is active and process is selected + if let Some(pid) = self.selected_process_pid { + // Check if ProcessDetails modal is currently active + if let Some(crate::ui::modal::ModalType::ProcessDetails { .. }) = + self.modal_manager.current_modal() + { + // Poll process details every 500ms when modal is active + if self.last_process_details_poll.elapsed() + >= self.process_details_interval + { + // Use timeout to prevent blocking the event loop + match timeout( + Duration::from_millis(2000), + ws.request(AgentRequest::ProcessMetrics { pid }), + ) + .await + { + Ok(Ok(AgentResponse::ProcessMetrics(details))) => { + // Update history for sparklines + let cpu_usage = details.process.cpu_usage; + push_capped(&mut self.process_cpu_history, cpu_usage, 600); + + let mem_bytes = details.process.mem_bytes; + push_capped(&mut self.process_mem_history, mem_bytes, 600); + + // I/O bytes from agent are cumulative, calculate deltas + if let Some(read) = details.process.read_bytes { + let delta = if let Some(last) = self.last_io_read_bytes + { + read.saturating_sub(last) + } else { + 0 // First sample, no delta available + }; + push_capped( + &mut self.process_io_read_history, + delta, + 600, + ); + self.last_io_read_bytes = Some(read); + } + if let Some(write) = details.process.write_bytes { + let delta = if let Some(last) = self.last_io_write_bytes + { + write.saturating_sub(last) + } else { + 0 // First sample, no delta available + }; + push_capped( + &mut self.process_io_write_history, + delta, + 600, + ); + self.last_io_write_bytes = Some(write); + } + + self.process_details = Some(details); + self.process_details_unsupported = false; + } + Ok(Err(_)) | Err(_) => { + // Agent doesn't support this feature or timeout occurred + // Mark as unsupported so we can show appropriate message + self.process_details_unsupported = true; + } + Ok(Ok(_)) => { + // Wrong response type + self.process_details_unsupported = true; + } + } + self.last_process_details_poll = Instant::now(); + } + + // Poll journal entries every 5s when modal is active + if self.last_journal_poll.elapsed() >= self.journal_interval { + // Use timeout to prevent blocking the event loop + match timeout( + Duration::from_millis(2000), + ws.request(AgentRequest::JournalEntries { pid }), + ) + .await + { + Ok(Ok(AgentResponse::JournalEntries(journal))) => { + self.journal_entries = Some(journal); + } + Ok(Err(_)) | Err(_) | Ok(Ok(_)) => { + // Agent doesn't support this feature, error occurred, or wrong response type + // Keep journal_entries as None + } + } + self.last_journal_poll = Instant::now(); + } + } + } } Err(e) => { // Connection error - show modal if not already shown @@ -760,6 +978,19 @@ impl App { Ok(()) } + /// Clear process details when modal is closed or selection changes + pub fn clear_process_details(&mut self) { + self.process_details = None; + self.journal_entries = None; + self.process_cpu_history.clear(); + self.process_mem_history.clear(); + self.process_io_read_history.clear(); + self.process_io_write_history.clear(); + self.last_io_read_bytes = None; + self.last_io_write_bytes = None; + self.process_details_unsupported = false; + } + fn update_with_metrics(&mut self, mut m: Metrics) { if let Some(prev) = &self.last_metrics { // Preserve slower fields when the fast payload omits them @@ -919,11 +1150,27 @@ impl App { self.last_metrics.as_ref(), self.procs_scroll_offset, self.procs_sort_by, + self.selected_process_pid, + self.selected_process_index, ); // Render modals on top of everything else if self.modal_manager.is_active() { - self.modal_manager.render(f); + use crate::ui::modal::{ProcessHistoryData, ProcessModalData}; + self.modal_manager.render( + f, + ProcessModalData { + details: self.process_details.as_ref(), + journal: self.journal_entries.as_ref(), + history: ProcessHistoryData { + cpu: &self.process_cpu_history, + mem: &self.process_mem_history, + io_read: &self.process_io_read_history, + io_write: &self.process_io_write_history, + }, + unsupported: self.process_details_unsupported, + }, + ); } } } @@ -946,6 +1193,9 @@ impl Default for App { procs_drag: None, procs_sort_by: ProcSortBy::CpuDesc, last_procs_area: None, + selected_process_pid: None, + selected_process_index: None, + prev_selected_process_pid: None, last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -955,6 +1205,23 @@ impl Default for App { procs_interval: Duration::from_secs(2), disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), + process_details: None, + journal_entries: None, + process_cpu_history: VecDeque::with_capacity(600), + process_mem_history: VecDeque::with_capacity(600), + process_io_read_history: VecDeque::with_capacity(600), + process_io_write_history: VecDeque::with_capacity(600), + last_io_read_bytes: None, + last_io_write_bytes: None, + process_details_unsupported: false, + last_process_details_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + last_journal_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + process_details_interval: Duration::from_millis(500), + journal_interval: Duration::from_secs(5), ws_url: String::new(), tls_ca: None, verify_hostname: false, diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index f020d62..d6e0d71 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -16,9 +16,37 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + widgets::{ + Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, Table, Wrap, + }, }; +/// History data for process metrics rendering +pub struct ProcessHistoryData<'a> { + pub cpu: &'a std::collections::VecDeque, + pub mem: &'a std::collections::VecDeque, + pub io_read: &'a std::collections::VecDeque, + pub io_write: &'a std::collections::VecDeque, +} + +/// Process data for modal rendering +pub struct ProcessModalData<'a> { + pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, + pub journal: Option<&'a socktop_connector::JournalResponse>, + pub history: ProcessHistoryData<'a>, + pub unsupported: bool, +} + +/// Parameters for rendering scatter plot +struct ScatterPlotParams<'a> { + process: &'a socktop_connector::DetailedProcessInfo, + main_user_ms: f64, + main_system_ms: f64, + max_user: f64, + max_system: f64, +} + #[derive(Debug, Clone)] pub enum ModalType { ConnectionError { @@ -27,6 +55,9 @@ pub enum ModalType { retry_count: u32, auto_retry_countdown: Option, }, + ProcessDetails { + pid: u32, + }, #[allow(dead_code)] Confirmation { title: String, @@ -35,17 +66,22 @@ pub enum ModalType { cancel_text: String, }, #[allow(dead_code)] - Info { title: String, message: String }, + Info { + title: String, + message: String, + }, } #[derive(Debug, Clone, PartialEq)] pub enum ModalAction { - None, + None, // Modal didn't handle the key, pass to main window + Handled, // Modal handled the key, don't pass to main window RetryConnection, ExitApp, Confirm, Cancel, Dismiss, + SwitchToParentProcess(u32), // Switch to viewing parent process details } #[derive(Debug, Clone, PartialEq)] @@ -61,6 +97,10 @@ pub enum ModalButton { pub struct ModalManager { stack: Vec, active_button: ModalButton, + pub thread_scroll_offset: usize, + pub journal_scroll_offset: usize, + thread_scroll_max: usize, + journal_scroll_max: usize, } impl ModalManager { @@ -68,16 +108,32 @@ impl ModalManager { Self { stack: Vec::new(), active_button: ModalButton::Retry, + thread_scroll_offset: 0, + journal_scroll_offset: 0, + thread_scroll_max: 0, + journal_scroll_max: 0, } } pub fn is_active(&self) -> bool { !self.stack.is_empty() } + pub fn current_modal(&self) -> Option<&ModalType> { + self.stack.last() + } + pub fn push_modal(&mut self, modal: ModalType) { self.stack.push(modal); self.active_button = match self.stack.last() { Some(ModalType::ConnectionError { .. }) => ModalButton::Retry, + Some(ModalType::ProcessDetails { .. }) => { + // Reset scroll state for new process details + self.thread_scroll_offset = 0; + self.journal_scroll_offset = 0; + self.thread_scroll_max = 0; + self.journal_scroll_max = 0; + ModalButton::Ok + } Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Info { .. }) => ModalButton::Ok, None => ModalButton::Ok, @@ -88,6 +144,7 @@ impl ModalManager { if let Some(next) = self.stack.last() { self.active_button = match next { ModalType::ConnectionError { .. } => ModalButton::Retry, + ModalType::ProcessDetails { .. } => ModalButton::Ok, ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Info { .. } => ModalButton::Ok, }; @@ -135,6 +192,85 @@ impl ModalManager { ModalAction::None } } + KeyCode::Char('x') | KeyCode::Char('X') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + // Close all ProcessDetails modals at once (handles parent navigation chain) + while matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.pop_modal(); + } + ModalAction::Dismiss + } else { + ModalAction::None + } + } + KeyCode::Char('j') | KeyCode::Char('J') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self + .thread_scroll_offset + .saturating_add(1) + .min(self.thread_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('k') | KeyCode::Char('K') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(1); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('d') | KeyCode::Char('D') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self + .thread_scroll_offset + .saturating_add(10) + .min(self.thread_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('u') | KeyCode::Char('U') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(10); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('[') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.journal_scroll_offset = self.journal_scroll_offset.saturating_sub(1); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char(']') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.journal_scroll_offset = self + .journal_scroll_offset + .saturating_add(1) + .min(self.journal_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('p') | KeyCode::Char('P') => { + // Switch to parent process if it exists + if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() { + // We need to get the parent PID from the process details + // For now, return a special action that the app can handle + // The app has access to the process details and can extract parent_pid + ModalAction::SwitchToParentProcess(*pid) + } else { + ModalAction::None + } + } _ => ModalAction::None, } } @@ -144,6 +280,10 @@ impl ModalManager { ModalAction::RetryConnection } (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp, + (Some(ModalType::ProcessDetails { .. }), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Info { .. }), ModalButton::Ok) => { @@ -166,10 +306,10 @@ impl ModalManager { self.next_button(); } - pub fn render(&self, f: &mut Frame) { - if let Some(m) = self.stack.last() { + pub fn render(&mut self, f: &mut Frame, data: ProcessModalData) { + if let Some(m) = self.stack.last().cloned() { self.render_background_dim(f); - self.render_modal_content(f, m); + self.render_modal_content(f, &m, data); } } @@ -184,9 +324,19 @@ impl ModalManager { ); } - fn render_modal_content(&self, f: &mut Frame, modal: &ModalType) { + fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) { let area = f.area(); - let modal_area = self.centered_rect(70, 50, area); + // Different sizes for different modal types + let modal_area = match modal { + ModalType::ProcessDetails { .. } => { + // Process details modal uses almost full screen (95% width, 90% height) + self.centered_rect(95, 90, area) + } + _ => { + // Other modals use smaller size + self.centered_rect(70, 50, area) + } + }; f.render_widget(Clear, modal_area); match modal { ModalType::ConnectionError { @@ -202,6 +352,9 @@ impl ModalManager { *retry_count, *auto_retry_countdown, ), + ModalType::ProcessDetails { pid } => { + self.render_process_details(f, modal_area, *pid, data) + } ModalType::Confirmation { title, message, @@ -595,6 +748,1109 @@ impl ModalManager { ]) .split(vert[1])[1] } + + fn render_process_details( + &mut self, + f: &mut Frame, + area: Rect, + pid: u32, + data: ProcessModalData, + ) { + let title = format!("Process Details - PID {pid}"); + + // Use neutral colors to match main UI aesthetic + let block = Block::default().title(title).borders(Borders::ALL); + + // Split the modal into the 3-row layout as designed + let inner = block.inner(area); + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot + Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) + Constraint::Min(6), // Bottom row: Journal events (gets remaining space) + Constraint::Length(1), // Help line + ]) + .split(inner); + + // Render the border + f.render_widget(block, area); + + if let Some(details) = data.details { + // Top Row: CPU sparkline (left) | Thread scatter plot (right) + self.render_top_row_with_sparkline( + f, + main_chunks[0], + &details.process, + data.history.cpu, + ); + + // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) + self.render_middle_row_with_metadata( + f, + main_chunks[1], + &details.process, + data.history.mem, + data.history.io_read, + data.history.io_write, + ); + + // Bottom Row: Journal Events + if let Some(journal) = data.journal { + self.render_journal_events(f, main_chunks[2], journal); + } else { + self.render_loading_journal_events(f, main_chunks[2]); + } + } else if data.unsupported { + // Agent doesn't support this feature + self.render_unsupported_message(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } else { + // Loading states for all sections + self.render_loading_top_row(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } + + // Help line + let help_text = vec![Line::from(vec![ + Span::styled( + "X ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "P ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "goto parent ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::styled( + "j/k ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "[ ] ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), + ])]; + let help = Paragraph::new(help_text).alignment(Alignment::Center); + f.render_widget(help, main_chunks[3]); + } + + fn render_thread_scatter_plot( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let plot_block = Block::default() + .title("Thread & Process CPU Time") + .borders(Borders::ALL); + + let inner = plot_block.inner(area); + + // Convert CPU times from microseconds to milliseconds for better readability + let main_user_ms = process.cpu_time_user as f64 / 1000.0; + let main_system_ms = process.cpu_time_system as f64 / 1000.0; + + // Calculate max values for scaling + let mut max_user = main_user_ms; + let mut max_system = main_system_ms; + + for child in &process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + max_user = max_user.max(child_user_ms); + max_system = max_system.max(child_system_ms); + } + + // Add some padding to the scale + max_user = (max_user * 1.1).max(1.0); + max_system = (max_system * 1.1).max(1.0); + + // Render the existing scatter plot but in the smaller space + self.render_scatter_plot_content( + f, + inner, + ScatterPlotParams { + process, + main_user_ms, + main_system_ms, + max_user, + max_system, + }, + ); + + // Render the border + f.render_widget(plot_block, area); + } + + fn render_memory_io_graphs( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + let graphs_block = Block::default().title("Memory & I/O").borders(Borders::ALL); + + let mem_mb = process.mem_bytes as f64 / 1_048_576.0; + let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; + + let mut content_lines = vec![ + Line::from(vec![Span::styled( + "🧠 Memory", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + Span::styled(" 📊 RSS: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{mem_mb:.1} MB")), + ]), + ]; + + // Add memory sparkline if we have history + if mem_history.len() >= 2 { + let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB + let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); + + // Create mini sparkline using Unicode blocks + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = mem_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Blue)), + ])); + } else { + content_lines.push(Line::from(vec![Span::styled( + " Collecting...", + Style::default().add_modifier(Modifier::DIM), + )])); + } + + content_lines.push(Line::from(vec![ + Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{virtual_mb:.1} MB")), + ])); + + // Add shared memory if available + if let Some(shared_bytes) = process.shared_mem_bytes { + let shared_mb = shared_bytes as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{shared_mb:.1} MB")), + ])); + } + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "💾 Disk I/O", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Add I/O stats if available + match (process.read_bytes, process.write_bytes) { + (Some(read), Some(write)) => { + let read_mb = read as f64 / 1_048_576.0; + let write_mb = write as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" 📖 Read: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{read_mb:.1} MB")), + ])); + + // Add read I/O sparkline if we have history + if io_read_history.len() >= 2 { + let read_data: Vec = io_read_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = read_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Green)), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled( + " ✍️ Write: ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::raw(format!("{write_mb:.1} MB")), + ])); + + // Add write I/O sparkline if we have history + if io_write_history.len() >= 2 { + let write_data: Vec = io_write_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = write_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), + ])); + } + } + _ => { + content_lines.push(Line::from(vec![Span::styled( + " Not available", + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + let content = Paragraph::new(content_lines).block(graphs_block); + + f.render_widget(content, area); + } + + fn render_thread_table( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let total_items = process.threads.len() + process.child_processes.len(); + + // Manually calculate inner area (like processes.rs does) + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) + let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; + + // Calculate and store max scroll for key handler bounds checking + self.thread_scroll_max = if total_items > visible_rows { + total_items.saturating_sub(visible_rows) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); + + // Combine threads and processes into rows + let mut rows = Vec::new(); + + // Add threads first + for thread in &process.threads { + rows.push(Row::new(vec![ + Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), + Line::from(format!("{}", thread.tid)), + Line::from(thread.name.clone()), + Line::from(thread.status.clone()), + ])); + } + + // Add child processes + for child in &process.child_processes { + rows.push(Row::new(vec![ + Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), + Line::from(format!("{}", child.pid)), + Line::from(child.name.clone()), + Line::from(format!("{:.1}%", child.cpu_usage)), + ])); + } + + // Create table header + let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) + .style(Style::default().add_modifier(Modifier::BOLD)) + .bottom_margin(1); + + let block = Block::default() + .title(format!( + "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", + process.threads.len(), + process.child_processes.len() + )) + .borders(Borders::ALL); + + let table = Table::new( + rows.iter().skip(scroll_offset).take(visible_rows).cloned(), + [ + Constraint::Length(6), + Constraint::Length(10), + Constraint::Min(15), + Constraint::Length(12), + ], + ) + .header(header) + .block(block) + .highlight_style(Style::default()); + + f.render_widget(table, area); + + // Render scrollbar if there are more items than visible + if total_items > visible_rows { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + // This ensures the scrollbar position matches our actual scroll range + let mut scrollbar_state = + ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_journal_events( + &mut self, + f: &mut Frame, + area: Rect, + journal: &socktop_connector::JournalResponse, + ) { + let total_entries = journal.entries.len(); + let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders + + // Calculate and store max scroll for key handler bounds checking + self.journal_scroll_max = if total_entries > visible_lines { + total_entries.saturating_sub(visible_lines) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); + + let journal_block = Block::default() + .title(format!( + "Journal Events ({total_entries} entries) - Use [ ] to scroll" + )) + .borders(Borders::ALL); + + let content_lines: Vec = if journal.entries.is_empty() { + vec![ + Line::from(""), + Line::from(Span::styled( + "No journal entries found for this process", + Style::default().add_modifier(Modifier::DIM), + )), + ] + } else { + journal + .entries + .iter() + .skip(scroll_offset) + .take(visible_lines) + .map(|entry| { + let priority_style = match entry.priority { + socktop_connector::LogLevel::Error + | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), + socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), + socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { + Style::default().fg(Color::Blue) + } + _ => Style::default(), + }; + + let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time + let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority + let message = &entry.message[..entry.message.len().min(message_max_len)]; + + Line::from(vec![ + Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::styled( + format!("{:>7}", format!("{:?}", entry.priority)), + priority_style, + ), + Span::raw(" "), + Span::raw(message), + if entry.message.len() > message_max_len { + Span::styled("...", Style::default().add_modifier(Modifier::DIM)) + } else { + Span::raw("") + }, + ]) + }) + .collect() + }; + + let content = Paragraph::new(content_lines).block(journal_block); + + f.render_widget(content, area); + + // Render scrollbar if there are more entries than visible + if total_entries > visible_lines { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + let mut scrollbar_state = + ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { + if area.width < 20 || area.height < 10 { + // Area too small for meaningful plot + let content = Paragraph::new(vec![Line::from(Span::styled( + "Area too small for plot", + Style::default().fg(MODAL_HINT_FG), + ))]) + .alignment(Alignment::Center) + .style(Style::default().bg(MODAL_BG)); + f.render_widget(content, area); + return; + } + + // Calculate plot dimensions (leave space for axes labels) + let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels + let plot_height = area.height.saturating_sub(3) as usize; // Leave space for X-axis labels + + if plot_width == 0 || plot_height == 0 { + return; + } + + // Create a 2D grid to represent the plot + let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; + + // Plot main process + let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let main_y = plot_height.saturating_sub(1).saturating_sub( + ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + if main_x < plot_width && main_y < plot_height { + plot_grid[main_y][main_x] = '●'; // Main process marker + } + + // Plot threads (use different marker) + for thread in ¶ms.process.threads { + let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; + let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; + + let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let thread_y = plot_height.saturating_sub(1).saturating_sub( + ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if thread_x < plot_width && thread_y < plot_height { + if plot_grid[thread_y][thread_x] == ' ' { + plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) + } else if plot_grid[thread_y][thread_x] == '○' { + plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point + } else { + plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point + } + } + } + + // Plot child processes + for child in ¶ms.process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + + let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let child_y = plot_height.saturating_sub(1).saturating_sub( + ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if child_x < plot_width && child_y < plot_height { + if plot_grid[child_y][child_x] == ' ' { + plot_grid[child_y][child_x] = '•'; // Child process marker + } else { + plot_grid[child_y][child_x] = '◉'; // Multiple items at same point + } + } + } + + // Render the plot + let mut lines = Vec::new(); + + // Add Y-axis labels and plot content + for (i, row) in plot_grid.iter().enumerate() { + let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); + // Always format with 4 characters width, right-aligned, to prevent axis shifting + let y_label = if y_value >= 100.0 { + format!("{y_value:>4.0}") + } else { + format!("{y_value:>4.1}") + }; + + let plot_content: String = row.iter().collect(); + + lines.push(Line::from(vec![ + Span::styled(y_label, Style::default()), + Span::styled(" │", Style::default()), + Span::styled(plot_content, Style::default()), + ])); + } + + // Add X-axis + let x_axis_padding = " ".to_string(); // Match Y-axis label width + let x_axis_line = "─".repeat(plot_width + 1); + lines.push(Line::from(vec![ + Span::styled(x_axis_padding, Style::default()), + Span::styled(x_axis_line, Style::default()), + ])); + + // Add X-axis labels + let x_label_start = "0.0".to_string(); + let x_label_mid = format!("{:.1}", params.max_user / 2.0); + let x_label_end = format!("{:.1}", params.max_user); + + let spacing = plot_width / 3; + let x_labels = format!( + " {}{}{}{}{}", + x_label_start, + " ".repeat(spacing.saturating_sub(x_label_start.len())), + x_label_mid, + " ".repeat(spacing.saturating_sub(x_label_mid.len())), + x_label_end + ); + + lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); + + // Add axis titles + lines.push(Line::from(vec![Span::styled( + " User CPU Time (ms) →", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Add legend + lines.insert( + 0, + Line::from(vec![Span::styled( + "System CPU Time (ms) ↑", + Style::default().add_modifier(Modifier::BOLD), + )]), + ); + lines.insert( + 1, + Line::from(vec![Span::styled( + "● Main Process ○ Thread • Child Process ◉ Multiple", + Style::default().add_modifier(Modifier::DIM), + )]), + ); + + let content = Paragraph::new(lines) + .style(Style::default()) + .alignment(Alignment::Left); + + f.render_widget(content, area); + } + + fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + self.render_loading_metadata(f, top_chunks[0]); + self.render_loading_scatter(f, top_chunks[1]); + } + + fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_loading_graphs(f, middle_chunks[0]); + self.render_loading_table(f, middle_chunks[1]); + self.render_loading_command(f, middle_chunks[2]); + } + + fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Info & CPU History") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading process metadata...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Thread CPU Time Distribution") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading CPU time data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { + let block = Block::default().title("Memory & I/O").borders(Borders::ALL); + + let content = Paragraph::new("Loading memory & I/O data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_table(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Child Processes") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading child process data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_command(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Command & Details") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading command details...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Journal Events") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading journal entries...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Details") + .borders(Borders::ALL); + + let content = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "⚠ Agent Update Required", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This agent version does not support per-process metrics.", + Style::default().add_modifier(Modifier::DIM), + )), + Line::from(Span::styled( + "Please update your socktop_agent to the latest version.", + Style::default().add_modifier(Modifier::DIM), + )), + ]) + .block(block) + .alignment(Alignment::Center); + + f.render_widget(content, area); + } + + fn render_top_row_with_sparkline( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(60), // CPU sparkline + Constraint::Percentage(40), // Thread scatter plot + ]) + .split(area); + + self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); + self.render_thread_scatter_plot(f, top_chunks[1], process); + } + + fn render_cpu_sparkline( + &self, + f: &mut Frame, + area: Rect, + _process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Calculate actual average and current + let current_cpu = cpu_history.back().copied().unwrap_or(0.0); + let avg_cpu = if cpu_history.is_empty() { + 0.0 + } else { + cpu_history.iter().sum::() / cpu_history.len() as f32 + }; + let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); + + // Similar to main CPU rendering but for process CPU + if cpu_history.len() < 2 { + let block = Block::default().title(title).borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let content = Paragraph::new("Collecting CPU history data...") + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + f.render_widget(content, inner); + return; + } + + let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels + let start = cpu_history.len().saturating_sub(max_points); + + // Create data points for the chart + let data: Vec<(f64, f64)> = cpu_history + .iter() + .skip(start) + .enumerate() + .map(|(i, &val)| (i as f64, val as f64)) + .collect(); + + let datasets = vec![ + Dataset::default() + .name("CPU %") + .marker(ratatui::symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&data), + ]; + + let x_max = data.len().max(1) as f64; + let y_max = cpu_history.iter().copied().fold(0.0f32, f32::max).max(10.0) as f64; // At least 10% scale + + let y_labels = vec![ + Line::from("0%"), + Line::from(format!("{:.0}%", y_max / 2.0)), + Line::from(format!("{y_max:.0}%")), + ]; + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::ALL).title(title)) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .bounds([0.0, x_max]), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .labels(y_labels) + .bounds([0.0, y_max]), + ); + + f.render_widget(chart, area); + } + + fn render_middle_row_with_metadata( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_memory_io_graphs( + f, + middle_chunks[0], + process, + mem_history, + io_read_history, + io_write_history, + ); + self.render_thread_table(f, middle_chunks[1], process); + self.render_command_and_metadata(f, middle_chunks[2], process); + } + + fn render_command_and_metadata( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let details_block = Block::default() + .title("Command & Details") + .borders(Borders::ALL); + + // Calculate uptime + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let uptime_secs = now.saturating_sub(process.start_time); + let uptime_str = format_uptime(uptime_secs); + + // Format CPU times + let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; + let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; + + let mut content_lines = vec![ + Line::from(vec![ + Span::styled("⚡ Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&process.status), + ]), + Line::from(vec![ + Span::styled( + "⏱️ Uptime: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(uptime_str), + ]), + Line::from(vec![ + Span::styled( + "🧵 Threads: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", process.thread_count)), + ]), + Line::from(vec![ + Span::styled( + "👶 Children: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", process.child_processes.len())), + ]), + ]; + + // Add file descriptors if available + if let Some(fd_count) = process.fd_count { + content_lines.push(Line::from(vec![ + Span::styled("📁 FDs: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{fd_count}")), + ])); + } + + content_lines.push(Line::from("")); + + // Process hierarchy with clickable parent PID + if let Some(ppid) = process.parent_pid { + content_lines.push(Line::from(vec![ + Span::styled( + "👪 Parent PID: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{ppid} "), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + ), + Span::styled( + "[P to open]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::DIM), + ), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled("👤 UID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.user_id)), + Span::styled(" 👥 GID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.group_id)), + ])); + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "⏲️ CPU Time", + Style::default().add_modifier(Modifier::BOLD), + )])); + content_lines.push(Line::from(vec![ + Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{user_time_sec:.2}s")), + ])); + content_lines.push(Line::from(vec![ + Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{system_time_sec:.2}s")), + ])); + + content_lines.push(Line::from("")); + + // Executable path if available + if let Some(exe) = &process.executable_path { + content_lines.push(Line::from(vec![Span::styled( + "📂 Executable:", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(4)) as usize; + if exe.len() > max_width { + let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(Span::styled( + truncated, + Style::default().add_modifier(Modifier::DIM), + ))); + } else { + content_lines.push(Line::from(Span::styled( + exe, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + + // Working directory if available + if let Some(cwd) = &process.working_directory { + content_lines.push(Line::from(vec![Span::styled( + "📁 Working Dir:", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(4)) as usize; + if cwd.len() > max_width { + let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(Span::styled( + truncated, + Style::default().add_modifier(Modifier::DIM), + ))); + } else { + content_lines.push(Line::from(Span::styled( + cwd, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + + content_lines.push(Line::from("")); + + // Add command line (wrap if needed) + content_lines.push(Line::from(vec![Span::styled( + "⚙️ Command:", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Split command into multiple lines if too long + let cmd_text = &process.command; + let max_width = (area.width.saturating_sub(4)) as usize; + if cmd_text.len() > max_width { + for chunk in cmd_text.as_bytes().chunks(max_width) { + if let Ok(s) = std::str::from_utf8(chunk) { + content_lines.push(Line::from(Span::styled( + s, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + } else { + content_lines.push(Line::from(Span::styled( + cmd_text, + Style::default().add_modifier(Modifier::DIM), + ))); + } + + let content = Paragraph::new(content_lines) + .block(details_block) + .wrap(Wrap { trim: false }); + + f.render_widget(content, area); + } +} + +fn format_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + + if days > 0 { + format!("{days}d {hours}h {minutes}m") + } else if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } } fn format_duration(duration: Duration) -> String { diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index 0855055..d506016 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -12,7 +12,10 @@ use std::cmp::Ordering; use crate::types::Metrics; use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse}; -use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK}; +use crate::ui::theme::{ + PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW, + SB_THUMB, SB_TRACK, +}; use crate::ui::util::human; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -37,6 +40,8 @@ pub fn draw_top_processes( m: Option<&Metrics>, scroll_offset: usize, sort_by: ProcSortBy, + selected_process_pid: Option, + selected_process_index: Option, ) { // Draw outer block and title let Some(mm) = m else { return }; @@ -110,12 +115,29 @@ pub fn draw_top_processes( _ => Color::Red, }; - let emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON { + let mut emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON { Style::default().add_modifier(Modifier::BOLD) } else { Style::default() }; + // Check if this process is selected - prioritize PID matching + let is_selected = if let Some(selected_pid) = selected_process_pid { + selected_pid == p.pid + } else if let Some(selected_idx) = selected_process_index { + selected_idx == ix // ix is the absolute index in the sorted list + } else { + false + }; + + // Apply selection highlighting + if is_selected { + emphasis = emphasis + .bg(PROCESS_SELECTION_BG) + .fg(PROCESS_SELECTION_FG) + .add_modifier(Modifier::BOLD); + } + let cpu_str = fmt_cpu_pct(cpu_val); ratatui::widgets::Row::new(vec![ @@ -151,6 +173,47 @@ pub fn draw_top_processes( .column_spacing(1); f.render_widget(table, content); + // Draw tooltip if a process is selected + if let Some(selected_pid) = selected_process_pid { + // Find the selected process to get its name + let process_info = if let Some(metrics) = m { + metrics + .top_processes + .iter() + .find(|p| p.pid == selected_pid) + .map(|p| format!("PID {} • {}", p.pid, p.name)) + .unwrap_or_else(|| format!("PID {selected_pid}")) + } else { + format!("PID {selected_pid}") + }; + + let tooltip_text = format!("{process_info} | Enter for details • X to unselect"); + let tooltip_width = tooltip_text.len() as u16 + 2; // Add padding + let tooltip_height = 3; + + // Position tooltip at bottom-right of the processes area + if area.width > tooltip_width + 2 && area.height > tooltip_height + 1 { + let tooltip_area = Rect { + x: area.x + area.width.saturating_sub(tooltip_width + 1), + y: area.y + area.height.saturating_sub(tooltip_height + 1), + width: tooltip_width, + height: tooltip_height, + }; + + let tooltip_block = Block::default().borders(Borders::ALL).style( + Style::default() + .bg(PROCESS_TOOLTIP_BG) + .fg(PROCESS_TOOLTIP_FG), + ); + + let tooltip_paragraph = Paragraph::new(tooltip_text) + .block(tooltip_block) + .wrap(ratatui::widgets::Wrap { trim: true }); + + f.render_widget(tooltip_paragraph, tooltip_area); + } + } + // Draw scrollbar like CPU pane let scroll_area = Rect { x: inner.x + inner.width.saturating_sub(1), @@ -191,6 +254,8 @@ fn fmt_cpu_pct(v: f32) -> String { } /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) +/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality +#[allow(dead_code)] pub fn processes_handle_key( scroll_offset: &mut usize, key: crossterm::event::KeyEvent, @@ -199,8 +264,44 @@ pub fn processes_handle_key( crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); } +/// Enhanced keyboard handler that also manages process selection +pub fn processes_handle_key_with_selection( + _scroll_offset: &mut usize, + selected_process_pid: &mut Option, + selected_process_index: &mut Option, + key: crossterm::event::KeyEvent, + _page_size: usize, + _total_rows: usize, + _metrics: Option<&Metrics>, +) -> bool { + use crossterm::event::KeyCode; + + match key.code { + KeyCode::Char('x') | KeyCode::Char('X') => { + // Unselect any selected process + if selected_process_pid.is_some() || selected_process_index.is_some() { + *selected_process_pid = None; + *selected_process_index = None; + true // Handled + } else { + false // No selection to clear + } + } + KeyCode::Enter => { + // Signal that Enter was pressed with a selection + selected_process_pid.is_some() // Return true if we have a selection to handle + } + _ => { + // No other keys handled - let scrollbar handle all navigation + false + } + } +} + /// Handle mouse for content scrolling and scrollbar dragging. /// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked. +/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality +#[allow(dead_code)] pub fn processes_handle_mouse( scroll_offset: &mut usize, drag: &mut Option, @@ -264,3 +365,127 @@ pub fn processes_handle_mouse( ); None } + +/// Parameters for process mouse event handling +pub struct ProcessMouseParams<'a> { + pub scroll_offset: &'a mut usize, + pub selected_process_pid: &'a mut Option, + pub selected_process_index: &'a mut Option, + pub drag: &'a mut Option, + pub mouse: MouseEvent, + pub area: Rect, + pub total_rows: usize, + pub metrics: Option<&'a Metrics>, + pub sort_by: ProcSortBy, +} + +/// Enhanced mouse handler that also manages process selection +/// Returns Some(new_sort) if the header was clicked, or handles row selection +pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Option { + // Inner and content areas (match draw_top_processes) + let inner = Rect { + x: params.area.x + 1, + y: params.area.y + 1, + width: params.area.width.saturating_sub(2), + height: params.area.height.saturating_sub(2), + }; + if inner.height == 0 || inner.width <= 2 { + return None; + } + let content = Rect { + x: inner.x, + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height, + }; + + // Scrollbar interactions (click arrows/page/drag) + per_core_handle_scrollbar_mouse( + params.scroll_offset, + params.drag, + params.mouse, + params.area, + params.total_rows, + ); + + // Wheel scrolling when inside the content + crate::ui::cpu::per_core_handle_mouse( + params.scroll_offset, + params.mouse, + content, + content.height as usize, + ); + + // Header click to change sort + let header_area = Rect { + x: content.x, + y: content.y, + width: content.width, + height: 1, + }; + let inside_header = params.mouse.row == header_area.y + && params.mouse.column >= header_area.x + && params.mouse.column < header_area.x + header_area.width; + + if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + // Split header into the same columns + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints(COLS.to_vec()) + .split(header_area); + if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width { + return Some(ProcSortBy::CpuDesc); + } + if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width { + return Some(ProcSortBy::MemDesc); + } + } + + // Row click for process selection + let data_start_row = content.y + 1; // Skip header + let data_area_height = content.height.saturating_sub(1); // Exclude header + + if matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && params.mouse.row >= data_start_row + && params.mouse.row < data_start_row + data_area_height + && params.mouse.column >= content.x + && params.mouse.column < content.x + content.width + { + let clicked_row = (params.mouse.row - data_start_row) as usize; + + // Find the actual process using the same sorting logic as the drawing code + if let Some(m) = params.metrics { + // Create the same sorted index array as in draw_top_processes + let mut idxs: Vec = (0..m.top_processes.len()).collect(); + match params.sort_by { + ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].cpu_usage; + let bb = m.top_processes[b].cpu_usage; + bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal) + }), + ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].mem_bytes; + let bb = m.top_processes[b].mem_bytes; + bb.cmp(&aa) + }), + } + + // Calculate which process was actually clicked based on sorted order + let visible_process_position = *params.scroll_offset + clicked_row; + if visible_process_position < idxs.len() { + let actual_process_index = idxs[visible_process_position]; + let clicked_process = &m.top_processes[actual_process_index]; + *params.selected_process_pid = Some(clicked_process.pid); + *params.selected_process_index = Some(actual_process_index); + } + } + } + + // Clamp to valid range + per_core_clamp( + params.scroll_offset, + params.total_rows, + (content.height.saturating_sub(1)) as usize, + ); + None +} diff --git a/socktop/src/ui/theme.rs b/socktop/src/ui/theme.rs index 5e79fef..7b4d1bf 100644 --- a/socktop/src/ui/theme.rs +++ b/socktop/src/ui/theme.rs @@ -30,6 +30,15 @@ pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46); pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255); +// Process selection colors +pub const PROCESS_SELECTION_BG: Color = Color::Rgb(147, 112, 219); // Medium slate blue (purple) +pub const PROCESS_SELECTION_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast +pub const PROCESS_TOOLTIP_BG: Color = Color::Rgb(147, 112, 219); // Same purple as selection +pub const PROCESS_TOOLTIP_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast + +// Process details modal colors (matches main UI aesthetic - no custom colors, terminal defaults) +pub const PROCESS_DETAILS_ACCENT: Color = Color::Rgb(147, 112, 219); // Purple accent for highlights + // Emoji / icon strings (centralized so they can be themed/swapped later) pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR "; pub const ICON_CLUSTER: &str = "⚠️"; diff --git a/socktop_agent/Cargo.toml b/socktop_agent/Cargo.toml index 8a58762..68add85 100644 --- a/socktop_agent/Cargo.toml +++ b/socktop_agent/Cargo.toml @@ -28,6 +28,8 @@ anyhow = "1" hostname = "0.3" prost = { workspace = true } time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] } +# For executing journalctl commands +tokio-process = "0.2" [build-dependencies] prost-build = "0.13" diff --git a/socktop_agent/src/cache.rs b/socktop_agent/src/cache.rs new file mode 100644 index 0000000..816c5b4 --- /dev/null +++ b/socktop_agent/src/cache.rs @@ -0,0 +1,95 @@ +//! Caching for process metrics and journal entries + +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +use crate::types::{ProcessMetricsResponse, JournalResponse}; + +#[derive(Debug, Clone)] +struct CacheEntry { + data: T, + cached_at: Instant, + ttl: Duration, +} + +impl CacheEntry { + fn is_expired(&self) -> bool { + self.cached_at.elapsed() > self.ttl + } +} + +#[derive(Debug)] +pub struct ProcessCache { + process_metrics: RwLock>>, + journal_entries: RwLock>>, +} + +impl ProcessCache { + pub fn new() -> Self { + Self { + process_metrics: RwLock::new(HashMap::new()), + journal_entries: RwLock::new(HashMap::new()), + } + } + + /// Get cached process metrics if available and not expired (250ms TTL) + pub async fn get_process_metrics(&self, pid: u32) -> Option { + let cache = self.process_metrics.read().await; + if let Some(entry) = cache.get(&pid) { + if !entry.is_expired() { + return Some(entry.data.clone()); + } + } + None + } + + /// Cache process metrics with 250ms TTL + pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) { + let mut cache = self.process_metrics.write().await; + cache.insert(pid, CacheEntry { + data, + cached_at: Instant::now(), + ttl: Duration::from_millis(250), + }); + } + + /// Get cached journal entries if available and not expired (1s TTL) + pub async fn get_journal_entries(&self, pid: u32) -> Option { + let cache = self.journal_entries.read().await; + if let Some(entry) = cache.get(&pid) { + if !entry.is_expired() { + return Some(entry.data.clone()); + } + } + None + } + + /// Cache journal entries with 1s TTL + pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) { + let mut cache = self.journal_entries.write().await; + cache.insert(pid, CacheEntry { + data, + cached_at: Instant::now(), + ttl: Duration::from_secs(1), + }); + } + + /// Clean up expired entries periodically + pub async fn cleanup_expired(&self) { + { + let mut cache = self.process_metrics.write().await; + cache.retain(|_, entry| !entry.is_expired()); + } + { + let mut cache = self.journal_entries.write().await; + cache.retain(|_, entry| !entry.is_expired()); + } + } +} + +impl Default for ProcessCache { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/socktop_agent/src/lib.rs b/socktop_agent/src/lib.rs new file mode 100644 index 0000000..991c725 --- /dev/null +++ b/socktop_agent/src/lib.rs @@ -0,0 +1,17 @@ +//! Library interface for socktop_agent functionality +//! This allows testing of agent functions. + +pub mod gpu; +pub mod metrics; +pub mod proto; +pub mod state; +pub mod tls; +pub mod types; +pub mod ws; + +// Re-export commonly used types and functions for testing +pub use metrics::{collect_journal_entries, collect_process_metrics}; +pub use state::{AppState, CacheEntry}; +pub use types::{ + DetailedProcessInfo, JournalEntry, JournalResponse, LogLevel, ProcessMetricsResponse, +}; diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index c8dacc3..4cdc4ca 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -2,7 +2,10 @@ use crate::gpu::collect_all_gpus; use crate::state::AppState; -use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload}; +use crate::types::{ + DetailedProcessInfo, DiskInfo, JournalEntry, JournalResponse, LogLevel, Metrics, NetworkInfo, + ProcessInfo, ProcessMetricsResponse, ProcessesPayload, +}; use once_cell::sync::OnceCell; #[cfg(target_os = "linux")] use std::collections::HashMap; @@ -10,13 +13,55 @@ use std::collections::HashMap; use std::fs; #[cfg(target_os = "linux")] use std::io; +use std::process::Command; use std::sync::Mutex; use std::time::Duration as StdDuration; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; use tracing::warn; // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. + +// Helper functions to get CPU time from /proc/stat on Linux +#[cfg(target_os = "linux")] +fn get_cpu_time_user(pid: u32) -> u64 { + if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let fields: Vec<&str> = stat.split_whitespace().collect(); + if fields.len() > 13 { + // Field 13 (0-indexed) is utime (user CPU time in clock ticks) + if let Ok(utime) = fields[13].parse::() { + // Convert clock ticks to milliseconds (assuming 100 Hz) + return utime * 10; // 1 tick = 10ms at 100 Hz + } + } + } + 0 +} + +#[cfg(target_os = "linux")] +fn get_cpu_time_system(pid: u32) -> u64 { + if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let fields: Vec<&str> = stat.split_whitespace().collect(); + if fields.len() > 14 { + // Field 14 (0-indexed) is stime (system CPU time in clock ticks) + if let Ok(stime) = fields[14].parse::() { + // Convert clock ticks to milliseconds (assuming 100 Hz) + return stime * 10; // 1 tick = 10ms at 100 Hz + } + } + } + 0 +} + +#[cfg(not(target_os = "linux"))] +fn get_cpu_time_user(_pid: u32) -> u64 { + 0 // Not implemented for non-Linux platforms +} + +#[cfg(not(target_os = "linux"))] +fn get_cpu_time_system(_pid: u32) -> u64 { + 0 // Not implemented for non-Linux platforms +} // Runtime toggles (read once) fn gpu_enabled() -> bool { static ON: OnceCell = OnceCell::new(); @@ -549,3 +594,616 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { } payload } + +/// Lightweight child process enumeration using direct /proc access +/// This avoids the expensive refresh_processes_specifics(All) call +#[cfg(target_os = "linux")] +fn enumerate_child_processes_lightweight( + parent_pid: u32, + system: &sysinfo::System, +) -> Vec { + let mut children = Vec::new(); + + // Read /proc to find all child processes + // This is much faster than refresh_processes_specifics(All) + if let Ok(entries) = fs::read_dir("/proc") { + for entry in entries.flatten() { + if let Ok(file_name) = entry.file_name().into_string() { + if let Ok(pid) = file_name.parse::() { + // Check if this process is a child of our target + if let Some(child_parent_pid) = read_parent_pid_from_proc(pid) { + if child_parent_pid == parent_pid { + // Found a child! Collect its details from /proc + if let Some(child_info) = collect_process_info_from_proc(pid, system) { + children.push(child_info); + } + } + } + } + } + } + } + + children +} + +/// Read parent PID from /proc/{pid}/stat +#[cfg(target_os = "linux")] +fn read_parent_pid_from_proc(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // Format: pid (comm) state ppid ... + // We need to handle process names with spaces/parentheses + let ppid_start = stat.rfind(')')?; + let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect(); + // After the closing paren: state ppid ... + // Field 1 (0-indexed) is ppid + fields.get(1)?.parse::().ok() +} + +/// Collect process information from /proc files +#[cfg(target_os = "linux")] +fn collect_process_info_from_proc( + pid: u32, + system: &sysinfo::System, +) -> Option { + // Try to get basic info from sysinfo if it's already loaded (cheap lookup) + // Otherwise read from /proc directly + let (name, cpu_usage, mem_bytes, virtual_mem_bytes) = + if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) { + ( + proc.name().to_string_lossy().to_string(), + proc.cpu_usage(), + proc.memory(), + proc.virtual_memory(), + ) + } else { + // Process not in sysinfo cache, read minimal info from /proc + let name = fs::read_to_string(format!("/proc/{pid}/comm")) + .ok()? + .trim() + .to_string(); + + // Read memory from /proc/{pid}/status + let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let mut mem_bytes = 0u64; + let mut virtual_mem_bytes = 0u64; + + for line in status_content.lines() { + if let Some(value) = line.strip_prefix("VmRSS:") { + if let Some(kb) = value.split_whitespace().next() { + mem_bytes = kb.parse::().unwrap_or(0) * 1024; + } + } else if let Some(value) = line.strip_prefix("VmSize:") { + if let Some(kb) = value.split_whitespace().next() { + virtual_mem_bytes = kb.parse::().unwrap_or(0) * 1024; + } + } + } + + (name, 0.0, mem_bytes, virtual_mem_bytes) + }; + + // Read command line + let command = fs::read_to_string(format!("/proc/{pid}/cmdline")) + .ok() + .map(|s| s.replace('\0', " ").trim().to_string()) + .unwrap_or_default(); + + // Read status information + let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let mut uid = 0u32; + let mut gid = 0u32; + let mut thread_count = 0u32; + let mut status = "Unknown".to_string(); + + for line in status_content.lines() { + if let Some(value) = line.strip_prefix("Uid:") { + if let Some(uid_str) = value.split_whitespace().next() { + uid = uid_str.parse().unwrap_or(0); + } + } else if let Some(value) = line.strip_prefix("Gid:") { + if let Some(gid_str) = value.split_whitespace().next() { + gid = gid_str.parse().unwrap_or(0); + } + } else if let Some(value) = line.strip_prefix("Threads:") { + thread_count = value.trim().parse().unwrap_or(0); + } else if let Some(value) = line.strip_prefix("State:") { + status = value + .trim() + .chars() + .next() + .map(|c| match c { + 'R' => "Running", + 'S' => "Sleeping", + 'D' => "Disk Sleep", + 'Z' => "Zombie", + 'T' => "Stopped", + 't' => "Tracing Stop", + 'X' | 'x' => "Dead", + 'K' => "Wakekill", + 'W' => "Waking", + 'P' => "Parked", + 'I' => "Idle", + _ => "Unknown", + }) + .unwrap_or("Unknown") + .to_string(); + } + } + + // Read start time from stat + let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let stat_end = stat.rfind(')')?; + let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect(); + // Field 19 (0-indexed) is starttime in clock ticks since boot + fields.get(19)?.parse::().ok()? + } else { + 0 + }; + + // Read I/O stats if available + let (read_bytes, write_bytes) = + if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) { + let mut read_bytes = None; + let mut write_bytes = None; + + for line in io_content.lines() { + if let Some(value) = line.strip_prefix("read_bytes:") { + read_bytes = value.trim().parse().ok(); + } else if let Some(value) = line.strip_prefix("write_bytes:") { + write_bytes = value.trim().parse().ok(); + } + } + + (read_bytes, write_bytes) + } else { + (None, None) + }; + + // Read working directory + let working_directory = fs::read_link(format!("/proc/{pid}/cwd")) + .ok() + .map(|p| p.to_string_lossy().to_string()); + + // Read executable path + let executable_path = fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .map(|p| p.to_string_lossy().to_string()); + + Some(DetailedProcessInfo { + pid, + name, + command, + cpu_usage, + mem_bytes, + virtual_mem_bytes, + shared_mem_bytes: None, // Would need to parse /proc/{pid}/statm for this + thread_count, + fd_count: None, // Would need to count entries in /proc/{pid}/fd + status, + parent_pid: None, // We already know the parent + user_id: uid, + group_id: gid, + start_time, + cpu_time_user: get_cpu_time_user(pid), + cpu_time_system: get_cpu_time_system(pid), + read_bytes, + write_bytes, + working_directory, + executable_path, + child_processes: Vec::new(), // Don't recurse + threads: Vec::new(), // Not collected for child processes + }) +} + +/// Fallback for non-Linux: use sysinfo (less efficient but functional) +#[cfg(not(target_os = "linux"))] +fn enumerate_child_processes_lightweight( + parent_pid: u32, + system: &sysinfo::System, +) -> Vec { + let mut children = Vec::new(); + + // On non-Linux, we have to iterate through all processes in sysinfo + // This is less efficient but maintains cross-platform compatibility + for (child_pid, child_process) in system.processes() { + if let Some(parent) = child_process.parent() { + if parent.as_u32() == parent_pid { + let child_info = DetailedProcessInfo { + pid: child_pid.as_u32(), + name: child_process.name().to_string_lossy().to_string(), + command: child_process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>() + .join(" "), + cpu_usage: child_process.cpu_usage(), + mem_bytes: child_process.memory(), + virtual_mem_bytes: child_process.virtual_memory(), + shared_mem_bytes: None, + thread_count: child_process + .tasks() + .map(|tasks| tasks.len() as u32) + .unwrap_or(0), + fd_count: None, + status: format!("{:?}", child_process.status()), + parent_pid: Some(parent_pid), + // On non-Linux platforms, sysinfo UID/GID might not be accurate + // Just use 0 as placeholder since we can't read /proc + user_id: 0, + group_id: 0, + start_time: child_process.start_time(), + cpu_time_user: 0, // Not available on non-Linux in our implementation + cpu_time_system: 0, + read_bytes: Some(child_process.disk_usage().read_bytes), + write_bytes: Some(child_process.disk_usage().written_bytes), + working_directory: child_process.cwd().map(|p| p.to_string_lossy().to_string()), + executable_path: child_process.exe().map(|p| p.to_string_lossy().to_string()), + child_processes: Vec::new(), + threads: Vec::new(), // Not collected for non-Linux + }; + children.push(child_info); + } + } + } + + children +} + +/// Collect thread information for a specific process (Linux only) +#[cfg(target_os = "linux")] +fn collect_thread_info(pid: u32) -> Vec { + let mut threads = Vec::new(); + + // Read /proc/{pid}/task directory + let task_dir = format!("/proc/{pid}/task"); + let Ok(entries) = fs::read_dir(&task_dir) else { + return threads; + }; + + for entry in entries.flatten() { + let file_name = entry.file_name(); + let tid_str = file_name.to_string_lossy(); + let Ok(tid) = tid_str.parse::() else { + continue; + }; + + // Read thread name from comm + let name = fs::read_to_string(format!("/proc/{pid}/task/{tid}/comm")) + .unwrap_or_else(|_| format!("Thread-{tid}")) + .trim() + .to_string(); + + // Read thread stat for CPU times and status + let stat_path = format!("/proc/{pid}/task/{tid}/stat"); + let Ok(stat_content) = fs::read_to_string(&stat_path) else { + continue; + }; + + // Parse stat file (similar format to process stat) + // Fields: pid comm state ... utime stime ... + let fields: Vec<&str> = stat_content.split_whitespace().collect(); + if fields.len() < 15 { + continue; + } + + // Field 2 is state (R, S, D, Z, T, etc.) + let status = fields + .get(2) + .and_then(|s| s.chars().next()) + .map(|c| match c { + 'R' => "Running", + 'S' => "Sleeping", + 'D' => "Disk Sleep", + 'Z' => "Zombie", + 'T' => "Stopped", + 't' => "Tracing Stop", + 'X' | 'x' => "Dead", + _ => "Unknown", + }) + .unwrap_or("Unknown") + .to_string(); + + // Field 13 is utime (user CPU time in clock ticks) + // Field 14 is stime (system CPU time in clock ticks) + let utime = fields + .get(13) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let stime = fields + .get(14) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + // Convert clock ticks to microseconds (assuming 100 Hz) + // 1 tick = 10ms = 10,000 microseconds + let cpu_time_user = utime * 10_000; + let cpu_time_system = stime * 10_000; + + threads.push(crate::types::ThreadInfo { + tid, + name, + cpu_time_user, + cpu_time_system, + status, + }); + } + + threads +} + +/// Fallback for non-Linux: return empty thread list +#[cfg(not(target_os = "linux"))] +fn collect_thread_info(_pid: u32) -> Vec { + Vec::new() +} + +/// Collect detailed metrics for a specific process +pub async fn collect_process_metrics( + pid: u32, + state: &AppState, +) -> Result { + let mut system = state.sys.lock().await; + + // OPTIMIZED: Only refresh the specific process we care about + // This avoids polluting the main process list with threads and prevents race conditions + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]), + false, + ProcessRefreshKind::nothing() + .with_memory() + .with_cpu() + .with_disk_usage(), + ); + + let process = system + .process(sysinfo::Pid::from_u32(pid)) + .ok_or_else(|| format!("Process {pid} not found"))?; + + // Get current timestamp + let cached_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Time error: {e}"))? + .as_secs(); + + // Extract all needed data from process while we have the lock + let name = process.name().to_string_lossy().to_string(); + let command = process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>() + .join(" "); + let cpu_usage = process.cpu_usage(); + let mem_bytes = process.memory(); + let virtual_mem_bytes = process.virtual_memory(); + let thread_count = process.tasks().map(|tasks| tasks.len() as u32).unwrap_or(0); + let status = format!("{:?}", process.status()); + let parent_pid = process.parent().map(|p| p.as_u32()); + let start_time = process.start_time(); + + // Read UID and GID directly from /proc/{pid}/status for accuracy + let (user_id, group_id) = + if let Ok(status_content) = fs::read_to_string(format!("/proc/{pid}/status")) { + let mut uid = 0u32; + let mut gid = 0u32; + + for line in status_content.lines() { + if let Some(value) = line.strip_prefix("Uid:") { + // Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem) + // We want the real UID (first value) + if let Some(uid_str) = value.split_whitespace().next() { + uid = uid_str.parse().unwrap_or(0); + } + } else if let Some(value) = line.strip_prefix("Gid:") { + // Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem) + // We want the real GID (first value) + if let Some(gid_str) = value.split_whitespace().next() { + gid = gid_str.parse().unwrap_or(0); + } + } + } + + (uid, gid) + } else { + // Fallback if /proc read fails (non-Linux or permission issue) + (0, 0) + }; + + // Read I/O stats directly from /proc/{pid}/io + // Use rchar/wchar to capture ALL I/O including cached reads (like htop/btop do) + // sysinfo's total_read_bytes/total_written_bytes only count actual disk I/O + let (read_bytes, write_bytes) = + if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) { + let mut rchar = 0u64; + let mut wchar = 0u64; + + for line in io_content.lines() { + if let Some(value) = line.strip_prefix("rchar: ") { + rchar = value.trim().parse().unwrap_or(0); + } else if let Some(value) = line.strip_prefix("wchar: ") { + wchar = value.trim().parse().unwrap_or(0); + } + } + + (Some(rchar), Some(wchar)) + } else { + // Fallback to sysinfo if we can't read /proc (permissions, non-Linux, etc.) + let disk_usage = process.disk_usage(); + ( + Some(disk_usage.total_read_bytes), + Some(disk_usage.total_written_bytes), + ) + }; + let working_directory = process.cwd().map(|p| p.to_string_lossy().to_string()); + let executable_path = process.exe().map(|p| p.to_string_lossy().to_string()); + + // Collect child processes using lightweight /proc access + // This avoids the expensive system.refresh_processes_specifics(All) call + let child_processes = enumerate_child_processes_lightweight(pid, &system); + + // Release the system lock early (automatic when system goes out of scope) + drop(system); + + // Collect thread information (Linux only) + let threads = collect_thread_info(pid); + + // Now construct the detailed info without holding the lock + let detailed_info = DetailedProcessInfo { + pid, + name, + command, + cpu_usage, + mem_bytes, + virtual_mem_bytes, + shared_mem_bytes: None, // Not available from sysinfo + thread_count, + fd_count: None, // Not available from sysinfo on all platforms + status, + parent_pid, + user_id, + group_id, + start_time, + cpu_time_user: get_cpu_time_user(pid), + cpu_time_system: get_cpu_time_system(pid), + read_bytes, + write_bytes, + working_directory, + executable_path, + child_processes, + threads, + }; + + Ok(ProcessMetricsResponse { + process: detailed_info, + cached_at, + }) +} + +/// Collect journal entries for a specific process +pub fn collect_journal_entries(pid: u32) -> Result { + let output = Command::new("journalctl") + .args([ + &format!("_PID={pid}"), + "--output=json", + "--lines=100", + "--no-pager", + ]) + .output() + .map_err(|e| format!("Failed to execute journalctl: {e}"))?; + + if !output.status.success() { + return Err(format!( + "journalctl failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut entries = Vec::new(); + + // Parse each line as JSON (journalctl outputs one JSON object per line) + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } + + let json: serde_json::Value = + serde_json::from_str(line).map_err(|e| format!("Failed to parse journal JSON: {e}"))?; + + // Extract relevant fields + let timestamp_str = json + .get("__REALTIME_TIMESTAMP") + .and_then(|v| v.as_str()) + .unwrap_or("0"); + + // Convert timestamp to ISO 8601 format + let timestamp = if let Ok(ts_micros) = timestamp_str.parse::() { + let ts_secs = ts_micros / 1_000_000; + let ts_nanos = (ts_micros % 1_000_000) * 1000; + let time = SystemTime::UNIX_EPOCH + + Duration::from_secs(ts_secs) + + Duration::from_nanos(ts_nanos); + // Simple ISO 8601 format - we can improve this if needed + format!("{time:?}") + .replace("SystemTime { tv_sec: ", "") + .replace(", tv_nsec: ", ".") + .replace(" }", "") + } else { + timestamp_str.to_string() + }; + + let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) { + Some("0") => LogLevel::Emergency, + Some("1") => LogLevel::Alert, + Some("2") => LogLevel::Critical, + Some("3") => LogLevel::Error, + Some("4") => LogLevel::Warning, + Some("5") => LogLevel::Notice, + Some("6") => LogLevel::Info, + Some("7") => LogLevel::Debug, + _ => LogLevel::Info, + }; + + let message = json + .get("MESSAGE") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let unit = json + .get("_SYSTEMD_UNIT") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let entry_pid = json + .get("_PID") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + let comm = json + .get("_COMM") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let uid = json + .get("_UID") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + let gid = json + .get("_GID") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + entries.push(JournalEntry { + timestamp, + priority, + message, + unit, + pid: entry_pid, + comm, + uid, + gid, + }); + } + + // Sort by timestamp (newest first) + entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + let response_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Time error: {e}"))? + .as_secs(); + + let total_count = entries.len() as u32; + let truncated = entries.len() >= 100; // We requested 100 lines, so if we got 100, there might be more + + Ok(JournalResponse { + entries, + total_count, + truncated, + cached_at: response_timestamp, + }) +} diff --git a/socktop_agent/src/state.rs b/socktop_agent/src/state.rs index ac7a67c..344b877 100644 --- a/socktop_agent/src/state.rs +++ b/socktop_agent/src/state.rs @@ -63,6 +63,11 @@ pub struct AppState { pub cache_metrics: Arc>>, pub cache_disks: Arc>>>, pub cache_processes: Arc>>, + + // Process detail caches (per-PID) + pub cache_process_metrics: + Arc>>>, + pub cache_journal_entries: Arc>>>, } #[derive(Clone, Debug)] @@ -71,6 +76,12 @@ pub struct CacheEntry { pub value: Option, } +impl Default for CacheEntry { + fn default() -> Self { + Self::new() + } +} + impl CacheEntry { pub fn new() -> Self { Self { @@ -90,6 +101,12 @@ impl CacheEntry { } } +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + impl AppState { pub fn new() -> Self { let sys = System::new(); @@ -116,6 +133,8 @@ impl AppState { cache_metrics: Arc::new(Mutex::new(CacheEntry::new())), cache_disks: Arc::new(Mutex::new(CacheEntry::new())), cache_processes: Arc::new(Mutex::new(CacheEntry::new())), + cache_process_metrics: Arc::new(Mutex::new(HashMap::new())), + cache_journal_entries: Arc::new(Mutex::new(HashMap::new())), } } } diff --git a/socktop_agent/src/types.rs b/socktop_agent/src/types.rs index dc2b05f..4b7d37c 100644 --- a/socktop_agent/src/types.rs +++ b/socktop_agent/src/types.rs @@ -47,3 +47,76 @@ pub struct ProcessesPayload { pub process_count: usize, pub top_processes: Vec, } + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadInfo { + pub tid: u32, // Thread ID + pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm) + pub cpu_time_user: u64, // User CPU time in microseconds + pub cpu_time_system: u64, // System CPU time in microseconds + pub status: String, // Thread status (Running, Sleeping, etc.) +} + +#[derive(Debug, Clone, Serialize)] +pub struct DetailedProcessInfo { + pub pid: u32, + pub name: String, + pub command: String, + pub cpu_usage: f32, + pub mem_bytes: u64, + pub virtual_mem_bytes: u64, + pub shared_mem_bytes: Option, + pub thread_count: u32, + pub fd_count: Option, + pub status: String, + pub parent_pid: Option, + pub user_id: u32, + pub group_id: u32, + pub start_time: u64, // Unix timestamp + pub cpu_time_user: u64, // Microseconds + pub cpu_time_system: u64, // Microseconds + pub read_bytes: Option, + pub write_bytes: Option, + pub working_directory: Option, + pub executable_path: Option, + pub child_processes: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProcessMetricsResponse { + pub process: DetailedProcessInfo, + pub cached_at: u64, // Unix timestamp when this data was cached +} + +#[derive(Debug, Clone, Serialize)] +pub struct JournalEntry { + pub timestamp: String, // ISO 8601 formatted timestamp + pub priority: LogLevel, + pub message: String, + pub unit: Option, // systemd unit name + pub pid: Option, + pub comm: Option, // process command name + pub uid: Option, + pub gid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub enum LogLevel { + Emergency = 0, + Alert = 1, + Critical = 2, + Error = 3, + Warning = 4, + Notice = 5, + Info = 6, + Debug = 7, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JournalResponse { + pub entries: Vec, + pub total_count: u32, + pub truncated: bool, + pub cached_at: u64, // Unix timestamp when this data was cached +} diff --git a/socktop_agent/src/ws.rs b/socktop_agent/src/ws.rs index 9586f14..b7bfe16 100644 --- a/socktop_agent/src/ws.rs +++ b/socktop_agent/src/ws.rs @@ -17,6 +17,8 @@ use crate::proto::pb; use crate::state::AppState; // Compression threshold based on typical payload size +// Temporarily increased for testing - revert to 768 for production +//const COMPRESSION_THRESHOLD: usize = 50_000; const COMPRESSION_THRESHOLD: usize = 768; // Reusable buffer for compression to avoid allocations @@ -111,6 +113,92 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { } drop(cache); // Explicit drop to release mutex early } + Message::Text(ref text) if text.starts_with("get_process_metrics:") => { + if let Some(pid_str) = text.strip_prefix("get_process_metrics:") { + if let Ok(pid) = pid_str.parse::() { + let ttl = std::time::Duration::from_millis(250); // 250ms TTL + + // Check cache first + { + let cache = state.cache_process_metrics.lock().await; + if let Some(entry) = cache.get(&pid) { + if entry.is_fresh(ttl) + && let Some(cached_response) = entry.get() + { + let _ = send_json(&mut socket, cached_response).await; + continue; + } + } + } + + // Collect fresh data + match crate::metrics::collect_process_metrics(pid, &state).await { + Ok(response) => { + // Cache the response + { + let mut cache = state.cache_process_metrics.lock().await; + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(response.clone()); + } + let _ = send_json(&mut socket, &response).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": "get_process_metrics", + "pid": pid + }); + let _ = send_json(&mut socket, &error_response).await; + } + } + } + } + } + Message::Text(ref text) if text.starts_with("get_journal_entries:") => { + if let Some(pid_str) = text.strip_prefix("get_journal_entries:") { + if let Ok(pid) = pid_str.parse::() { + let ttl = std::time::Duration::from_secs(1); // 1s TTL + + // Check cache first + { + let cache = state.cache_journal_entries.lock().await; + if let Some(entry) = cache.get(&pid) { + if entry.is_fresh(ttl) + && let Some(cached_response) = entry.get() + { + let _ = send_json(&mut socket, cached_response).await; + continue; + } + } + } + + // Collect fresh data + match crate::metrics::collect_journal_entries(pid) { + Ok(response) => { + // Cache the response + { + let mut cache = state.cache_journal_entries.lock().await; + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(response.clone()); + } + let _ = send_json(&mut socket, &response).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": "get_journal_entries", + "pid": pid + }); + let _ = send_json(&mut socket, &error_response).await; + } + } + } + } + } Message::Close(_) => break, _ => {} } diff --git a/socktop_agent/tests/cache_tests.rs b/socktop_agent/tests/cache_tests.rs new file mode 100644 index 0000000..821de87 --- /dev/null +++ b/socktop_agent/tests/cache_tests.rs @@ -0,0 +1,132 @@ +//! Tests for the process cache functionality + +use socktop_agent::state::{AppState, CacheEntry}; +use socktop_agent::types::{DetailedProcessInfo, JournalResponse, ProcessMetricsResponse}; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::test] +async fn test_process_cache_ttl() { + let state = AppState::new(); + let pid = 12345; + + // Create mock data + let process_info = DetailedProcessInfo { + pid, + name: "test_process".to_string(), + command: "test command".to_string(), + cpu_usage: 50.0, + mem_bytes: 1024 * 1024, + virtual_mem_bytes: 2048 * 1024, + shared_mem_bytes: Some(512 * 1024), + thread_count: 4, + fd_count: Some(10), + status: "Running".to_string(), + parent_pid: Some(1), + user_id: 1000, + group_id: 1000, + start_time: 1234567890, + cpu_time_user: 100000, + cpu_time_system: 50000, + read_bytes: Some(1024), + write_bytes: Some(2048), + working_directory: Some("/tmp".to_string()), + executable_path: Some("/usr/bin/test".to_string()), + child_processes: vec![], + threads: vec![], + }; + + let metrics_response = ProcessMetricsResponse { + process: process_info, + cached_at: 1234567890, + }; + + let journal_response = JournalResponse { + entries: vec![], + total_count: 0, + truncated: false, + cached_at: 1234567890, + }; + + // Test process metrics caching + { + let mut cache = state.cache_process_metrics.lock().await; + cache + .entry(pid) + .or_insert_with(CacheEntry::new) + .set(metrics_response.clone()); + } + + // Should get cached value immediately + { + let cache = state.cache_process_metrics.lock().await; + let ttl = Duration::from_millis(250); + if let Some(entry) = cache.get(&pid) { + assert!(entry.is_fresh(ttl)); + assert!(entry.get().is_some()); + assert_eq!(entry.get().unwrap().process.pid, pid); + } else { + panic!("Expected cached entry"); + } + } + println!("✓ Process metrics cached and retrieved successfully"); + + // Test journal entries caching + { + let mut cache = state.cache_journal_entries.lock().await; + cache + .entry(pid) + .or_insert_with(CacheEntry::new) + .set(journal_response.clone()); + } + + // Should get cached value immediately + { + let cache = state.cache_journal_entries.lock().await; + let ttl = Duration::from_secs(1); + if let Some(entry) = cache.get(&pid) { + assert!(entry.is_fresh(ttl)); + assert!(entry.get().is_some()); + assert_eq!(entry.get().unwrap().total_count, 0); + } else { + panic!("Expected cached entry"); + } + } + println!("✓ Journal entries cached and retrieved successfully"); + + // Wait for process metrics to expire (250ms + buffer) + sleep(Duration::from_millis(300)).await; + + // Process metrics should be expired now + { + let cache = state.cache_process_metrics.lock().await; + let ttl = Duration::from_millis(250); + if let Some(entry) = cache.get(&pid) { + assert!(!entry.is_fresh(ttl)); + } + } + println!("✓ Process metrics correctly expired after TTL"); + + // Journal entries should still be valid (1s TTL) + { + let cache = state.cache_journal_entries.lock().await; + let ttl = Duration::from_secs(1); + if let Some(entry) = cache.get(&pid) { + assert!(entry.is_fresh(ttl)); + } + } + println!("✓ Journal entries still valid within TTL"); + + // Wait for journal entries to expire (additional 800ms to reach 1s total) + sleep(Duration::from_millis(800)).await; + + // Journal entries should be expired now + { + let cache = state.cache_journal_entries.lock().await; + let ttl = Duration::from_secs(1); + if let Some(entry) = cache.get(&pid) { + assert!(!entry.is_fresh(ttl)); + } + } + println!("✓ Journal entries correctly expired after TTL"); +} diff --git a/socktop_agent/tests/process_details.rs b/socktop_agent/tests/process_details.rs new file mode 100644 index 0000000..dfc52c3 --- /dev/null +++ b/socktop_agent/tests/process_details.rs @@ -0,0 +1,89 @@ +//! Tests for process detail collection functionality + +use socktop_agent::metrics::{collect_journal_entries, collect_process_metrics}; +use socktop_agent::state::AppState; +use std::process; + +#[tokio::test] +async fn test_collect_process_metrics_self() { + // Test collecting metrics for our own process + let pid = process::id(); + let state = AppState::new(); + + match collect_process_metrics(pid, &state).await { + Ok(response) => { + assert_eq!(response.process.pid, pid); + assert!(!response.process.name.is_empty()); + // Command might be empty on some systems, so don't assert on it + assert!(response.cached_at > 0); + println!( + "✓ Process metrics collected for PID {}: {} ({})", + pid, response.process.name, response.process.command + ); + } + Err(e) => { + // This might fail if sysinfo can't find the process, which is possible + println!("⚠ Warning: Failed to collect process metrics for self: {e}"); + } + } +} + +#[tokio::test] +async fn test_collect_journal_entries_self() { + // Test collecting journal entries for our own process + let pid = process::id(); + + match collect_journal_entries(pid) { + Ok(response) => { + assert!(response.cached_at > 0); + println!( + "✓ Journal entries collected for PID {}: {} entries", + pid, response.total_count + ); + if !response.entries.is_empty() { + let entry = &response.entries[0]; + println!(" Latest entry: {}", entry.message); + } + } + Err(e) => { + // This might fail if journalctl is not available or restricted + println!("⚠ Warning: Failed to collect journal entries for self: {e}"); + } + } +} + +#[tokio::test] +async fn test_collect_process_metrics_invalid_pid() { + // Test with an invalid PID + let invalid_pid = 999999; + let state = AppState::new(); + + match collect_process_metrics(invalid_pid, &state).await { + Ok(_) => { + println!("⚠ Warning: Unexpectedly found process for invalid PID {invalid_pid}"); + } + Err(e) => { + println!("✓ Correctly failed for invalid PID {invalid_pid}: {e}"); + assert!(e.contains("not found")); + } + } +} + +#[tokio::test] +async fn test_collect_journal_entries_invalid_pid() { + // Test with an invalid PID - journalctl might still return empty results + let invalid_pid = 999999; + + match collect_journal_entries(invalid_pid) { + Ok(response) => { + println!( + "✓ Journal query completed for invalid PID {} (empty result expected): {} entries", + invalid_pid, response.total_count + ); + // Should be empty or very few entries + } + Err(e) => { + println!("✓ Journal query failed for invalid PID {invalid_pid}: {e}"); + } + } +} diff --git a/socktop_connector/src/connector.rs b/socktop_connector/src/connector.rs index c9825c2..3ed5b35 100644 --- a/socktop_connector/src/connector.rs +++ b/socktop_connector/src/connector.rs @@ -66,7 +66,7 @@ use tokio_tungstenite::{Connector, connect_async_tls_with_config}; use crate::error::{ConnectorError, Result}; use crate::types::{AgentRequest, AgentResponse}; #[cfg(any(feature = "networking", feature = "wasm"))] -use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload}; +use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, ProcessMetricsResponse, JournalResponse}; #[cfg(feature = "tls")] fn ensure_crypto_provider() { use std::sync::Once; @@ -186,6 +186,18 @@ impl SocktopConnector { .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; Ok(AgentResponse::Processes(processes)) } + AgentRequest::ProcessMetrics { pid } => { + let process_metrics = request_process_metrics(stream, pid) + .await + .ok_or_else(|| ConnectorError::invalid_response("Failed to get process metrics"))?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid } => { + let journal_entries = request_journal_entries(stream, pid) + .await + .ok_or_else(|| ConnectorError::invalid_response("Failed to get journal entries"))?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } @@ -437,6 +449,38 @@ async fn request_processes(ws: &mut WsStream) -> Option { } } +// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse +#[cfg(feature = "networking")] +async fn request_process_metrics(ws: &mut WsStream, pid: u32) -> Option { + let request = format!("get_process_metrics:{}", pid); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => { + gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) + } + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} + +// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse +#[cfg(feature = "networking")] +async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { + let request = format!("get_journal_entries:{}", pid); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => { + gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) + } + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} + // Decompress a gzip-compressed binary frame into a String. /// Unified gzip decompression to string for both networking and WASM #[cfg(any(feature = "networking", feature = "wasm"))] @@ -805,6 +849,20 @@ impl SocktopConnector { Ok(AgentResponse::Processes(processes)) } } + AgentRequest::ProcessMetrics { pid: _ } => { + // Parse JSON response for process metrics + let process_metrics: ProcessMetricsResponse = serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!("Failed to parse process metrics: {e}")) + })?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid: _ } => { + // Parse JSON response for journal entries + let journal_entries: JournalResponse = serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!("Failed to parse journal entries: {e}")) + })?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } diff --git a/socktop_connector/src/connector_impl.rs b/socktop_connector/src/connector_impl.rs index bcac691..0b5e97c 100644 --- a/socktop_connector/src/connector_impl.rs +++ b/socktop_connector/src/connector_impl.rs @@ -6,7 +6,8 @@ use crate::{AgentRequest, AgentResponse}; #[cfg(feature = "networking")] use crate::networking::{ - WsStream, connect_to_agent, request_disks, request_metrics, request_processes, + WsStream, connect_to_agent, request_disks, request_journal_entries, request_metrics, + request_process_metrics, request_processes, }; #[cfg(all(feature = "wasm", not(feature = "networking")))] @@ -72,6 +73,20 @@ impl SocktopConnector { .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; Ok(AgentResponse::Processes(processes)) } + AgentRequest::ProcessMetrics { pid } => { + let process_metrics = + request_process_metrics(stream, pid).await.ok_or_else(|| { + ConnectorError::invalid_response("Failed to get process metrics") + })?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid } => { + let journal_entries = + request_journal_entries(stream, pid).await.ok_or_else(|| { + ConnectorError::invalid_response("Failed to get journal entries") + })?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } diff --git a/socktop_connector/src/lib.rs b/socktop_connector/src/lib.rs index a819aaf..e82a73d 100644 --- a/socktop_connector/src/lib.rs +++ b/socktop_connector/src/lib.rs @@ -161,7 +161,8 @@ pub use config::ConnectorConfig; pub use connector_impl::SocktopConnector; pub use error::{ConnectorError, Result}; pub use types::{ - AgentRequest, AgentResponse, DiskInfo, GpuInfo, Metrics, NetworkInfo, ProcessInfo, + AgentRequest, AgentResponse, DetailedProcessInfo, DiskInfo, GpuInfo, JournalEntry, + JournalResponse, LogLevel, Metrics, NetworkInfo, ProcessInfo, ProcessMetricsResponse, ProcessesPayload, }; diff --git a/socktop_connector/src/networking/requests.rs b/socktop_connector/src/networking/requests.rs index 9cb85bc..726fbe2 100644 --- a/socktop_connector/src/networking/requests.rs +++ b/socktop_connector/src/networking/requests.rs @@ -1,6 +1,7 @@ //! WebSocket request handlers for native (non-WASM) environments. use crate::networking::WsStream; +use crate::types::{JournalResponse, ProcessMetricsResponse}; use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip}; use crate::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, pb}; @@ -82,3 +83,36 @@ pub async fn request_processes(ws: &mut WsStream) -> Option { _ => None, } } + +/// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse +pub async fn request_process_metrics( + ws: &mut WsStream, + pid: u32, +) -> Option { + let request = format!("get_process_metrics:{pid}"); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => gunzip_to_string(&b) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()), + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} + +/// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse +pub async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { + let request = format!("get_journal_entries:{pid}"); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => gunzip_to_string(&b) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()), + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} diff --git a/socktop_connector/src/types.rs b/socktop_connector/src/types.rs index b06bbda..125e11c 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -73,6 +73,79 @@ pub struct ProcessesPayload { pub top_processes: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ThreadInfo { + pub tid: u32, // Thread ID + pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm) + pub cpu_time_user: u64, // User CPU time in microseconds + pub cpu_time_system: u64, // System CPU time in microseconds + pub status: String, // Thread status (Running, Sleeping, etc.) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DetailedProcessInfo { + pub pid: u32, + pub name: String, + pub command: String, + pub cpu_usage: f32, + pub mem_bytes: u64, + pub virtual_mem_bytes: u64, + pub shared_mem_bytes: Option, + pub thread_count: u32, + pub fd_count: Option, + pub status: String, + pub parent_pid: Option, + pub user_id: u32, + pub group_id: u32, + pub start_time: u64, // Unix timestamp + pub cpu_time_user: u64, // Microseconds + pub cpu_time_system: u64, // Microseconds + pub read_bytes: Option, + pub write_bytes: Option, + pub working_directory: Option, + pub executable_path: Option, + pub child_processes: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProcessMetricsResponse { + pub process: DetailedProcessInfo, + pub cached_at: u64, // Unix timestamp when this data was cached +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JournalEntry { + pub timestamp: String, // ISO 8601 formatted timestamp + pub priority: LogLevel, + pub message: String, + pub unit: Option, // systemd unit name + pub pid: Option, + pub comm: Option, // process command name + pub uid: Option, + pub gid: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum LogLevel { + Emergency = 0, + Alert = 1, + Critical = 2, + Error = 3, + Warning = 4, + Notice = 5, + Info = 6, + Debug = 7, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JournalResponse { + pub entries: Vec, + pub total_count: u32, + pub truncated: bool, + pub cached_at: u64, // Unix timestamp when this data was cached +} + /// Request types that can be sent to the agent #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] @@ -83,6 +156,10 @@ pub enum AgentRequest { Disks, #[serde(rename = "processes")] Processes, + #[serde(rename = "process_metrics")] + ProcessMetrics { pid: u32 }, + #[serde(rename = "journal_entries")] + JournalEntries { pid: u32 }, } impl AgentRequest { @@ -92,6 +169,8 @@ impl AgentRequest { AgentRequest::Metrics => "get_metrics".to_string(), AgentRequest::Disks => "get_disks".to_string(), AgentRequest::Processes => "get_processes".to_string(), + AgentRequest::ProcessMetrics { pid } => format!("get_process_metrics:{pid}"), + AgentRequest::JournalEntries { pid } => format!("get_journal_entries:{pid}"), } } } @@ -106,4 +185,8 @@ pub enum AgentResponse { Disks(Vec), #[serde(rename = "processes")] Processes(ProcessesPayload), + #[serde(rename = "process_metrics")] + ProcessMetrics(ProcessMetricsResponse), + #[serde(rename = "journal_entries")] + JournalEntries(JournalResponse), } diff --git a/socktop_connector/src/wasm/requests.rs b/socktop_connector/src/wasm/requests.rs index 9ecea53..17ecc15 100644 --- a/socktop_connector/src/wasm/requests.rs +++ b/socktop_connector/src/wasm/requests.rs @@ -3,7 +3,10 @@ use crate::error::{ConnectorError, Result}; use crate::pb::Processes; use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip, log_debug}; -use crate::{AgentRequest, AgentResponse, DiskInfo, Metrics, ProcessInfo, ProcessesPayload}; +use crate::{ + AgentRequest, AgentResponse, DiskInfo, JournalResponse, Metrics, ProcessInfo, + ProcessMetricsResponse, ProcessesPayload, +}; use prost::Message as ProstMessage; use std::cell::RefCell; @@ -206,6 +209,26 @@ pub async fn send_request_and_wait( Ok(AgentResponse::Processes(processes)) } } + AgentRequest::ProcessMetrics { pid: _ } => { + // Parse JSON response for process metrics + let process_metrics: ProcessMetricsResponse = + serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!( + "Failed to parse process metrics: {e}" + )) + })?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid: _ } => { + // Parse JSON response for journal entries + let journal_entries: JournalResponse = + serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!( + "Failed to parse journal entries: {e}" + )) + })?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } From e857cfc665477c9f737fd014e0c46683a5efd6a1 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 5 Oct 2025 00:07:27 -0700 Subject: [PATCH 02/19] add processes window cleanup - refactor code - add unit test - fix warnings. --- nohup.out | 8 - socktop/src/app.rs | 37 +- socktop/src/ui/.modal.rs.backup | 1849 ++++++++++++++++++++++++++++ socktop/src/ui/mod.rs | 4 + socktop/src/ui/modal.rs | 1487 +--------------------- socktop/src/ui/modal_connection.rs | 297 +++++ socktop/src/ui/modal_format.rs | 112 ++ socktop/src/ui/modal_process.rs | 1131 +++++++++++++++++ socktop/src/ui/modal_types.rs | 74 ++ socktop_agent/src/metrics.rs | 27 +- socktop_agent/src/ws.rs | 130 +- 11 files changed, 3570 insertions(+), 1586 deletions(-) delete mode 100644 nohup.out create mode 100644 socktop/src/ui/.modal.rs.backup create mode 100644 socktop/src/ui/modal_connection.rs create mode 100644 socktop/src/ui/modal_format.rs create mode 100644 socktop/src/ui/modal_process.rs create mode 100644 socktop/src/ui/modal_types.rs diff --git a/nohup.out b/nohup.out deleted file mode 100644 index 86587c9..0000000 --- a/nohup.out +++ /dev/null @@ -1,8 +0,0 @@ -socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws -socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws -socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws -Error: Address already in use (os error 98) -socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws -Error: Address already in use (os error 98) -socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws -socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 71e47d0..7609ddd 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -621,19 +621,19 @@ impl App { } ModalAction::SwitchToParentProcess(_current_pid) => { // Get parent PID from current process details - if let Some(details) = &self.process_details { - if let Some(parent_pid) = details.process.parent_pid { - // Clear current process details - self.clear_process_details(); - // Update selected process to parent - self.selected_process_pid = Some(parent_pid); - // Open modal for parent process - self.modal_manager.push_modal( - crate::ui::modal::ModalType::ProcessDetails { - pid: parent_pid, - }, - ); - } + if let Some(details) = &self.process_details + && let Some(parent_pid) = details.process.parent_pid + { + // Clear current process details + self.clear_process_details(); + // Update selected process to parent + self.selected_process_pid = Some(parent_pid); + // Open modal for parent process + self.modal_manager.push_modal( + crate::ui::modal::ModalType::ProcessDetails { + pid: parent_pid, + }, + ); } continue; } @@ -710,11 +710,12 @@ impl App { } // Check if Enter was pressed with a process selected - if process_handled && k.code == KeyCode::Enter { - if let Some(selected_pid) = self.selected_process_pid { - self.modal_manager - .push_modal(ModalType::ProcessDetails { pid: selected_pid }); - } + if process_handled + && k.code == KeyCode::Enter + && let Some(selected_pid) = self.selected_process_pid + { + self.modal_manager + .push_modal(ModalType::ProcessDetails { pid: selected_pid }); } let total_rows = self diff --git a/socktop/src/ui/.modal.rs.backup b/socktop/src/ui/.modal.rs.backup new file mode 100644 index 0000000..a64db1b --- /dev/null +++ b/socktop/src/ui/.modal.rs.backup @@ -0,0 +1,1849 @@ +//! Modal window system for socktop TUI application + +use std::time::Instant; + +use super::modal_format::{calculate_dynamic_y_max, format_duration, format_uptime, normalize_cpu_usage}; +use super::theme::{ + BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT, + BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER, + ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE, + LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG, + MODAL_DIM_BG, MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, + MODAL_RETRY_LABEL_FG, MODAL_TITLE_FG, +}; +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{ + Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Padding, Paragraph, Row, + Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap, + }, +}; + +/// History data for process metrics rendering +pub struct ProcessHistoryData<'a> { + pub cpu: &'a std::collections::VecDeque, + pub mem: &'a std::collections::VecDeque, + pub io_read: &'a std::collections::VecDeque, + pub io_write: &'a std::collections::VecDeque, +} + +/// Process data for modal rendering +pub struct ProcessModalData<'a> { + pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, + pub journal: Option<&'a socktop_connector::JournalResponse>, + pub history: ProcessHistoryData<'a>, + pub unsupported: bool, +} + +/// Parameters for rendering scatter plot +struct ScatterPlotParams<'a> { + process: &'a socktop_connector::DetailedProcessInfo, + main_user_ms: f64, + main_system_ms: f64, + max_user: f64, + max_system: f64, +} + +#[derive(Debug, Clone)] +pub enum ModalType { + ConnectionError { + message: String, + disconnected_at: Instant, + retry_count: u32, + auto_retry_countdown: Option, + }, + ProcessDetails { + pid: u32, + }, + #[allow(dead_code)] + Confirmation { + title: String, + message: String, + confirm_text: String, + cancel_text: String, + }, + #[allow(dead_code)] + Info { + title: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModalAction { + None, // Modal didn't handle the key, pass to main window + Handled, // Modal handled the key, don't pass to main window + RetryConnection, + ExitApp, + Confirm, + Cancel, + Dismiss, + SwitchToParentProcess(u32), // Switch to viewing parent process details +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModalButton { + Retry, + Exit, + Confirm, + Cancel, + Ok, +} + +#[derive(Debug)] +pub struct ModalManager { + stack: Vec, + active_button: ModalButton, + pub thread_scroll_offset: usize, + pub journal_scroll_offset: usize, + thread_scroll_max: usize, + journal_scroll_max: usize, +} + +impl ModalManager { + pub fn new() -> Self { + Self { + stack: Vec::new(), + active_button: ModalButton::Retry, + thread_scroll_offset: 0, + journal_scroll_offset: 0, + thread_scroll_max: 0, + journal_scroll_max: 0, + } + } + pub fn is_active(&self) -> bool { + !self.stack.is_empty() + } + + pub fn current_modal(&self) -> Option<&ModalType> { + self.stack.last() + } + + pub fn push_modal(&mut self, modal: ModalType) { + self.stack.push(modal); + self.active_button = match self.stack.last() { + Some(ModalType::ConnectionError { .. }) => ModalButton::Retry, + Some(ModalType::ProcessDetails { .. }) => { + // Reset scroll state for new process details + self.thread_scroll_offset = 0; + self.journal_scroll_offset = 0; + self.thread_scroll_max = 0; + self.journal_scroll_max = 0; + ModalButton::Ok + } + Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, + Some(ModalType::Info { .. }) => ModalButton::Ok, + None => ModalButton::Ok, + }; + } + pub fn pop_modal(&mut self) -> Option { + let m = self.stack.pop(); + if let Some(next) = self.stack.last() { + self.active_button = match next { + ModalType::ConnectionError { .. } => ModalButton::Retry, + ModalType::ProcessDetails { .. } => ModalButton::Ok, + ModalType::Confirmation { .. } => ModalButton::Confirm, + ModalType::Info { .. } => ModalButton::Ok, + }; + } + m + } + pub fn update_connection_error_countdown(&mut self, new_countdown: Option) { + if let Some(ModalType::ConnectionError { + auto_retry_countdown, + .. + }) = self.stack.last_mut() + { + *auto_retry_countdown = new_countdown; + } + } + pub fn handle_key(&mut self, key: KeyCode) -> ModalAction { + if !self.is_active() { + return ModalAction::None; + } + match key { + KeyCode::Esc => { + self.pop_modal(); + ModalAction::Cancel + } + KeyCode::Enter => self.handle_enter(), + KeyCode::Tab | KeyCode::Right => { + self.next_button(); + ModalAction::None + } + KeyCode::BackTab | KeyCode::Left => { + self.prev_button(); + ModalAction::None + } + KeyCode::Char('r') | KeyCode::Char('R') => { + if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { + ModalAction::RetryConnection + } else { + ModalAction::None + } + } + KeyCode::Char('q') | KeyCode::Char('Q') => { + if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { + ModalAction::ExitApp + } else { + ModalAction::None + } + } + KeyCode::Char('x') | KeyCode::Char('X') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + // Close all ProcessDetails modals at once (handles parent navigation chain) + while matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.pop_modal(); + } + ModalAction::Dismiss + } else { + ModalAction::None + } + } + KeyCode::Char('j') | KeyCode::Char('J') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self + .thread_scroll_offset + .saturating_add(1) + .min(self.thread_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('k') | KeyCode::Char('K') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(1); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('d') | KeyCode::Char('D') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self + .thread_scroll_offset + .saturating_add(10) + .min(self.thread_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('u') | KeyCode::Char('U') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(10); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('[') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.journal_scroll_offset = self.journal_scroll_offset.saturating_sub(1); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char(']') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.journal_scroll_offset = self + .journal_scroll_offset + .saturating_add(1) + .min(self.journal_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('p') | KeyCode::Char('P') => { + // Switch to parent process if it exists + if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() { + // We need to get the parent PID from the process details + // For now, return a special action that the app can handle + // The app has access to the process details and can extract parent_pid + ModalAction::SwitchToParentProcess(*pid) + } else { + ModalAction::None + } + } + _ => ModalAction::None, + } + } + fn handle_enter(&mut self) -> ModalAction { + match (&self.stack.last(), &self.active_button) { + (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => { + ModalAction::RetryConnection + } + (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp, + (Some(ModalType::ProcessDetails { .. }), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } + (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, + (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, + (Some(ModalType::Info { .. }), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } + _ => ModalAction::None, + } + } + fn next_button(&mut self) { + self.active_button = match (&self.stack.last(), &self.active_button) { + (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit, + (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry, + (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalButton::Cancel, + (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm, + _ => self.active_button.clone(), + }; + } + fn prev_button(&mut self) { + self.next_button(); + } + + pub fn render(&mut self, f: &mut Frame, data: ProcessModalData) { + if let Some(m) = self.stack.last().cloned() { + self.render_background_dim(f); + self.render_modal_content(f, &m, data); + } + } + + fn render_background_dim(&self, f: &mut Frame) { + let area = f.area(); + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .style(Style::default().bg(MODAL_DIM_BG).fg(MODAL_DIM_BG)) + .borders(Borders::NONE), + area, + ); + } + + fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) { + let area = f.area(); + // Different sizes for different modal types + let modal_area = match modal { + ModalType::ProcessDetails { .. } => { + // Process details modal uses almost full screen (95% width, 90% height) + self.centered_rect(95, 90, area) + } + _ => { + // Other modals use smaller size + self.centered_rect(70, 50, area) + } + }; + f.render_widget(Clear, modal_area); + match modal { + ModalType::ConnectionError { + message, + disconnected_at, + retry_count, + auto_retry_countdown, + } => self.render_connection_error( + f, + modal_area, + message, + *disconnected_at, + *retry_count, + *auto_retry_countdown, + ), + ModalType::ProcessDetails { pid } => { + self.render_process_details(f, modal_area, *pid, data) + } + ModalType::Confirmation { + title, + message, + confirm_text, + cancel_text, + } => self.render_confirmation(f, modal_area, title, message, confirm_text, cancel_text), + ModalType::Info { title, message } => self.render_info(f, modal_area, title, message), + } + } + + fn render_connection_error( + &self, + f: &mut Frame, + area: Rect, + message: &str, + disconnected_at: Instant, + retry_count: u32, + auto_retry_countdown: Option, + ) { + let duration_text = format_duration(disconnected_at.elapsed()); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(4), + ]) + .split(area); + let block = Block::default() + .title(ICON_WARNING_TITLE) + .title_style( + Style::default() + .fg(MODAL_TITLE_FG) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(MODAL_BORDER_FG)) + .style(Style::default().bg(MODAL_BG).fg(MODAL_FG)); + f.render_widget(block, area); + + let content_area = chunks[1]; + let max_w = content_area.width.saturating_sub(15) as usize; + let clean_message = if message.to_lowercase().contains("hostname verification") + || message.contains("socktop_connector") + { + "Connection failed - hostname verification disabled".to_string() + } else if message.contains("Failed to fetch metrics:") { + if let Some(p) = message.find(':') { + let ess = message[p + 1..].trim(); + if ess.len() > max_w { + format!("{}...", &ess[..max_w.saturating_sub(3)]) + } else { + ess.to_string() + } + } else { + "Connection error".to_string() + } + } else if message.starts_with("Retry failed:") { + if let Some(p) = message.find(':') { + let ess = message[p + 1..].trim(); + if ess.len() > max_w { + format!("{}...", &ess[..max_w.saturating_sub(3)]) + } else { + ess.to_string() + } + } else { + "Retry failed".to_string() + } + } else if message.len() > max_w { + format!("{}...", &message[..max_w.saturating_sub(3)]) + } else { + message.to_string() + }; + let truncate = |s: &str| { + if s.len() > max_w { + format!("{}...", &s[..max_w.saturating_sub(3)]) + } else { + s.to_string() + } + }; + let agent_text = truncate("📡 Cannot connect to socktop agent"); + let message_text = truncate(&clean_message); + let duration_display = truncate(&duration_text); + let retry_display = truncate(&retry_count.to_string()); + let countdown_text = auto_retry_countdown.map(|c| { + if c == 0 { + "Auto retry now...".to_string() + } else { + format!("{c}s") + } + }); + + // Determine if we have enough space (height + width) to show large centered icon + let icon_max_width = LARGE_ERROR_ICON + .iter() + .map(|l| l.trim().chars().count()) + .max() + .unwrap_or(0) as u16; + let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8) + && content_area.width >= icon_max_width + 6; // small margin for borders/padding + let mut icon_lines: Vec = Vec::new(); + if large_allowed { + for &raw in LARGE_ERROR_ICON.iter() { + let trimmed = raw.trim(); + icon_lines.push(Line::from( + trimmed + .chars() + .map(|ch| { + if ch == '!' { + Span::styled( + ch.to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else if ch == '/' || ch == '\\' || ch == '_' { + // keep outline in pink + Span::styled( + ch.to_string(), + Style::default() + .fg(MODAL_ICON_PINK) + .add_modifier(Modifier::BOLD), + ) + } else if ch == ' ' { + Span::raw(" ") + } else { + Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK)) + } + }) + .collect::>(), + )); + } + icon_lines.push(Line::from("")); // blank spacer line below icon + } + + let mut info_lines: Vec = Vec::new(); + if !large_allowed { + info_lines.push(Line::from(vec![Span::styled( + ICON_CLUSTER, + Style::default().fg(MODAL_ICON_PINK), + )])); + info_lines.push(Line::from("")); + } + info_lines.push(Line::from(vec![Span::styled( + &agent_text, + Style::default().fg(MODAL_AGENT_FG), + )])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)), + Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)), + ])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled( + ICON_OFFLINE_LABEL, + Style::default().fg(MODAL_OFFLINE_LABEL_FG), + ), + Span::styled( + &duration_display, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + info_lines.push(Line::from(vec![ + Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)), + Span::styled( + &retry_display, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + if let Some(cd) = &countdown_text { + info_lines.push(Line::from(vec![ + Span::styled( + ICON_COUNTDOWN_LABEL, + Style::default().fg(MODAL_COUNTDOWN_LABEL_FG), + ), + Span::styled( + cd, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + } + + let constrained = Rect { + x: content_area.x + 2, + y: content_area.y, + width: content_area.width.saturating_sub(4), + height: content_area.height, + }; + if large_allowed { + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(icon_lines.len() as u16), + Constraint::Min(0), + ]) + .split(constrained); + // Center the icon block; each line already trimmed so per-line centering keeps shape + f.render_widget( + Paragraph::new(Text::from(icon_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }), + split[0], + ); + f.render_widget( + Paragraph::new(Text::from(info_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + split[1], + ); + } else { + f.render_widget( + Paragraph::new(Text::from(info_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + constrained, + ); + } + + let button_area = Rect { + x: chunks[2].x, + y: chunks[2].y, + width: chunks[2].width, + height: chunks[2].height.saturating_sub(1), + }; + self.render_connection_error_buttons(f, button_area); + } + + fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) { + let button_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(15), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(30), + ]) + .split(area); + let retry_style = if self.active_button == ModalButton::Retry { + Style::default() + .bg(BTN_RETRY_BG_ACTIVE) + .fg(BTN_RETRY_FG_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(BTN_RETRY_FG_INACTIVE) + .add_modifier(Modifier::DIM) + }; + let exit_style = if self.active_button == ModalButton::Exit { + Style::default() + .bg(BTN_EXIT_BG_ACTIVE) + .fg(BTN_EXIT_FG_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(BTN_EXIT_FG_INACTIVE) + .add_modifier(Modifier::DIM) + }; + f.render_widget( + Paragraph::new(Text::from(Line::from(vec![Span::styled( + BTN_RETRY_TEXT, + retry_style, + )]))) + .alignment(Alignment::Center), + button_chunks[1], + ); + f.render_widget( + Paragraph::new(Text::from(Line::from(vec![Span::styled( + BTN_EXIT_TEXT, + exit_style, + )]))) + .alignment(Alignment::Center), + button_chunks[3], + ); + } + + fn render_confirmation( + &self, + f: &mut Frame, + area: Rect, + title: &str, + message: &str, + confirm_text: &str, + cancel_text: &str, + ) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); + let block = Block::default() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(message) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + chunks[0], + ); + let buttons = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + let confirm_style = if self.active_button == ModalButton::Confirm { + Style::default() + .bg(Color::Green) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green) + }; + let cancel_style = if self.active_button == ModalButton::Cancel { + Style::default() + .bg(Color::Red) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Red) + }; + f.render_widget( + Paragraph::new(confirm_text) + .style(confirm_style) + .alignment(Alignment::Center), + buttons[0], + ); + f.render_widget( + Paragraph::new(cancel_text) + .style(cancel_style) + .alignment(Alignment::Center), + buttons[1], + ); + } + + fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); + let block = Block::default() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(message) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + chunks[0], + ); + let ok_style = if self.active_button == ModalButton::Ok { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue) + }; + f.render_widget( + Paragraph::new("[ Enter ] OK") + .style(ok_style) + .alignment(Alignment::Center), + chunks[1], + ); + } + + fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vert[1])[1] + } + + fn render_process_details( + &mut self, + f: &mut Frame, + area: Rect, + pid: u32, + data: ProcessModalData, + ) { + let title = format!("Process Details - PID {pid}"); + + // Use neutral colors to match main UI aesthetic + let block = Block::default().title(title).borders(Borders::ALL); + + // Split the modal into the 3-row layout as designed + let inner = block.inner(area); + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot + Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) + Constraint::Min(6), // Bottom row: Journal events (gets remaining space) + Constraint::Length(1), // Help line + ]) + .split(inner); + + // Render the border + f.render_widget(block, area); + + if let Some(details) = data.details { + // Top Row: CPU sparkline (left) | Thread scatter plot (right) + self.render_top_row_with_sparkline( + f, + main_chunks[0], + &details.process, + data.history.cpu, + ); + + // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) + self.render_middle_row_with_metadata( + f, + main_chunks[1], + &details.process, + data.history.mem, + data.history.io_read, + data.history.io_write, + ); + + // Bottom Row: Journal Events + if let Some(journal) = data.journal { + self.render_journal_events(f, main_chunks[2], journal); + } else { + self.render_loading_journal_events(f, main_chunks[2]); + } + } else if data.unsupported { + // Agent doesn't support this feature + self.render_unsupported_message(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } else { + // Loading states for all sections + self.render_loading_top_row(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } + + // Help line + let help_text = vec![Line::from(vec![ + Span::styled( + "X ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "P ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "goto parent ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::styled( + "j/k ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "[ ] ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), + ])]; + let help = Paragraph::new(help_text).alignment(Alignment::Center); + f.render_widget(help, main_chunks[3]); + } + + fn render_thread_scatter_plot( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let plot_block = Block::default() + .title("Thread & Process CPU Time") + .borders(Borders::ALL); + + let inner = plot_block.inner(area); + + // Convert CPU times from microseconds to milliseconds for better readability + let main_user_ms = process.cpu_time_user as f64 / 1000.0; + let main_system_ms = process.cpu_time_system as f64 / 1000.0; + + // Calculate max values for scaling + let mut max_user = main_user_ms; + let mut max_system = main_system_ms; + + for child in &process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + max_user = max_user.max(child_user_ms); + max_system = max_system.max(child_system_ms); + } + + // Add some padding to the scale + max_user = (max_user * 1.1).max(1.0); + max_system = (max_system * 1.1).max(1.0); + + // Render the existing scatter plot but in the smaller space + self.render_scatter_plot_content( + f, + inner, + ScatterPlotParams { + process, + main_user_ms, + main_system_ms, + max_user, + max_system, + }, + ); + + // Render the border + f.render_widget(plot_block, area); + } + + fn render_memory_io_graphs( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + let graphs_block = Block::default() + .title("Memory & I/O") + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + + let mem_mb = process.mem_bytes as f64 / 1_048_576.0; + let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; + + let mut content_lines = vec![ + Line::from(vec![ + Span::styled("🧠 Memory", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(""), // Small padding + ]), + Line::from(vec![ + Span::styled(" RSS: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{mem_mb:.1} MB")), + ]), + ]; + + // Add memory sparkline if we have history + if mem_history.len() >= 2 { + let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB + let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); + + // Create mini sparkline using Unicode blocks + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = mem_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Blue)), + ])); + } else { + content_lines.push(Line::from(vec![Span::styled( + " Collecting...", + Style::default().add_modifier(Modifier::DIM), + )])); + } + + content_lines.push(Line::from(vec![ + Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{virtual_mb:.1} MB")), + ])); + + // Add shared memory if available + if let Some(shared_bytes) = process.shared_mem_bytes { + let shared_mb = shared_bytes as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{shared_mb:.1} MB")), + ])); + } + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![ + Span::styled("💾 Disk I/O", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(""), // Small padding + ])); + + // Add I/O stats if available + match (process.read_bytes, process.write_bytes) { + (Some(read), Some(write)) => { + let read_mb = read as f64 / 1_048_576.0; + let write_mb = write as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" Read: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{read_mb:.1} MB")), + ])); + + // Add read I/O sparkline if we have history + if io_read_history.len() >= 2 { + let read_data: Vec = io_read_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = read_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Green)), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled(" Write: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{write_mb:.1} MB")), + ])); + + // Add write I/O sparkline if we have history + if io_write_history.len() >= 2 { + let write_data: Vec = io_write_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = write_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), + ])); + } + } + _ => { + content_lines.push(Line::from(vec![Span::styled( + " Not available", + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + let content = Paragraph::new(content_lines).block(graphs_block); + + f.render_widget(content, area); + } + + fn render_thread_table( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let total_items = process.threads.len() + process.child_processes.len(); + + // Manually calculate inner area (like processes.rs does) + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) + let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; + + // Calculate and store max scroll for key handler bounds checking + self.thread_scroll_max = if total_items > visible_rows { + total_items.saturating_sub(visible_rows) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); + + // Combine threads and processes into rows + let mut rows = Vec::new(); + + // Add threads first + for thread in &process.threads { + rows.push(Row::new(vec![ + Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), + Line::from(format!("{}", thread.tid)), + Line::from(thread.name.clone()), + Line::from(thread.status.clone()), + ])); + } + + // Add child processes + for child in &process.child_processes { + rows.push(Row::new(vec![ + Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), + Line::from(format!("{}", child.pid)), + Line::from(child.name.clone()), + Line::from(format!("{:.1}%", child.cpu_usage)), + ])); + } + + // Create table header + let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) + .style(Style::default().add_modifier(Modifier::BOLD)) + .bottom_margin(1); + + let block = Block::default() + .title(format!( + "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", + process.threads.len(), + process.child_processes.len() + )) + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + + let table = Table::new( + rows.iter().skip(scroll_offset).take(visible_rows).cloned(), + [ + Constraint::Length(6), + Constraint::Length(10), + Constraint::Min(15), + Constraint::Length(12), + ], + ) + .header(header) + .block(block) + .highlight_style(Style::default()); + + f.render_widget(table, area); + + // Render scrollbar if there are more items than visible + if total_items > visible_rows { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + // This ensures the scrollbar position matches our actual scroll range + let mut scrollbar_state = + ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_journal_events( + &mut self, + f: &mut Frame, + area: Rect, + journal: &socktop_connector::JournalResponse, + ) { + let total_entries = journal.entries.len(); + let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders + + // Calculate and store max scroll for key handler bounds checking + self.journal_scroll_max = if total_entries > visible_lines { + total_entries.saturating_sub(visible_lines) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); + + let journal_block = Block::default() + .title(format!( + "Journal Events ({total_entries} entries) - Use [ ] to scroll" + )) + .borders(Borders::ALL); + + let content_lines: Vec = if journal.entries.is_empty() { + vec![ + Line::from(""), + Line::from(Span::styled( + "No journal entries found for this process", + Style::default().add_modifier(Modifier::DIM), + )), + ] + } else { + journal + .entries + .iter() + .skip(scroll_offset) + .take(visible_lines) + .map(|entry| { + let priority_style = match entry.priority { + socktop_connector::LogLevel::Error + | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), + socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), + socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { + Style::default().fg(Color::Blue) + } + _ => Style::default(), + }; + + let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time + let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority + let message = &entry.message[..entry.message.len().min(message_max_len)]; + + Line::from(vec![ + Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::styled( + format!("{:>7}", format!("{:?}", entry.priority)), + priority_style, + ), + Span::raw(" "), + Span::raw(message), + if entry.message.len() > message_max_len { + Span::styled("...", Style::default().add_modifier(Modifier::DIM)) + } else { + Span::raw("") + }, + ]) + }) + .collect() + }; + + let content = Paragraph::new(content_lines).block(journal_block); + + f.render_widget(content, area); + + // Render scrollbar if there are more entries than visible + if total_entries > visible_lines { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + let mut scrollbar_state = + ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { + if area.width < 20 || area.height < 10 { + // Area too small for meaningful plot + let content = Paragraph::new(vec![Line::from(Span::styled( + "Area too small for plot", + Style::default().fg(MODAL_HINT_FG), + ))]) + .alignment(Alignment::Center) + .style(Style::default().bg(MODAL_BG)); + f.render_widget(content, area); + return; + } + + // Calculate plot dimensions (leave space for axes labels + legend) + let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels + let plot_height = area.height.saturating_sub(6) as usize; // Leave space for legend (3 lines) + X-axis labels (2 lines) + title (1 line) + + if plot_width == 0 || plot_height == 0 { + return; + } + + // Create a 2D grid to represent the plot + let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; + + // Plot main process + let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let main_y = plot_height.saturating_sub(1).saturating_sub( + ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + if main_x < plot_width && main_y < plot_height { + plot_grid[main_y][main_x] = '●'; // Main process marker + } + + // Plot threads (use different marker) + for thread in ¶ms.process.threads { + let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; + let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; + + let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let thread_y = plot_height.saturating_sub(1).saturating_sub( + ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if thread_x < plot_width && thread_y < plot_height { + if plot_grid[thread_y][thread_x] == ' ' { + plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) + } else if plot_grid[thread_y][thread_x] == '○' { + plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point + } else { + plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point + } + } + } + + // Plot child processes + for child in ¶ms.process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + + let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let child_y = plot_height.saturating_sub(1).saturating_sub( + ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if child_x < plot_width && child_y < plot_height { + if plot_grid[child_y][child_x] == ' ' { + plot_grid[child_y][child_x] = '•'; // Child process marker + } else { + plot_grid[child_y][child_x] = '◉'; // Multiple items at same point + } + } + } + + // Render the plot + let mut lines = Vec::new(); + + // Add Y-axis labels and plot content + for (i, row) in plot_grid.iter().enumerate() { + let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); + // Always format with 4 characters width, right-aligned, to prevent axis shifting + let y_label = if y_value >= 100.0 { + format!("{y_value:>4.0}") + } else { + format!("{y_value:>4.1}") + }; + + let plot_content: String = row.iter().collect(); + + lines.push(Line::from(vec![ + Span::styled(y_label, Style::default()), + Span::styled(" │", Style::default()), + Span::styled(plot_content, Style::default()), + ])); + } + + // Add X-axis + let x_axis_padding = " ".to_string(); // Match Y-axis label width + let x_axis_line = "─".repeat(plot_width + 1); + lines.push(Line::from(vec![ + Span::styled(x_axis_padding, Style::default()), + Span::styled(x_axis_line, Style::default()), + ])); + + // Add X-axis labels + let x_label_start = "0.0".to_string(); + let x_label_mid = format!("{:.1}", params.max_user / 2.0); + let x_label_end = format!("{:.1}", params.max_user); + + let spacing = plot_width / 3; + let x_labels = format!( + " {}{}{}{}{}", + x_label_start, + " ".repeat(spacing.saturating_sub(x_label_start.len())), + x_label_mid, + " ".repeat(spacing.saturating_sub(x_label_mid.len())), + x_label_end + ); + + lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); + + // Add axis titles with better visibility + lines.push(Line::from(vec![Span::styled( + " User CPU Time (ms) →", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); + + // Add Y-axis label and legend at the top + lines.insert( + 0, + Line::from(vec![Span::styled( + "↑ System CPU Time (ms)", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]), + ); + lines.insert( + 1, + Line::from(vec![Span::styled( + "● Main ○ Thread • Child ◉ Multiple", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::DIM), + )]), + ); + lines.insert(2, Line::from("")); // Spacing after legend + + let content = Paragraph::new(lines) + .style(Style::default()) + .alignment(Alignment::Left); + + f.render_widget(content, area); + } + + fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + self.render_loading_metadata(f, top_chunks[0]); + self.render_loading_scatter(f, top_chunks[1]); + } + + fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_loading_graphs(f, middle_chunks[0]); + self.render_loading_table(f, middle_chunks[1]); + self.render_loading_command(f, middle_chunks[2]); + } + + fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Info & CPU History") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading process metadata...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Thread CPU Time Distribution") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading CPU time data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { + let block = Block::default().title("Memory & I/O").borders(Borders::ALL); + + let content = Paragraph::new("Loading memory & I/O data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_table(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Child Processes") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading child process data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_command(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Command & Details") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading command details...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Journal Events") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading journal entries...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Details") + .borders(Borders::ALL); + + let content = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "⚠ Agent Update Required", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This agent version does not support per-process metrics.", + Style::default().add_modifier(Modifier::DIM), + )), + Line::from(Span::styled( + "Please update your socktop_agent to the latest version.", + Style::default().add_modifier(Modifier::DIM), + )), + ]) + .block(block) + .alignment(Alignment::Center); + + f.render_widget(content, area); + } + + fn render_top_row_with_sparkline( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(60), // CPU sparkline + Constraint::Percentage(40), // Thread scatter plot + ]) + .split(area); + + self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); + self.render_thread_scatter_plot(f, top_chunks[1], process); + } + + fn render_cpu_sparkline( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Normalize CPU to 0-100% by dividing by thread count + // This shows per-core utilization rather than total utilization across all cores + let thread_count = process.thread_count; + + // Calculate actual average and current (normalized to 0-100%) + let current_cpu = normalize_cpu_usage( + cpu_history.back().copied().unwrap_or(0.0), + thread_count + ); + let avg_cpu = if cpu_history.is_empty() { + 0.0 + } else { + let total: f32 = cpu_history.iter().sum(); + normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) + }; + let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); + + // Similar to main CPU rendering but for process CPU + if cpu_history.len() < 2 { + let block = Block::default().title(title).borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let content = Paragraph::new("Collecting CPU history data...") + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + f.render_widget(content, inner); + return; + } + + let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels + let start = cpu_history.len().saturating_sub(max_points); + + // Create data points for the chart (normalized to 0-100%) + let data: Vec<(f64, f64)> = cpu_history + .iter() + .skip(start) + .enumerate() + .map(|(i, &val)| { + let normalized = normalize_cpu_usage(val, thread_count); + (i as f64, normalized as f64) + }) + .collect(); + + let datasets = vec![ + Dataset::default() + .name("CPU %") + .marker(ratatui::symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&data), + ]; + + let x_max = data.len().max(1) as f64; + + // Dynamic Y-axis scaling in 10% increments + let max_cpu = data.iter().map(|(_, y)| *y).fold(0.0f64, f64::max); + let y_max = calculate_dynamic_y_max(max_cpu); + + let y_labels = vec![ + Line::from("0%"), + Line::from(format!("{:.0}%", y_max / 2.0)), + Line::from(format!("{y_max:.0}%")), + ]; + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::ALL).title(title)) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .bounds([0.0, x_max]), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .labels(y_labels) + .bounds([0.0, y_max]), + ); + + f.render_widget(chart, area); + } + + fn render_middle_row_with_metadata( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_memory_io_graphs( + f, + middle_chunks[0], + process, + mem_history, + io_read_history, + io_write_history, + ); + self.render_thread_table(f, middle_chunks[1], process); + self.render_command_and_metadata(f, middle_chunks[2], process); + } + + fn render_command_and_metadata( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let details_block = Block::default() + .title("Command & Details") + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + + // Calculate uptime + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let uptime_secs = now.saturating_sub(process.start_time); + let uptime_str = format_uptime(uptime_secs); + + // Format CPU times + let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; + let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; + + let mut content_lines = vec![ + Line::from(vec![ + Span::styled("⚡ Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&process.status), + ]), + Line::from(vec![ + Span::styled("⏱️ Uptime: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(uptime_str), + ]), + Line::from(vec![ + Span::styled("🧵 Threads: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.thread_count)), + ]), + Line::from(vec![ + Span::styled("👶 Children: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.child_processes.len())), + ]), + ]; + + // Add file descriptors if available + if let Some(fd_count) = process.fd_count { + content_lines.push(Line::from(vec![ + Span::styled("📁 FDs: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{fd_count}")), + ])); + } + + content_lines.push(Line::from("")); + + // Process hierarchy with clickable parent PID + if let Some(ppid) = process.parent_pid { + content_lines.push(Line::from(vec![ + Span::styled("👪 Parent: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!("{ppid}"), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + ), + Span::styled( + " [P]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::DIM), + ), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled("👤 UID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.user_id)), + Span::styled(" 👥 GID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.group_id)), + ])); + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "⏲️ CPU Time", + Style::default().add_modifier(Modifier::BOLD), + )])); + content_lines.push(Line::from(vec![ + Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{user_time_sec:.2}s")), + ])); + content_lines.push(Line::from(vec![ + Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{system_time_sec:.2}s")), + ])); + + content_lines.push(Line::from("")); + + // Executable path if available + if let Some(exe) = &process.executable_path { + content_lines.push(Line::from(vec![Span::styled( + "📂 Executable", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(6)) as usize; + if exe.len() > max_width { + let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(vec![Span::styled( + format!(" {truncated}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } else { + content_lines.push(Line::from(vec![Span::styled( + format!(" {exe}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + // Working directory if available + if let Some(cwd) = &process.working_directory { + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "📁 Working Dir", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(6)) as usize; + if cwd.len() > max_width { + let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(vec![Span::styled( + format!(" {truncated}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } else { + content_lines.push(Line::from(vec![Span::styled( + format!(" {cwd}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + content_lines.push(Line::from("")); + + // Add command line (wrap if needed) + content_lines.push(Line::from(vec![Span::styled( + "⚙️ Command", + Style::default().add_modifier(Modifier::BOLD), + )])); + + + // Split command into multiple lines if too long + let cmd_text = &process.command; + let max_width = (area.width.saturating_sub(6)) as usize; // More conservative to avoid wrapping issues + if cmd_text.len() > max_width { + for chunk in cmd_text.as_bytes().chunks(max_width) { + if let Ok(s) = std::str::from_utf8(chunk) { + content_lines.push(Line::from(vec![Span::styled( + format!(" {s}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + } else { + content_lines.push(Line::from(vec![Span::styled( + format!(" {cmd_text}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + + let content = Paragraph::new(content_lines).block(details_block); + + f.render_widget(content, area); + } +} diff --git a/socktop/src/ui/mod.rs b/socktop/src/ui/mod.rs index 7b5d160..0e1e85e 100644 --- a/socktop/src/ui/mod.rs +++ b/socktop/src/ui/mod.rs @@ -6,6 +6,10 @@ pub mod gpu; pub mod header; pub mod mem; pub mod modal; +pub mod modal_connection; +pub mod modal_format; +pub mod modal_process; +pub mod modal_types; pub mod net; pub mod processes; pub mod swap; diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index d6e0d71..a3233e0 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -1,106 +1,27 @@ //! Modal window system for socktop TUI application -use std::time::{Duration, Instant}; - -use super::theme::{ - BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT, - BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER, - ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE, - LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG, - MODAL_DIM_BG, MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, - MODAL_RETRY_LABEL_FG, MODAL_TITLE_FG, -}; +use super::theme::MODAL_DIM_BG; use crossterm::event::KeyCode; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{ - Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Paragraph, Row, Scrollbar, - ScrollbarOrientation, ScrollbarState, Table, Wrap, - }, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; -/// History data for process metrics rendering -pub struct ProcessHistoryData<'a> { - pub cpu: &'a std::collections::VecDeque, - pub mem: &'a std::collections::VecDeque, - pub io_read: &'a std::collections::VecDeque, - pub io_write: &'a std::collections::VecDeque, -} - -/// Process data for modal rendering -pub struct ProcessModalData<'a> { - pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, - pub journal: Option<&'a socktop_connector::JournalResponse>, - pub history: ProcessHistoryData<'a>, - pub unsupported: bool, -} - -/// Parameters for rendering scatter plot -struct ScatterPlotParams<'a> { - process: &'a socktop_connector::DetailedProcessInfo, - main_user_ms: f64, - main_system_ms: f64, - max_user: f64, - max_system: f64, -} - -#[derive(Debug, Clone)] -pub enum ModalType { - ConnectionError { - message: String, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - }, - ProcessDetails { - pid: u32, - }, - #[allow(dead_code)] - Confirmation { - title: String, - message: String, - confirm_text: String, - cancel_text: String, - }, - #[allow(dead_code)] - Info { - title: String, - message: String, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ModalAction { - None, // Modal didn't handle the key, pass to main window - Handled, // Modal handled the key, don't pass to main window - RetryConnection, - ExitApp, - Confirm, - Cancel, - Dismiss, - SwitchToParentProcess(u32), // Switch to viewing parent process details -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ModalButton { - Retry, - Exit, - Confirm, - Cancel, - Ok, -} +// Re-export types from modal_types +pub use super::modal_types::{ + ModalAction, ModalButton, ModalType, ProcessHistoryData, ProcessModalData, +}; #[derive(Debug)] pub struct ModalManager { stack: Vec, - active_button: ModalButton, + pub(super) active_button: ModalButton, pub thread_scroll_offset: usize, pub journal_scroll_offset: usize, - thread_scroll_max: usize, - journal_scroll_max: usize, + pub thread_scroll_max: usize, + pub journal_scroll_max: usize, } impl ModalManager { @@ -365,279 +286,6 @@ impl ModalManager { } } - fn render_connection_error( - &self, - f: &mut Frame, - area: Rect, - message: &str, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - ) { - let duration_text = format_duration(disconnected_at.elapsed()); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(4), - ]) - .split(area); - let block = Block::default() - .title(ICON_WARNING_TITLE) - .title_style( - Style::default() - .fg(MODAL_TITLE_FG) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(MODAL_BORDER_FG)) - .style(Style::default().bg(MODAL_BG).fg(MODAL_FG)); - f.render_widget(block, area); - - let content_area = chunks[1]; - let max_w = content_area.width.saturating_sub(15) as usize; - let clean_message = if message.to_lowercase().contains("hostname verification") - || message.contains("socktop_connector") - { - "Connection failed - hostname verification disabled".to_string() - } else if message.contains("Failed to fetch metrics:") { - if let Some(p) = message.find(':') { - let ess = message[p + 1..].trim(); - if ess.len() > max_w { - format!("{}...", &ess[..max_w.saturating_sub(3)]) - } else { - ess.to_string() - } - } else { - "Connection error".to_string() - } - } else if message.starts_with("Retry failed:") { - if let Some(p) = message.find(':') { - let ess = message[p + 1..].trim(); - if ess.len() > max_w { - format!("{}...", &ess[..max_w.saturating_sub(3)]) - } else { - ess.to_string() - } - } else { - "Retry failed".to_string() - } - } else if message.len() > max_w { - format!("{}...", &message[..max_w.saturating_sub(3)]) - } else { - message.to_string() - }; - let truncate = |s: &str| { - if s.len() > max_w { - format!("{}...", &s[..max_w.saturating_sub(3)]) - } else { - s.to_string() - } - }; - let agent_text = truncate("📡 Cannot connect to socktop agent"); - let message_text = truncate(&clean_message); - let duration_display = truncate(&duration_text); - let retry_display = truncate(&retry_count.to_string()); - let countdown_text = auto_retry_countdown.map(|c| { - if c == 0 { - "Auto retry now...".to_string() - } else { - format!("{c}s") - } - }); - - // Determine if we have enough space (height + width) to show large centered icon - let icon_max_width = LARGE_ERROR_ICON - .iter() - .map(|l| l.trim().chars().count()) - .max() - .unwrap_or(0) as u16; - let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8) - && content_area.width >= icon_max_width + 6; // small margin for borders/padding - let mut icon_lines: Vec = Vec::new(); - if large_allowed { - for &raw in LARGE_ERROR_ICON.iter() { - let trimmed = raw.trim(); - icon_lines.push(Line::from( - trimmed - .chars() - .map(|ch| { - if ch == '!' { - Span::styled( - ch.to_string(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ) - } else if ch == '/' || ch == '\\' || ch == '_' { - // keep outline in pink - Span::styled( - ch.to_string(), - Style::default() - .fg(MODAL_ICON_PINK) - .add_modifier(Modifier::BOLD), - ) - } else if ch == ' ' { - Span::raw(" ") - } else { - Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK)) - } - }) - .collect::>(), - )); - } - icon_lines.push(Line::from("")); // blank spacer line below icon - } - - let mut info_lines: Vec = Vec::new(); - if !large_allowed { - info_lines.push(Line::from(vec![Span::styled( - ICON_CLUSTER, - Style::default().fg(MODAL_ICON_PINK), - )])); - info_lines.push(Line::from("")); - } - info_lines.push(Line::from(vec![Span::styled( - &agent_text, - Style::default().fg(MODAL_AGENT_FG), - )])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)), - Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)), - ])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled( - ICON_OFFLINE_LABEL, - Style::default().fg(MODAL_OFFLINE_LABEL_FG), - ), - Span::styled( - &duration_display, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - info_lines.push(Line::from(vec![ - Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)), - Span::styled( - &retry_display, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - if let Some(cd) = &countdown_text { - info_lines.push(Line::from(vec![ - Span::styled( - ICON_COUNTDOWN_LABEL, - Style::default().fg(MODAL_COUNTDOWN_LABEL_FG), - ), - Span::styled( - cd, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ])); - } - - let constrained = Rect { - x: content_area.x + 2, - y: content_area.y, - width: content_area.width.saturating_sub(4), - height: content_area.height, - }; - if large_allowed { - let split = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(icon_lines.len() as u16), - Constraint::Min(0), - ]) - .split(constrained); - // Center the icon block; each line already trimmed so per-line centering keeps shape - f.render_widget( - Paragraph::new(Text::from(icon_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: false }), - split[0], - ); - f.render_widget( - Paragraph::new(Text::from(info_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - split[1], - ); - } else { - f.render_widget( - Paragraph::new(Text::from(info_lines)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - constrained, - ); - } - - let button_area = Rect { - x: chunks[2].x, - y: chunks[2].y, - width: chunks[2].width, - height: chunks[2].height.saturating_sub(1), - }; - self.render_connection_error_buttons(f, button_area); - } - - fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) { - let button_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(15), - Constraint::Percentage(10), - Constraint::Percentage(15), - Constraint::Percentage(30), - ]) - .split(area); - let retry_style = if self.active_button == ModalButton::Retry { - Style::default() - .bg(BTN_RETRY_BG_ACTIVE) - .fg(BTN_RETRY_FG_ACTIVE) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(BTN_RETRY_FG_INACTIVE) - .add_modifier(Modifier::DIM) - }; - let exit_style = if self.active_button == ModalButton::Exit { - Style::default() - .bg(BTN_EXIT_BG_ACTIVE) - .fg(BTN_EXIT_FG_ACTIVE) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(BTN_EXIT_FG_INACTIVE) - .add_modifier(Modifier::DIM) - }; - f.render_widget( - Paragraph::new(Text::from(Line::from(vec![Span::styled( - BTN_RETRY_TEXT, - retry_style, - )]))) - .alignment(Alignment::Center), - button_chunks[1], - ); - f.render_widget( - Paragraph::new(Text::from(Line::from(vec![Span::styled( - BTN_EXIT_TEXT, - exit_style, - )]))) - .alignment(Alignment::Center), - button_chunks[3], - ); - } - fn render_confirmation( &self, f: &mut Frame, @@ -748,1121 +396,4 @@ impl ModalManager { ]) .split(vert[1])[1] } - - fn render_process_details( - &mut self, - f: &mut Frame, - area: Rect, - pid: u32, - data: ProcessModalData, - ) { - let title = format!("Process Details - PID {pid}"); - - // Use neutral colors to match main UI aesthetic - let block = Block::default().title(title).borders(Borders::ALL); - - // Split the modal into the 3-row layout as designed - let inner = block.inner(area); - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot - Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) - Constraint::Min(6), // Bottom row: Journal events (gets remaining space) - Constraint::Length(1), // Help line - ]) - .split(inner); - - // Render the border - f.render_widget(block, area); - - if let Some(details) = data.details { - // Top Row: CPU sparkline (left) | Thread scatter plot (right) - self.render_top_row_with_sparkline( - f, - main_chunks[0], - &details.process, - data.history.cpu, - ); - - // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) - self.render_middle_row_with_metadata( - f, - main_chunks[1], - &details.process, - data.history.mem, - data.history.io_read, - data.history.io_write, - ); - - // Bottom Row: Journal Events - if let Some(journal) = data.journal { - self.render_journal_events(f, main_chunks[2], journal); - } else { - self.render_loading_journal_events(f, main_chunks[2]); - } - } else if data.unsupported { - // Agent doesn't support this feature - self.render_unsupported_message(f, main_chunks[0]); - self.render_loading_middle_row(f, main_chunks[1]); - self.render_loading_journal_events(f, main_chunks[2]); - } else { - // Loading states for all sections - self.render_loading_top_row(f, main_chunks[0]); - self.render_loading_middle_row(f, main_chunks[1]); - self.render_loading_journal_events(f, main_chunks[2]); - } - - // Help line - let help_text = vec![Line::from(vec![ - Span::styled( - "X ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - "P ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "goto parent ", - Style::default().add_modifier(Modifier::DIM), - ), - Span::styled( - "j/k ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - "[ ] ", - Style::default() - .fg(super::theme::PROCESS_DETAILS_ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), - ])]; - let help = Paragraph::new(help_text).alignment(Alignment::Center); - f.render_widget(help, main_chunks[3]); - } - - fn render_thread_scatter_plot( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let plot_block = Block::default() - .title("Thread & Process CPU Time") - .borders(Borders::ALL); - - let inner = plot_block.inner(area); - - // Convert CPU times from microseconds to milliseconds for better readability - let main_user_ms = process.cpu_time_user as f64 / 1000.0; - let main_system_ms = process.cpu_time_system as f64 / 1000.0; - - // Calculate max values for scaling - let mut max_user = main_user_ms; - let mut max_system = main_system_ms; - - for child in &process.child_processes { - let child_user_ms = child.cpu_time_user as f64 / 1000.0; - let child_system_ms = child.cpu_time_system as f64 / 1000.0; - max_user = max_user.max(child_user_ms); - max_system = max_system.max(child_system_ms); - } - - // Add some padding to the scale - max_user = (max_user * 1.1).max(1.0); - max_system = (max_system * 1.1).max(1.0); - - // Render the existing scatter plot but in the smaller space - self.render_scatter_plot_content( - f, - inner, - ScatterPlotParams { - process, - main_user_ms, - main_system_ms, - max_user, - max_system, - }, - ); - - // Render the border - f.render_widget(plot_block, area); - } - - fn render_memory_io_graphs( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { - let graphs_block = Block::default().title("Memory & I/O").borders(Borders::ALL); - - let mem_mb = process.mem_bytes as f64 / 1_048_576.0; - let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; - - let mut content_lines = vec![ - Line::from(vec![Span::styled( - "🧠 Memory", - Style::default().add_modifier(Modifier::BOLD), - )]), - Line::from(vec![ - Span::styled(" 📊 RSS: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{mem_mb:.1} MB")), - ]), - ]; - - // Add memory sparkline if we have history - if mem_history.len() >= 2 { - let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB - let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); - - // Create mini sparkline using Unicode blocks - let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - let sparkline_str: String = mem_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Blue)), - ])); - } else { - content_lines.push(Line::from(vec![Span::styled( - " Collecting...", - Style::default().add_modifier(Modifier::DIM), - )])); - } - - content_lines.push(Line::from(vec![ - Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{virtual_mb:.1} MB")), - ])); - - // Add shared memory if available - if let Some(shared_bytes) = process.shared_mem_bytes { - let shared_mb = shared_bytes as f64 / 1_048_576.0; - content_lines.push(Line::from(vec![ - Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{shared_mb:.1} MB")), - ])); - } - - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![Span::styled( - "💾 Disk I/O", - Style::default().add_modifier(Modifier::BOLD), - )])); - - // Add I/O stats if available - match (process.read_bytes, process.write_bytes) { - (Some(read), Some(write)) => { - let read_mb = read as f64 / 1_048_576.0; - let write_mb = write as f64 / 1_048_576.0; - content_lines.push(Line::from(vec![ - Span::styled(" 📖 Read: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{read_mb:.1} MB")), - ])); - - // Add read I/O sparkline if we have history - if io_read_history.len() >= 2 { - let read_data: Vec = io_read_history - .iter() - .map(|&bytes| bytes / 1_048_576) - .collect(); // Convert to MB - let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); - - let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - let sparkline_str: String = read_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Green)), - ])); - } - - content_lines.push(Line::from(vec![ - Span::styled( - " ✍️ Write: ", - Style::default().add_modifier(Modifier::DIM), - ), - Span::raw(format!("{write_mb:.1} MB")), - ])); - - // Add write I/O sparkline if we have history - if io_write_history.len() >= 2 { - let write_data: Vec = io_write_history - .iter() - .map(|&bytes| bytes / 1_048_576) - .collect(); // Convert to MB - let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); - - let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - let sparkline_str: String = write_data - .iter() - .map(|&val| { - let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; - blocks[level.min(7)] - }) - .collect(); - - content_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), - ])); - } - } - _ => { - content_lines.push(Line::from(vec![Span::styled( - " Not available", - Style::default().add_modifier(Modifier::DIM), - )])); - } - } - - let content = Paragraph::new(content_lines).block(graphs_block); - - f.render_widget(content, area); - } - - fn render_thread_table( - &mut self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let total_items = process.threads.len() + process.child_processes.len(); - - // Manually calculate inner area (like processes.rs does) - let inner_area = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - - // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) - let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; - - // Calculate and store max scroll for key handler bounds checking - self.thread_scroll_max = if total_items > visible_rows { - total_items.saturating_sub(visible_rows) - } else { - 0 - }; - - // Clamp scroll offset to valid range - let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); - - // Combine threads and processes into rows - let mut rows = Vec::new(); - - // Add threads first - for thread in &process.threads { - rows.push(Row::new(vec![ - Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), - Line::from(format!("{}", thread.tid)), - Line::from(thread.name.clone()), - Line::from(thread.status.clone()), - ])); - } - - // Add child processes - for child in &process.child_processes { - rows.push(Row::new(vec![ - Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), - Line::from(format!("{}", child.pid)), - Line::from(child.name.clone()), - Line::from(format!("{:.1}%", child.cpu_usage)), - ])); - } - - // Create table header - let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) - .style(Style::default().add_modifier(Modifier::BOLD)) - .bottom_margin(1); - - let block = Block::default() - .title(format!( - "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", - process.threads.len(), - process.child_processes.len() - )) - .borders(Borders::ALL); - - let table = Table::new( - rows.iter().skip(scroll_offset).take(visible_rows).cloned(), - [ - Constraint::Length(6), - Constraint::Length(10), - Constraint::Min(15), - Constraint::Length(12), - ], - ) - .header(header) - .block(block) - .highlight_style(Style::default()); - - f.render_widget(table, area); - - // Render scrollbar if there are more items than visible - if total_items > visible_rows { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - // Use the same max_scroll value we use for clamping - // This ensures the scrollbar position matches our actual scroll range - let mut scrollbar_state = - ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); - - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y + 1, - width: 1, - height: area.height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } - - fn render_journal_events( - &mut self, - f: &mut Frame, - area: Rect, - journal: &socktop_connector::JournalResponse, - ) { - let total_entries = journal.entries.len(); - let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders - - // Calculate and store max scroll for key handler bounds checking - self.journal_scroll_max = if total_entries > visible_lines { - total_entries.saturating_sub(visible_lines) - } else { - 0 - }; - - // Clamp scroll offset to valid range - let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); - - let journal_block = Block::default() - .title(format!( - "Journal Events ({total_entries} entries) - Use [ ] to scroll" - )) - .borders(Borders::ALL); - - let content_lines: Vec = if journal.entries.is_empty() { - vec![ - Line::from(""), - Line::from(Span::styled( - "No journal entries found for this process", - Style::default().add_modifier(Modifier::DIM), - )), - ] - } else { - journal - .entries - .iter() - .skip(scroll_offset) - .take(visible_lines) - .map(|entry| { - let priority_style = match entry.priority { - socktop_connector::LogLevel::Error - | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), - socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), - socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { - Style::default().fg(Color::Blue) - } - _ => Style::default(), - }; - - let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time - let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority - let message = &entry.message[..entry.message.len().min(message_max_len)]; - - Line::from(vec![ - Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), - Span::raw(" "), - Span::styled( - format!("{:>7}", format!("{:?}", entry.priority)), - priority_style, - ), - Span::raw(" "), - Span::raw(message), - if entry.message.len() > message_max_len { - Span::styled("...", Style::default().add_modifier(Modifier::DIM)) - } else { - Span::raw("") - }, - ]) - }) - .collect() - }; - - let content = Paragraph::new(content_lines).block(journal_block); - - f.render_widget(content, area); - - // Render scrollbar if there are more entries than visible - if total_entries > visible_lines { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - // Use the same max_scroll value we use for clamping - let mut scrollbar_state = - ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); - - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y + 1, - width: 1, - height: area.height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } - - fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { - if area.width < 20 || area.height < 10 { - // Area too small for meaningful plot - let content = Paragraph::new(vec![Line::from(Span::styled( - "Area too small for plot", - Style::default().fg(MODAL_HINT_FG), - ))]) - .alignment(Alignment::Center) - .style(Style::default().bg(MODAL_BG)); - f.render_widget(content, area); - return; - } - - // Calculate plot dimensions (leave space for axes labels) - let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels - let plot_height = area.height.saturating_sub(3) as usize; // Leave space for X-axis labels - - if plot_width == 0 || plot_height == 0 { - return; - } - - // Create a 2D grid to represent the plot - let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; - - // Plot main process - let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let main_y = plot_height.saturating_sub(1).saturating_sub( - ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - if main_x < plot_width && main_y < plot_height { - plot_grid[main_y][main_x] = '●'; // Main process marker - } - - // Plot threads (use different marker) - for thread in ¶ms.process.threads { - let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; - let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; - - let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let thread_y = plot_height.saturating_sub(1).saturating_sub( - ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - - if thread_x < plot_width && thread_y < plot_height { - if plot_grid[thread_y][thread_x] == ' ' { - plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) - } else if plot_grid[thread_y][thread_x] == '○' { - plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point - } else { - plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point - } - } - } - - // Plot child processes - for child in ¶ms.process.child_processes { - let child_user_ms = child.cpu_time_user as f64 / 1000.0; - let child_system_ms = child.cpu_time_system as f64 / 1000.0; - - let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; - let child_y = plot_height.saturating_sub(1).saturating_sub( - ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, - ); - - if child_x < plot_width && child_y < plot_height { - if plot_grid[child_y][child_x] == ' ' { - plot_grid[child_y][child_x] = '•'; // Child process marker - } else { - plot_grid[child_y][child_x] = '◉'; // Multiple items at same point - } - } - } - - // Render the plot - let mut lines = Vec::new(); - - // Add Y-axis labels and plot content - for (i, row) in plot_grid.iter().enumerate() { - let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); - // Always format with 4 characters width, right-aligned, to prevent axis shifting - let y_label = if y_value >= 100.0 { - format!("{y_value:>4.0}") - } else { - format!("{y_value:>4.1}") - }; - - let plot_content: String = row.iter().collect(); - - lines.push(Line::from(vec![ - Span::styled(y_label, Style::default()), - Span::styled(" │", Style::default()), - Span::styled(plot_content, Style::default()), - ])); - } - - // Add X-axis - let x_axis_padding = " ".to_string(); // Match Y-axis label width - let x_axis_line = "─".repeat(plot_width + 1); - lines.push(Line::from(vec![ - Span::styled(x_axis_padding, Style::default()), - Span::styled(x_axis_line, Style::default()), - ])); - - // Add X-axis labels - let x_label_start = "0.0".to_string(); - let x_label_mid = format!("{:.1}", params.max_user / 2.0); - let x_label_end = format!("{:.1}", params.max_user); - - let spacing = plot_width / 3; - let x_labels = format!( - " {}{}{}{}{}", - x_label_start, - " ".repeat(spacing.saturating_sub(x_label_start.len())), - x_label_mid, - " ".repeat(spacing.saturating_sub(x_label_mid.len())), - x_label_end - ); - - lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); - - // Add axis titles - lines.push(Line::from(vec![Span::styled( - " User CPU Time (ms) →", - Style::default().add_modifier(Modifier::BOLD), - )])); - - // Add legend - lines.insert( - 0, - Line::from(vec![Span::styled( - "System CPU Time (ms) ↑", - Style::default().add_modifier(Modifier::BOLD), - )]), - ); - lines.insert( - 1, - Line::from(vec![Span::styled( - "● Main Process ○ Thread • Child Process ◉ Multiple", - Style::default().add_modifier(Modifier::DIM), - )]), - ); - - let content = Paragraph::new(lines) - .style(Style::default()) - .alignment(Alignment::Left); - - f.render_widget(content, area); - } - - fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) - .split(area); - - self.render_loading_metadata(f, top_chunks[0]); - self.render_loading_scatter(f, top_chunks[1]); - } - - fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(area); - - self.render_loading_graphs(f, middle_chunks[0]); - self.render_loading_table(f, middle_chunks[1]); - self.render_loading_command(f, middle_chunks[2]); - } - - fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Process Info & CPU History") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading process metadata...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Thread CPU Time Distribution") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading CPU time data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { - let block = Block::default().title("Memory & I/O").borders(Borders::ALL); - - let content = Paragraph::new("Loading memory & I/O data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_table(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Child Processes") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading child process data...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_command(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Command & Details") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading command details...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Journal Events") - .borders(Borders::ALL); - - let content = Paragraph::new("Loading journal entries...") - .block(block) - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - - f.render_widget(content, area); - } - - fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { - let block = Block::default() - .title("Process Details") - .borders(Borders::ALL); - - let content = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - "⚠ Agent Update Required", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "This agent version does not support per-process metrics.", - Style::default().add_modifier(Modifier::DIM), - )), - Line::from(Span::styled( - "Please update your socktop_agent to the latest version.", - Style::default().add_modifier(Modifier::DIM), - )), - ]) - .block(block) - .alignment(Alignment::Center); - - f.render_widget(content, area); - } - - fn render_top_row_with_sparkline( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - cpu_history: &std::collections::VecDeque, - ) { - // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(60), // CPU sparkline - Constraint::Percentage(40), // Thread scatter plot - ]) - .split(area); - - self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); - self.render_thread_scatter_plot(f, top_chunks[1], process); - } - - fn render_cpu_sparkline( - &self, - f: &mut Frame, - area: Rect, - _process: &socktop_connector::DetailedProcessInfo, - cpu_history: &std::collections::VecDeque, - ) { - // Calculate actual average and current - let current_cpu = cpu_history.back().copied().unwrap_or(0.0); - let avg_cpu = if cpu_history.is_empty() { - 0.0 - } else { - cpu_history.iter().sum::() / cpu_history.len() as f32 - }; - let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); - - // Similar to main CPU rendering but for process CPU - if cpu_history.len() < 2 { - let block = Block::default().title(title).borders(Borders::ALL); - let inner = block.inner(area); - f.render_widget(block, area); - - let content = Paragraph::new("Collecting CPU history data...") - .alignment(Alignment::Center) - .style(Style::default().add_modifier(Modifier::DIM)); - f.render_widget(content, inner); - return; - } - - let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels - let start = cpu_history.len().saturating_sub(max_points); - - // Create data points for the chart - let data: Vec<(f64, f64)> = cpu_history - .iter() - .skip(start) - .enumerate() - .map(|(i, &val)| (i as f64, val as f64)) - .collect(); - - let datasets = vec![ - Dataset::default() - .name("CPU %") - .marker(ratatui::symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(Color::Cyan)) - .data(&data), - ]; - - let x_max = data.len().max(1) as f64; - let y_max = cpu_history.iter().copied().fold(0.0f32, f32::max).max(10.0) as f64; // At least 10% scale - - let y_labels = vec![ - Line::from("0%"), - Line::from(format!("{:.0}%", y_max / 2.0)), - Line::from(format!("{y_max:.0}%")), - ]; - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::ALL).title(title)) - .x_axis( - Axis::default() - .style(Style::default().fg(Color::Gray)) - .bounds([0.0, x_max]), - ) - .y_axis( - Axis::default() - .style(Style::default().fg(Color::Gray)) - .labels(y_labels) - .bounds([0.0, y_max]), - ); - - f.render_widget(chart, area); - } - - fn render_middle_row_with_metadata( - &mut self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { - // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(area); - - self.render_memory_io_graphs( - f, - middle_chunks[0], - process, - mem_history, - io_read_history, - io_write_history, - ); - self.render_thread_table(f, middle_chunks[1], process); - self.render_command_and_metadata(f, middle_chunks[2], process); - } - - fn render_command_and_metadata( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - ) { - let details_block = Block::default() - .title("Command & Details") - .borders(Borders::ALL); - - // Calculate uptime - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let uptime_secs = now.saturating_sub(process.start_time); - let uptime_str = format_uptime(uptime_secs); - - // Format CPU times - let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; - let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; - - let mut content_lines = vec![ - Line::from(vec![ - Span::styled("⚡ Status: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&process.status), - ]), - Line::from(vec![ - Span::styled( - "⏱️ Uptime: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(uptime_str), - ]), - Line::from(vec![ - Span::styled( - "🧵 Threads: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!("{}", process.thread_count)), - ]), - Line::from(vec![ - Span::styled( - "👶 Children: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!("{}", process.child_processes.len())), - ]), - ]; - - // Add file descriptors if available - if let Some(fd_count) = process.fd_count { - content_lines.push(Line::from(vec![ - Span::styled("📁 FDs: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{fd_count}")), - ])); - } - - content_lines.push(Line::from("")); - - // Process hierarchy with clickable parent PID - if let Some(ppid) = process.parent_pid { - content_lines.push(Line::from(vec![ - Span::styled( - "👪 Parent PID: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{ppid} "), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED), - ), - Span::styled( - "[P to open]", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::DIM), - ), - ])); - } - - content_lines.push(Line::from(vec![ - Span::styled("👤 UID: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.user_id)), - Span::styled(" 👥 GID: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!("{}", process.group_id)), - ])); - - content_lines.push(Line::from("")); - content_lines.push(Line::from(vec![Span::styled( - "⏲️ CPU Time", - Style::default().add_modifier(Modifier::BOLD), - )])); - content_lines.push(Line::from(vec![ - Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{user_time_sec:.2}s")), - ])); - content_lines.push(Line::from(vec![ - Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), - Span::raw(format!("{system_time_sec:.2}s")), - ])); - - content_lines.push(Line::from("")); - - // Executable path if available - if let Some(exe) = &process.executable_path { - content_lines.push(Line::from(vec![Span::styled( - "📂 Executable:", - Style::default().add_modifier(Modifier::BOLD), - )])); - // Truncate if too long - let max_width = (area.width.saturating_sub(4)) as usize; - if exe.len() > max_width { - let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); - content_lines.push(Line::from(Span::styled( - truncated, - Style::default().add_modifier(Modifier::DIM), - ))); - } else { - content_lines.push(Line::from(Span::styled( - exe, - Style::default().add_modifier(Modifier::DIM), - ))); - } - } - - // Working directory if available - if let Some(cwd) = &process.working_directory { - content_lines.push(Line::from(vec![Span::styled( - "📁 Working Dir:", - Style::default().add_modifier(Modifier::BOLD), - )])); - // Truncate if too long - let max_width = (area.width.saturating_sub(4)) as usize; - if cwd.len() > max_width { - let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); - content_lines.push(Line::from(Span::styled( - truncated, - Style::default().add_modifier(Modifier::DIM), - ))); - } else { - content_lines.push(Line::from(Span::styled( - cwd, - Style::default().add_modifier(Modifier::DIM), - ))); - } - } - - content_lines.push(Line::from("")); - - // Add command line (wrap if needed) - content_lines.push(Line::from(vec![Span::styled( - "⚙️ Command:", - Style::default().add_modifier(Modifier::BOLD), - )])); - - // Split command into multiple lines if too long - let cmd_text = &process.command; - let max_width = (area.width.saturating_sub(4)) as usize; - if cmd_text.len() > max_width { - for chunk in cmd_text.as_bytes().chunks(max_width) { - if let Ok(s) = std::str::from_utf8(chunk) { - content_lines.push(Line::from(Span::styled( - s, - Style::default().add_modifier(Modifier::DIM), - ))); - } - } - } else { - content_lines.push(Line::from(Span::styled( - cmd_text, - Style::default().add_modifier(Modifier::DIM), - ))); - } - - let content = Paragraph::new(content_lines) - .block(details_block) - .wrap(Wrap { trim: false }); - - f.render_widget(content, area); - } -} - -fn format_uptime(secs: u64) -> String { - let days = secs / 86400; - let hours = (secs % 86400) / 3600; - let minutes = (secs % 3600) / 60; - let seconds = secs % 60; - - if days > 0 { - format!("{days}d {hours}h {minutes}m") - } else if hours > 0 { - format!("{hours}h {minutes}m {seconds}s") - } else if minutes > 0 { - format!("{minutes}m {seconds}s") - } else { - format!("{seconds}s") - } -} - -fn format_duration(duration: Duration) -> String { - let total = duration.as_secs(); - let h = total / 3600; - let m = (total % 3600) / 60; - let s = total % 60; - if h > 0 { - format!("{h}h {m}m {s}s") - } else if m > 0 { - format!("{m}m {s}s") - } else { - format!("{s}s") - } } diff --git a/socktop/src/ui/modal_connection.rs b/socktop/src/ui/modal_connection.rs new file mode 100644 index 0000000..be5bc02 --- /dev/null +++ b/socktop/src/ui/modal_connection.rs @@ -0,0 +1,297 @@ +//! Connection error modal rendering + +use std::time::Instant; + +use super::modal_format::format_duration; +use super::theme::{ + BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT, + BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER, + ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE, + LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG, + MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, MODAL_RETRY_LABEL_FG, + MODAL_TITLE_FG, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use super::modal::{ModalButton, ModalManager}; + +impl ModalManager { + pub(super) fn render_connection_error( + &self, + f: &mut Frame, + area: Rect, + message: &str, + disconnected_at: Instant, + retry_count: u32, + auto_retry_countdown: Option, + ) { + let duration_text = format_duration(disconnected_at.elapsed()); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(4), + ]) + .split(area); + let block = Block::default() + .title(ICON_WARNING_TITLE) + .title_style( + Style::default() + .fg(MODAL_TITLE_FG) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(MODAL_BORDER_FG)) + .style(Style::default().bg(MODAL_BG).fg(MODAL_FG)); + f.render_widget(block, area); + + let content_area = chunks[1]; + let max_w = content_area.width.saturating_sub(15) as usize; + let clean_message = if message.to_lowercase().contains("hostname verification") + || message.contains("socktop_connector") + { + "Connection failed - hostname verification disabled".to_string() + } else if message.contains("Failed to fetch metrics:") { + if let Some(p) = message.find(':') { + let ess = message[p + 1..].trim(); + if ess.len() > max_w { + format!("{}...", &ess[..max_w.saturating_sub(3)]) + } else { + ess.to_string() + } + } else { + "Connection error".to_string() + } + } else if message.starts_with("Retry failed:") { + if let Some(p) = message.find(':') { + let ess = message[p + 1..].trim(); + if ess.len() > max_w { + format!("{}...", &ess[..max_w.saturating_sub(3)]) + } else { + ess.to_string() + } + } else { + "Retry failed".to_string() + } + } else if message.len() > max_w { + format!("{}...", &message[..max_w.saturating_sub(3)]) + } else { + message.to_string() + }; + let truncate = |s: &str| { + if s.len() > max_w { + format!("{}...", &s[..max_w.saturating_sub(3)]) + } else { + s.to_string() + } + }; + let agent_text = truncate("📡 Cannot connect to socktop agent"); + let message_text = truncate(&clean_message); + let duration_display = truncate(&duration_text); + let retry_display = truncate(&retry_count.to_string()); + let countdown_text = auto_retry_countdown.map(|c| { + if c == 0 { + "Auto retry now...".to_string() + } else { + format!("{c}s") + } + }); + + // Determine if we have enough space (height + width) to show large centered icon + let icon_max_width = LARGE_ERROR_ICON + .iter() + .map(|l| l.trim().chars().count()) + .max() + .unwrap_or(0) as u16; + let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8) + && content_area.width >= icon_max_width + 6; // small margin for borders/padding + let mut icon_lines: Vec = Vec::new(); + if large_allowed { + for &raw in LARGE_ERROR_ICON.iter() { + let trimmed = raw.trim(); + icon_lines.push(Line::from( + trimmed + .chars() + .map(|ch| { + if ch == '!' { + Span::styled( + ch.to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else if ch == '/' || ch == '\\' || ch == '_' { + // keep outline in pink + Span::styled( + ch.to_string(), + Style::default() + .fg(MODAL_ICON_PINK) + .add_modifier(Modifier::BOLD), + ) + } else if ch == ' ' { + Span::raw(" ") + } else { + Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK)) + } + }) + .collect::>(), + )); + } + icon_lines.push(Line::from("")); // blank spacer line below icon + } + + let mut info_lines: Vec = Vec::new(); + if !large_allowed { + info_lines.push(Line::from(vec![Span::styled( + ICON_CLUSTER, + Style::default().fg(MODAL_ICON_PINK), + )])); + info_lines.push(Line::from("")); + } + info_lines.push(Line::from(vec![Span::styled( + &agent_text, + Style::default().fg(MODAL_AGENT_FG), + )])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)), + Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)), + ])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled( + ICON_OFFLINE_LABEL, + Style::default().fg(MODAL_OFFLINE_LABEL_FG), + ), + Span::styled( + &duration_display, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + info_lines.push(Line::from(vec![ + Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)), + Span::styled( + &retry_display, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + if let Some(cd) = &countdown_text { + info_lines.push(Line::from(vec![ + Span::styled( + ICON_COUNTDOWN_LABEL, + Style::default().fg(MODAL_COUNTDOWN_LABEL_FG), + ), + Span::styled( + cd, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ])); + } + + let constrained = Rect { + x: content_area.x + 2, + y: content_area.y, + width: content_area.width.saturating_sub(4), + height: content_area.height, + }; + if large_allowed { + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(icon_lines.len() as u16), + Constraint::Min(0), + ]) + .split(constrained); + // Center the icon block; each line already trimmed so per-line centering keeps shape + f.render_widget( + Paragraph::new(Text::from(icon_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }), + split[0], + ); + f.render_widget( + Paragraph::new(Text::from(info_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + split[1], + ); + } else { + f.render_widget( + Paragraph::new(Text::from(info_lines)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + constrained, + ); + } + + let button_area = Rect { + x: chunks[2].x, + y: chunks[2].y, + width: chunks[2].width, + height: chunks[2].height.saturating_sub(1), + }; + self.render_connection_error_buttons(f, button_area); + } + + fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) { + let button_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(15), + Constraint::Percentage(10), + Constraint::Percentage(15), + Constraint::Percentage(30), + ]) + .split(area); + let retry_style = if self.active_button == ModalButton::Retry { + Style::default() + .bg(BTN_RETRY_BG_ACTIVE) + .fg(BTN_RETRY_FG_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(BTN_RETRY_FG_INACTIVE) + .add_modifier(Modifier::DIM) + }; + let exit_style = if self.active_button == ModalButton::Exit { + Style::default() + .bg(BTN_EXIT_BG_ACTIVE) + .fg(BTN_EXIT_FG_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(BTN_EXIT_FG_INACTIVE) + .add_modifier(Modifier::DIM) + }; + f.render_widget( + Paragraph::new(Text::from(Line::from(vec![Span::styled( + BTN_RETRY_TEXT, + retry_style, + )]))) + .alignment(Alignment::Center), + button_chunks[1], + ); + f.render_widget( + Paragraph::new(Text::from(Line::from(vec![Span::styled( + BTN_EXIT_TEXT, + exit_style, + )]))) + .alignment(Alignment::Center), + button_chunks[3], + ); + } +} diff --git a/socktop/src/ui/modal_format.rs b/socktop/src/ui/modal_format.rs new file mode 100644 index 0000000..5c7e71f --- /dev/null +++ b/socktop/src/ui/modal_format.rs @@ -0,0 +1,112 @@ +//! Formatting utilities for process details modal + +use std::time::Duration; + +/// Format uptime in human-readable form +pub fn format_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + + if days > 0 { + format!("{days}d {hours}h {minutes}m") + } else if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + +/// Format duration in human-readable form +pub fn format_duration(duration: Duration) -> String { + let total = duration.as_secs(); + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { + format!("{h}h {m}m {s}s") + } else if m > 0 { + format!("{m}m {s}s") + } else { + format!("{s}s") + } +} + +/// Normalize CPU usage to 0-100% by dividing by thread count +pub fn normalize_cpu_usage(cpu_usage: f32, thread_count: u32) -> f32 { + let threads = thread_count.max(1) as f32; + (cpu_usage / threads).min(100.0) +} + +/// Calculate dynamic Y-axis maximum in 10% increments +pub fn calculate_dynamic_y_max(max_value: f64) -> f64 { + ((max_value / 10.0).ceil() * 10.0).clamp(10.0, 100.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_uptime_seconds() { + assert_eq!(format_uptime(45), "45s"); + } + + #[test] + fn test_format_uptime_minutes() { + assert_eq!(format_uptime(125), "2m 5s"); + } + + #[test] + fn test_format_uptime_hours() { + assert_eq!(format_uptime(3665), "1h 1m 5s"); + } + + #[test] + fn test_format_uptime_days() { + assert_eq!(format_uptime(90061), "1d 1h 1m"); + } + + #[test] + fn test_normalize_cpu_single_thread() { + assert_eq!(normalize_cpu_usage(50.0, 1), 50.0); + } + + #[test] + fn test_normalize_cpu_multi_thread() { + assert_eq!(normalize_cpu_usage(400.0, 4), 100.0); + } + + #[test] + fn test_normalize_cpu_zero_threads() { + // Should default to 1 thread to avoid division by zero + assert_eq!(normalize_cpu_usage(100.0, 0), 100.0); + } + + #[test] + fn test_normalize_cpu_caps_at_100() { + assert_eq!(normalize_cpu_usage(150.0, 1), 100.0); + } + + #[test] + fn test_dynamic_y_max_rounds_up() { + assert_eq!(calculate_dynamic_y_max(15.0), 20.0); + assert_eq!(calculate_dynamic_y_max(25.0), 30.0); + assert_eq!(calculate_dynamic_y_max(5.0), 10.0); + } + + #[test] + fn test_dynamic_y_max_minimum() { + assert_eq!(calculate_dynamic_y_max(0.0), 10.0); + assert_eq!(calculate_dynamic_y_max(3.0), 10.0); + } + + #[test] + fn test_dynamic_y_max_caps_at_100() { + assert_eq!(calculate_dynamic_y_max(95.0), 100.0); + assert_eq!(calculate_dynamic_y_max(100.0), 100.0); + } +} diff --git a/socktop/src/ui/modal_process.rs b/socktop/src/ui/modal_process.rs new file mode 100644 index 0000000..8388cd8 --- /dev/null +++ b/socktop/src/ui/modal_process.rs @@ -0,0 +1,1131 @@ +//! Process details modal for socktop TUI application + +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{ + Axis, Block, Borders, Chart, Dataset, GraphType, Padding, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, Table, + }, +}; + +use super::modal::ModalManager; +use super::modal_format::{calculate_dynamic_y_max, format_uptime, normalize_cpu_usage}; +use super::modal_types::{ProcessModalData, ScatterPlotParams}; +use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT}; + +impl ModalManager { + pub(super) fn render_process_details( + &mut self, + f: &mut Frame, + area: Rect, + pid: u32, + data: ProcessModalData, + ) { + let title = format!("Process Details - PID {pid}"); + + // Use neutral colors to match main UI aesthetic + let block = Block::default().title(title).borders(Borders::ALL); + + // Split the modal into the 3-row layout as designed + let inner = block.inner(area); + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot + Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) + Constraint::Min(6), // Bottom row: Journal events (gets remaining space) + Constraint::Length(1), // Help line + ]) + .split(inner); + + // Render the border + f.render_widget(block, area); + + if let Some(details) = data.details { + // Top Row: CPU sparkline (left) | Thread scatter plot (right) + self.render_top_row_with_sparkline( + f, + main_chunks[0], + &details.process, + data.history.cpu, + ); + + // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) + self.render_middle_row_with_metadata( + f, + main_chunks[1], + &details.process, + data.history.mem, + data.history.io_read, + data.history.io_write, + ); + + // Bottom Row: Journal Events + if let Some(journal) = data.journal { + self.render_journal_events(f, main_chunks[2], journal); + } else { + self.render_loading_journal_events(f, main_chunks[2]); + } + } else if data.unsupported { + // Agent doesn't support this feature + self.render_unsupported_message(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } else { + // Loading states for all sections + self.render_loading_top_row(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } + + // Help line + let help_text = vec![Line::from(vec![ + Span::styled( + "X ", + Style::default() + .fg(PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "P ", + Style::default() + .fg(PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "goto parent ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::styled( + "j/k ", + Style::default() + .fg(PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "[ ] ", + Style::default() + .fg(PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), + ])]; + + let help = Paragraph::new(Text::from(help_text)) + .alignment(Alignment::Center) + .style(Style::default()); + f.render_widget(help, main_chunks[3]); + } + fn render_thread_scatter_plot( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let plot_block = Block::default() + .title("Thread & Process CPU Time") + .borders(Borders::ALL); + + let inner = plot_block.inner(area); + + // Convert CPU times from microseconds to milliseconds for better readability + let main_user_ms = process.cpu_time_user as f64 / 1000.0; + let main_system_ms = process.cpu_time_system as f64 / 1000.0; + + // Calculate max values for scaling + let mut max_user = main_user_ms; + let mut max_system = main_system_ms; + + for child in &process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + max_user = max_user.max(child_user_ms); + max_system = max_system.max(child_system_ms); + } + + // Add some padding to the scale + max_user = (max_user * 1.1).max(1.0); + max_system = (max_system * 1.1).max(1.0); + + // Render the existing scatter plot but in the smaller space + self.render_scatter_plot_content( + f, + inner, + ScatterPlotParams { + process, + main_user_ms, + main_system_ms, + max_user, + max_system, + }, + ); + + // Render the border + f.render_widget(plot_block, area); + } + + fn render_memory_io_graphs( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + let graphs_block = Block::default() + .title("Memory & I/O") + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + + let mem_mb = process.mem_bytes as f64 / 1_048_576.0; + let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; + + let mut content_lines = vec![ + Line::from(vec![ + Span::styled("🧠 Memory", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(""), // Small padding + ]), + Line::from(vec![ + Span::styled(" RSS: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{mem_mb:.1} MB")), + ]), + ]; + + // Add memory sparkline if we have history + if mem_history.len() >= 2 { + let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB + let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); + + // Create mini sparkline using Unicode blocks + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = mem_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Blue)), + ])); + } else { + content_lines.push(Line::from(vec![Span::styled( + " Collecting...", + Style::default().add_modifier(Modifier::DIM), + )])); + } + + content_lines.push(Line::from(vec![ + Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{virtual_mb:.1} MB")), + ])); + + // Add shared memory if available + if let Some(shared_bytes) = process.shared_mem_bytes { + let shared_mb = shared_bytes as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{shared_mb:.1} MB")), + ])); + } + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![ + Span::styled("💾 Disk I/O", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(""), // Small padding + ])); + + // Add I/O stats if available + match (process.read_bytes, process.write_bytes) { + (Some(read), Some(write)) => { + let read_mb = read as f64 / 1_048_576.0; + let write_mb = write as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" Read: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{read_mb:.1} MB")), + ])); + + // Add read I/O sparkline if we have history + if io_read_history.len() >= 2 { + let read_data: Vec = io_read_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = read_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Green)), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled(" Write: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{write_mb:.1} MB")), + ])); + + // Add write I/O sparkline if we have history + if io_write_history.len() >= 2 { + let write_data: Vec = io_write_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = write_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), + ])); + } + } + _ => { + content_lines.push(Line::from(vec![Span::styled( + " Not available", + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + let content = Paragraph::new(content_lines).block(graphs_block); + + f.render_widget(content, area); + } + + fn render_thread_table( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let total_items = process.threads.len() + process.child_processes.len(); + + // Manually calculate inner area (like processes.rs does) + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) + let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; + + // Calculate and store max scroll for key handler bounds checking + self.thread_scroll_max = if total_items > visible_rows { + total_items.saturating_sub(visible_rows) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); + + // Combine threads and processes into rows + let mut rows = Vec::new(); + + // Add threads first + for thread in &process.threads { + rows.push(Row::new(vec![ + Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), + Line::from(format!("{}", thread.tid)), + Line::from(thread.name.clone()), + Line::from(thread.status.clone()), + ])); + } + + // Add child processes + for child in &process.child_processes { + rows.push(Row::new(vec![ + Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), + Line::from(format!("{}", child.pid)), + Line::from(child.name.clone()), + Line::from(format!("{:.1}%", child.cpu_usage)), + ])); + } + + // Create table header + let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) + .style(Style::default().add_modifier(Modifier::BOLD)) + .bottom_margin(1); + + let block = Block::default() + .title(format!( + "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", + process.threads.len(), + process.child_processes.len() + )) + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + + let table = Table::new( + rows.iter().skip(scroll_offset).take(visible_rows).cloned(), + [ + Constraint::Length(6), + Constraint::Length(10), + Constraint::Min(15), + Constraint::Length(12), + ], + ) + .header(header) + .block(block) + .highlight_style(Style::default()); + + f.render_widget(table, area); + + // Render scrollbar if there are more items than visible + if total_items > visible_rows { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + // This ensures the scrollbar position matches our actual scroll range + let mut scrollbar_state = + ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_journal_events( + &mut self, + f: &mut Frame, + area: Rect, + journal: &socktop_connector::JournalResponse, + ) { + let total_entries = journal.entries.len(); + let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders + + // Calculate and store max scroll for key handler bounds checking + self.journal_scroll_max = if total_entries > visible_lines { + total_entries.saturating_sub(visible_lines) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); + + let journal_block = Block::default() + .title(format!( + "Journal Events ({total_entries} entries) - Use [ ] to scroll" + )) + .borders(Borders::ALL); + + let content_lines: Vec = if journal.entries.is_empty() { + vec![ + Line::from(""), + Line::from(Span::styled( + "No journal entries found for this process", + Style::default().add_modifier(Modifier::DIM), + )), + ] + } else { + journal + .entries + .iter() + .skip(scroll_offset) + .take(visible_lines) + .map(|entry| { + let priority_style = match entry.priority { + socktop_connector::LogLevel::Error + | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), + socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), + socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { + Style::default().fg(Color::Blue) + } + _ => Style::default(), + }; + + let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time + let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority + let message = &entry.message[..entry.message.len().min(message_max_len)]; + + Line::from(vec![ + Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::styled( + format!("{:>7}", format!("{:?}", entry.priority)), + priority_style, + ), + Span::raw(" "), + Span::raw(message), + if entry.message.len() > message_max_len { + Span::styled("...", Style::default().add_modifier(Modifier::DIM)) + } else { + Span::raw("") + }, + ]) + }) + .collect() + }; + + let content = Paragraph::new(content_lines).block(journal_block); + + f.render_widget(content, area); + + // Render scrollbar if there are more entries than visible + if total_entries > visible_lines { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + let mut scrollbar_state = + ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { + if area.width < 20 || area.height < 10 { + // Area too small for meaningful plot + let content = Paragraph::new(vec![Line::from(Span::styled( + "Area too small for plot", + Style::default().fg(MODAL_HINT_FG), + ))]) + .alignment(Alignment::Center) + .style(Style::default().bg(MODAL_BG)); + f.render_widget(content, area); + return; + } + + // Calculate plot dimensions (leave space for axes labels + legend) + let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels + let plot_height = area.height.saturating_sub(6) as usize; // Leave space for legend (3 lines) + X-axis labels (2 lines) + title (1 line) + + if plot_width == 0 || plot_height == 0 { + return; + } + + // Create a 2D grid to represent the plot + let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; + + // Plot main process + let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let main_y = plot_height.saturating_sub(1).saturating_sub( + ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + if main_x < plot_width && main_y < plot_height { + plot_grid[main_y][main_x] = '●'; // Main process marker + } + + // Plot threads (use different marker) + for thread in ¶ms.process.threads { + let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; + let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; + + let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let thread_y = plot_height.saturating_sub(1).saturating_sub( + ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if thread_x < plot_width && thread_y < plot_height { + if plot_grid[thread_y][thread_x] == ' ' { + plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) + } else if plot_grid[thread_y][thread_x] == '○' { + plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point + } else { + plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point + } + } + } + + // Plot child processes + for child in ¶ms.process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + + let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let child_y = plot_height.saturating_sub(1).saturating_sub( + ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if child_x < plot_width && child_y < plot_height { + if plot_grid[child_y][child_x] == ' ' { + plot_grid[child_y][child_x] = '•'; // Child process marker + } else { + plot_grid[child_y][child_x] = '◉'; // Multiple items at same point + } + } + } + + // Render the plot + let mut lines = Vec::new(); + + // Add Y-axis labels and plot content + for (i, row) in plot_grid.iter().enumerate() { + let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); + // Always format with 4 characters width, right-aligned, to prevent axis shifting + let y_label = if y_value >= 100.0 { + format!("{y_value:>4.0}") + } else { + format!("{y_value:>4.1}") + }; + + let plot_content: String = row.iter().collect(); + + lines.push(Line::from(vec![ + Span::styled(y_label, Style::default()), + Span::styled(" │", Style::default()), + Span::styled(plot_content, Style::default()), + ])); + } + + // Add X-axis + let x_axis_padding = " ".to_string(); // Match Y-axis label width + let x_axis_line = "─".repeat(plot_width + 1); + lines.push(Line::from(vec![ + Span::styled(x_axis_padding, Style::default()), + Span::styled(x_axis_line, Style::default()), + ])); + + // Add X-axis labels + let x_label_start = "0.0".to_string(); + let x_label_mid = format!("{:.1}", params.max_user / 2.0); + let x_label_end = format!("{:.1}", params.max_user); + + let spacing = plot_width / 3; + let x_labels = format!( + " {}{}{}{}{}", + x_label_start, + " ".repeat(spacing.saturating_sub(x_label_start.len())), + x_label_mid, + " ".repeat(spacing.saturating_sub(x_label_mid.len())), + x_label_end + ); + + lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); + + // Add axis titles with better visibility + lines.push(Line::from(vec![Span::styled( + " User CPU Time (ms) →", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); + + // Add Y-axis label and legend at the top + lines.insert( + 0, + Line::from(vec![Span::styled( + "↑ System CPU Time (ms)", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]), + ); + lines.insert( + 1, + Line::from(vec![Span::styled( + "● Main ○ Thread • Child ◉ Multiple", + Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM), + )]), + ); + lines.insert(2, Line::from("")); // Spacing after legend + + let content = Paragraph::new(lines) + .style(Style::default()) + .alignment(Alignment::Left); + + f.render_widget(content, area); + } + + fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + self.render_loading_metadata(f, top_chunks[0]); + self.render_loading_scatter(f, top_chunks[1]); + } + + fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_loading_graphs(f, middle_chunks[0]); + self.render_loading_table(f, middle_chunks[1]); + self.render_loading_command(f, middle_chunks[2]); + } + + fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Info & CPU History") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading process metadata...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Thread CPU Time Distribution") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading CPU time data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { + let block = Block::default().title("Memory & I/O").borders(Borders::ALL); + + let content = Paragraph::new("Loading memory & I/O data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_table(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Child Processes") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading child process data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_command(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Command & Details") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading command details...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Journal Events") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading journal entries...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Details") + .borders(Borders::ALL); + + let content = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "⚠ Agent Update Required", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This agent version does not support per-process metrics.", + Style::default().add_modifier(Modifier::DIM), + )), + Line::from(Span::styled( + "Please update your socktop_agent to the latest version.", + Style::default().add_modifier(Modifier::DIM), + )), + ]) + .block(block) + .alignment(Alignment::Center); + + f.render_widget(content, area); + } + + fn render_top_row_with_sparkline( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(60), // CPU sparkline + Constraint::Percentage(40), // Thread scatter plot + ]) + .split(area); + + self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); + self.render_thread_scatter_plot(f, top_chunks[1], process); + } + + fn render_cpu_sparkline( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Normalize CPU to 0-100% by dividing by thread count + // This shows per-core utilization rather than total utilization across all cores + let thread_count = process.thread_count; + + // Calculate actual average and current (normalized to 0-100%) + let current_cpu = + normalize_cpu_usage(cpu_history.back().copied().unwrap_or(0.0), thread_count); + let avg_cpu = if cpu_history.is_empty() { + 0.0 + } else { + let total: f32 = cpu_history.iter().sum(); + normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) + }; + let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); + + // Similar to main CPU rendering but for process CPU + if cpu_history.len() < 2 { + let block = Block::default().title(title).borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let content = Paragraph::new("Collecting CPU history data...") + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + f.render_widget(content, inner); + return; + } + + let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels + let start = cpu_history.len().saturating_sub(max_points); + + // Create data points for the chart (normalized to 0-100%) + let data: Vec<(f64, f64)> = cpu_history + .iter() + .skip(start) + .enumerate() + .map(|(i, &val)| { + let normalized = normalize_cpu_usage(val, thread_count); + (i as f64, normalized as f64) + }) + .collect(); + + let datasets = vec![ + Dataset::default() + .name("CPU %") + .marker(ratatui::symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&data), + ]; + + let x_max = data.len().max(1) as f64; + + // Dynamic Y-axis scaling in 10% increments + let max_cpu = data.iter().map(|(_, y)| *y).fold(0.0f64, f64::max); + let y_max = calculate_dynamic_y_max(max_cpu); + + let y_labels = vec![ + Line::from("0%"), + Line::from(format!("{:.0}%", y_max / 2.0)), + Line::from(format!("{y_max:.0}%")), + ]; + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::ALL).title(title)) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .bounds([0.0, x_max]), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .labels(y_labels) + .bounds([0.0, y_max]), + ); + + f.render_widget(chart, area); + } + + fn render_middle_row_with_metadata( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_memory_io_graphs( + f, + middle_chunks[0], + process, + mem_history, + io_read_history, + io_write_history, + ); + self.render_thread_table(f, middle_chunks[1], process); + self.render_command_and_metadata(f, middle_chunks[2], process); + } + + fn render_command_and_metadata( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let details_block = Block::default() + .title("Command & Details") + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + + // Calculate uptime + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let uptime_secs = now.saturating_sub(process.start_time); + let uptime_str = format_uptime(uptime_secs); + + // Format CPU times + let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; + let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; + + let mut content_lines = vec![ + Line::from(vec![ + Span::styled( + "⚡ Status: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(&process.status), + ]), + Line::from(vec![ + Span::styled( + "⏱️ Uptime: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(uptime_str), + ]), + Line::from(vec![ + Span::styled( + "🧵 Threads: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", process.thread_count)), + ]), + Line::from(vec![ + Span::styled( + "👶 Children: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", process.child_processes.len())), + ]), + ]; + + // Add file descriptors if available + if let Some(fd_count) = process.fd_count { + content_lines.push(Line::from(vec![ + Span::styled( + "📁 FDs: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{fd_count}")), + ])); + } + + content_lines.push(Line::from("")); + + // Process hierarchy with clickable parent PID + if let Some(ppid) = process.parent_pid { + content_lines.push(Line::from(vec![ + Span::styled( + "👪 Parent: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{ppid}"), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + ), + Span::styled( + " [P]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::DIM), + ), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled("👤 UID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.user_id)), + Span::styled(" 👥 GID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.group_id)), + ])); + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "⏲️ CPU Time", + Style::default().add_modifier(Modifier::BOLD), + )])); + content_lines.push(Line::from(vec![ + Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{user_time_sec:.2}s")), + ])); + content_lines.push(Line::from(vec![ + Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{system_time_sec:.2}s")), + ])); + + content_lines.push(Line::from("")); + + // Executable path if available + if let Some(exe) = &process.executable_path { + content_lines.push(Line::from(vec![Span::styled( + "📂 Executable", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(6)) as usize; + if exe.len() > max_width { + let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(vec![Span::styled( + format!(" {truncated}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } else { + content_lines.push(Line::from(vec![Span::styled( + format!(" {exe}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + // Working directory if available + if let Some(cwd) = &process.working_directory { + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "📁 Working Dir", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(6)) as usize; + if cwd.len() > max_width { + let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(vec![Span::styled( + format!(" {truncated}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } else { + content_lines.push(Line::from(vec![Span::styled( + format!(" {cwd}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + content_lines.push(Line::from("")); + + // Add command line (wrap if needed) + content_lines.push(Line::from(vec![Span::styled( + "⚙️ Command", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Split command into multiple lines if too long + let cmd_text = &process.command; + let max_width = (area.width.saturating_sub(6)) as usize; // More conservative to avoid wrapping issues + if cmd_text.len() > max_width { + for chunk in cmd_text.as_bytes().chunks(max_width) { + if let Ok(s) = std::str::from_utf8(chunk) { + content_lines.push(Line::from(vec![Span::styled( + format!(" {s}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + } else { + content_lines.push(Line::from(vec![Span::styled( + format!(" {cmd_text}"), + Style::default().add_modifier(Modifier::DIM), + )])); + } + + let content = Paragraph::new(content_lines).block(details_block); + + f.render_widget(content, area); + } +} diff --git a/socktop/src/ui/modal_types.rs b/socktop/src/ui/modal_types.rs new file mode 100644 index 0000000..27af4aa --- /dev/null +++ b/socktop/src/ui/modal_types.rs @@ -0,0 +1,74 @@ +//! Type definitions for modal system + +use std::time::Instant; + +/// History data for process metrics rendering +pub struct ProcessHistoryData<'a> { + pub cpu: &'a std::collections::VecDeque, + pub mem: &'a std::collections::VecDeque, + pub io_read: &'a std::collections::VecDeque, + pub io_write: &'a std::collections::VecDeque, +} + +/// Process data for modal rendering +pub struct ProcessModalData<'a> { + pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, + pub journal: Option<&'a socktop_connector::JournalResponse>, + pub history: ProcessHistoryData<'a>, + pub unsupported: bool, +} + +/// Parameters for rendering scatter plot +pub(super) struct ScatterPlotParams<'a> { + pub process: &'a socktop_connector::DetailedProcessInfo, + pub main_user_ms: f64, + pub main_system_ms: f64, + pub max_user: f64, + pub max_system: f64, +} + +#[derive(Debug, Clone)] +pub enum ModalType { + ConnectionError { + message: String, + disconnected_at: Instant, + retry_count: u32, + auto_retry_countdown: Option, + }, + ProcessDetails { + pid: u32, + }, + #[allow(dead_code)] + Confirmation { + title: String, + message: String, + confirm_text: String, + cancel_text: String, + }, + #[allow(dead_code)] + Info { + title: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModalAction { + None, // Modal didn't handle the key, pass to main window + Handled, // Modal handled the key, don't pass to main window + RetryConnection, + ExitApp, + Confirm, + Cancel, + Dismiss, + SwitchToParentProcess(u32), // Switch to viewing parent process details +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModalButton { + Retry, + Exit, + Confirm, + Cancel, + Ok, +} diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 4cdc4ca..27e5b99 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -608,18 +608,13 @@ fn enumerate_child_processes_lightweight( // This is much faster than refresh_processes_specifics(All) if let Ok(entries) = fs::read_dir("/proc") { for entry in entries.flatten() { - if let Ok(file_name) = entry.file_name().into_string() { - if let Ok(pid) = file_name.parse::() { - // Check if this process is a child of our target - if let Some(child_parent_pid) = read_parent_pid_from_proc(pid) { - if child_parent_pid == parent_pid { - // Found a child! Collect its details from /proc - if let Some(child_info) = collect_process_info_from_proc(pid, system) { - children.push(child_info); - } - } - } - } + if let Ok(file_name) = entry.file_name().into_string() + && let Ok(pid) = file_name.parse::() + && let Some(child_parent_pid) = read_parent_pid_from_proc(pid) + && child_parent_pid == parent_pid + && let Some(child_info) = collect_process_info_from_proc(pid, system) + { + children.push(child_info); } } } @@ -673,10 +668,10 @@ fn collect_process_info_from_proc( if let Some(kb) = value.split_whitespace().next() { mem_bytes = kb.parse::().unwrap_or(0) * 1024; } - } else if let Some(value) = line.strip_prefix("VmSize:") { - if let Some(kb) = value.split_whitespace().next() { - virtual_mem_bytes = kb.parse::().unwrap_or(0) * 1024; - } + } else if let Some(value) = line.strip_prefix("VmSize:") + && let Some(kb) = value.split_whitespace().next() + { + virtual_mem_bytes = kb.parse::().unwrap_or(0) * 1024; } } diff --git a/socktop_agent/src/ws.rs b/socktop_agent/src/ws.rs index b7bfe16..872a6ae 100644 --- a/socktop_agent/src/ws.rs +++ b/socktop_agent/src/ws.rs @@ -114,87 +114,85 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { drop(cache); // Explicit drop to release mutex early } Message::Text(ref text) if text.starts_with("get_process_metrics:") => { - if let Some(pid_str) = text.strip_prefix("get_process_metrics:") { - if let Ok(pid) = pid_str.parse::() { - let ttl = std::time::Duration::from_millis(250); // 250ms TTL + if let Some(pid_str) = text.strip_prefix("get_process_metrics:") + && let Ok(pid) = pid_str.parse::() + { + let ttl = std::time::Duration::from_millis(250); // 250ms TTL - // Check cache first + // Check cache first + { + let cache = state.cache_process_metrics.lock().await; + if let Some(entry) = cache.get(&pid) + && entry.is_fresh(ttl) + && let Some(cached_response) = entry.get() { - let cache = state.cache_process_metrics.lock().await; - if let Some(entry) = cache.get(&pid) { - if entry.is_fresh(ttl) - && let Some(cached_response) = entry.get() - { - let _ = send_json(&mut socket, cached_response).await; - continue; - } - } + let _ = send_json(&mut socket, cached_response).await; + continue; } + } - // Collect fresh data - match crate::metrics::collect_process_metrics(pid, &state).await { - Ok(response) => { - // Cache the response - { - let mut cache = state.cache_process_metrics.lock().await; - cache - .entry(pid) - .or_insert_with(crate::state::CacheEntry::new) - .set(response.clone()); - } - let _ = send_json(&mut socket, &response).await; - } - Err(err) => { - let error_response = serde_json::json!({ - "error": err, - "request": "get_process_metrics", - "pid": pid - }); - let _ = send_json(&mut socket, &error_response).await; + // Collect fresh data + match crate::metrics::collect_process_metrics(pid, &state).await { + Ok(response) => { + // Cache the response + { + let mut cache = state.cache_process_metrics.lock().await; + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(response.clone()); } + let _ = send_json(&mut socket, &response).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": "get_process_metrics", + "pid": pid + }); + let _ = send_json(&mut socket, &error_response).await; } } } } Message::Text(ref text) if text.starts_with("get_journal_entries:") => { - if let Some(pid_str) = text.strip_prefix("get_journal_entries:") { - if let Ok(pid) = pid_str.parse::() { - let ttl = std::time::Duration::from_secs(1); // 1s TTL + if let Some(pid_str) = text.strip_prefix("get_journal_entries:") + && let Ok(pid) = pid_str.parse::() + { + let ttl = std::time::Duration::from_secs(1); // 1s TTL - // Check cache first + // Check cache first + { + let cache = state.cache_journal_entries.lock().await; + if let Some(entry) = cache.get(&pid) + && entry.is_fresh(ttl) + && let Some(cached_response) = entry.get() { - let cache = state.cache_journal_entries.lock().await; - if let Some(entry) = cache.get(&pid) { - if entry.is_fresh(ttl) - && let Some(cached_response) = entry.get() - { - let _ = send_json(&mut socket, cached_response).await; - continue; - } - } + let _ = send_json(&mut socket, cached_response).await; + continue; } + } - // Collect fresh data - match crate::metrics::collect_journal_entries(pid) { - Ok(response) => { - // Cache the response - { - let mut cache = state.cache_journal_entries.lock().await; - cache - .entry(pid) - .or_insert_with(crate::state::CacheEntry::new) - .set(response.clone()); - } - let _ = send_json(&mut socket, &response).await; - } - Err(err) => { - let error_response = serde_json::json!({ - "error": err, - "request": "get_journal_entries", - "pid": pid - }); - let _ = send_json(&mut socket, &error_response).await; + // Collect fresh data + match crate::metrics::collect_journal_entries(pid) { + Ok(response) => { + // Cache the response + { + let mut cache = state.cache_journal_entries.lock().await; + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(response.clone()); } + let _ = send_json(&mut socket, &response).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": "get_journal_entries", + "pid": pid + }); + let _ = send_json(&mut socket, &error_response).await; } } } From f4b54db399604f73095025de6e7eca8892634c7a Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 09:50:38 -0700 Subject: [PATCH 03/19] fix for windows build error. --- socktop_agent/src/metrics.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 27e5b99..a4bd9e8 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -1,5 +1,7 @@ //! Metrics collection using sysinfo for socktop_agent. +use std::fs; + use crate::gpu::collect_all_gpus; use crate::state::AppState; use crate::types::{ @@ -10,8 +12,6 @@ use once_cell::sync::OnceCell; #[cfg(target_os = "linux")] use std::collections::HashMap; #[cfg(target_os = "linux")] -use std::fs; -#[cfg(target_os = "linux")] use std::io; use std::process::Command; use std::sync::Mutex; @@ -979,8 +979,9 @@ pub async fn collect_process_metrics( let start_time = process.start_time(); // Read UID and GID directly from /proc/{pid}/status for accuracy + #[cfg(target_os = "linux")] let (user_id, group_id) = - if let Ok(status_content) = fs::read_to_string(format!("/proc/{pid}/status")) { + if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) { let mut uid = 0u32; let mut gid = 0u32; @@ -1002,15 +1003,19 @@ pub async fn collect_process_metrics( (uid, gid) } else { - // Fallback if /proc read fails (non-Linux or permission issue) + // Fallback if /proc read fails (permission issue) (0, 0) }; + + #[cfg(not(target_os = "linux"))] + let (user_id, group_id) = (0, 0); // Read I/O stats directly from /proc/{pid}/io // Use rchar/wchar to capture ALL I/O including cached reads (like htop/btop do) // sysinfo's total_read_bytes/total_written_bytes only count actual disk I/O + #[cfg(target_os = "linux")] let (read_bytes, write_bytes) = - if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) { + if let Ok(io_content) = std::fs::read_to_string(format!("/proc/{pid}/io")) { let mut rchar = 0u64; let mut wchar = 0u64; From 70a150152cb6ada7ec74b75eaf16af46bb71e48d Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 09:51:11 -0700 Subject: [PATCH 04/19] fix for windows build error --- socktop_agent/src/metrics.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index a4bd9e8..c818e7c 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -1006,7 +1006,7 @@ pub async fn collect_process_metrics( // Fallback if /proc read fails (permission issue) (0, 0) }; - + #[cfg(not(target_os = "linux"))] let (user_id, group_id) = (0, 0); @@ -1029,13 +1029,23 @@ pub async fn collect_process_metrics( (Some(rchar), Some(wchar)) } else { - // Fallback to sysinfo if we can't read /proc (permissions, non-Linux, etc.) + // Fallback to sysinfo if we can't read /proc (permissions) let disk_usage = process.disk_usage(); ( Some(disk_usage.total_read_bytes), Some(disk_usage.total_written_bytes), ) }; + + #[cfg(not(target_os = "linux"))] + let (read_bytes, write_bytes) = { + let disk_usage = process.disk_usage(); + ( + Some(disk_usage.total_read_bytes), + Some(disk_usage.total_written_bytes), + ) + }; + let working_directory = process.cwd().map(|p| p.to_string_lossy().to_string()); let executable_path = process.exe().map(|p| p.to_string_lossy().to_string()); From 0210b49219e2f04a59d63e8fa97dfa5cb4ad9c73 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 09:52:36 -0700 Subject: [PATCH 05/19] cargo fmt --- socktop_agent/src/metrics.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index c818e7c..70b368c 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -1036,7 +1036,7 @@ pub async fn collect_process_metrics( Some(disk_usage.total_written_bytes), ) }; - + #[cfg(not(target_os = "linux"))] let (read_bytes, write_bytes) = { let disk_usage = process.disk_usage(); @@ -1045,7 +1045,7 @@ pub async fn collect_process_metrics( Some(disk_usage.total_written_bytes), ) }; - + let working_directory = process.cwd().map(|p| p.to_string_lossy().to_string()); let executable_path = process.exe().map(|p| p.to_string_lossy().to_string()); From 47eff3a75cfc7234b822193a60e250c63b856430 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 10:01:40 -0700 Subject: [PATCH 06/19] remove unused import. / clippy cleanup --- socktop_agent/src/metrics.rs | 80 ++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 70b368c..ef190cb 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -1,7 +1,5 @@ //! Metrics collection using sysinfo for socktop_agent. -use std::fs; - use crate::gpu::collect_all_gpus; use crate::state::AppState; use crate::types::{ @@ -12,6 +10,8 @@ use once_cell::sync::OnceCell; #[cfg(target_os = "linux")] use std::collections::HashMap; #[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] use std::io; use std::process::Command; use std::sync::Mutex; @@ -802,44 +802,44 @@ fn enumerate_child_processes_lightweight( // On non-Linux, we have to iterate through all processes in sysinfo // This is less efficient but maintains cross-platform compatibility for (child_pid, child_process) in system.processes() { - if let Some(parent) = child_process.parent() { - if parent.as_u32() == parent_pid { - let child_info = DetailedProcessInfo { - pid: child_pid.as_u32(), - name: child_process.name().to_string_lossy().to_string(), - command: child_process - .cmd() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect::>() - .join(" "), - cpu_usage: child_process.cpu_usage(), - mem_bytes: child_process.memory(), - virtual_mem_bytes: child_process.virtual_memory(), - shared_mem_bytes: None, - thread_count: child_process - .tasks() - .map(|tasks| tasks.len() as u32) - .unwrap_or(0), - fd_count: None, - status: format!("{:?}", child_process.status()), - parent_pid: Some(parent_pid), - // On non-Linux platforms, sysinfo UID/GID might not be accurate - // Just use 0 as placeholder since we can't read /proc - user_id: 0, - group_id: 0, - start_time: child_process.start_time(), - cpu_time_user: 0, // Not available on non-Linux in our implementation - cpu_time_system: 0, - read_bytes: Some(child_process.disk_usage().read_bytes), - write_bytes: Some(child_process.disk_usage().written_bytes), - working_directory: child_process.cwd().map(|p| p.to_string_lossy().to_string()), - executable_path: child_process.exe().map(|p| p.to_string_lossy().to_string()), - child_processes: Vec::new(), - threads: Vec::new(), // Not collected for non-Linux - }; - children.push(child_info); - } + if let Some(parent) = child_process.parent() + && parent.as_u32() == parent_pid + { + let child_info = DetailedProcessInfo { + pid: child_pid.as_u32(), + name: child_process.name().to_string_lossy().to_string(), + command: child_process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>() + .join(" "), + cpu_usage: child_process.cpu_usage(), + mem_bytes: child_process.memory(), + virtual_mem_bytes: child_process.virtual_memory(), + shared_mem_bytes: None, + thread_count: child_process + .tasks() + .map(|tasks| tasks.len() as u32) + .unwrap_or(0), + fd_count: None, + status: format!("{:?}", child_process.status()), + parent_pid: Some(parent_pid), + // On non-Linux platforms, sysinfo UID/GID might not be accurate + // Just use 0 as placeholder since we can't read /proc + user_id: 0, + group_id: 0, + start_time: child_process.start_time(), + cpu_time_user: 0, // Not available on non-Linux in our implementation + cpu_time_system: 0, + read_bytes: Some(child_process.disk_usage().read_bytes), + write_bytes: Some(child_process.disk_usage().written_bytes), + working_directory: child_process.cwd().map(|p| p.to_string_lossy().to_string()), + executable_path: child_process.exe().map(|p| p.to_string_lossy().to_string()), + child_processes: Vec::new(), + threads: Vec::new(), // Not collected for non-Linux + }; + children.push(child_info); } } From 34e260a612ed3a9c1a2f9993a5201fc5bdb3d692 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 10:30:55 -0700 Subject: [PATCH 07/19] feat: disk section enhancements - temperature, partition indentation, duplicate filtering --- socktop/src/ui/disks.rs | 22 ++++++++++++-- socktop_agent/src/metrics.rs | 52 +++++++++++++++++++++++++++++++--- socktop_agent/src/types.rs | 2 ++ socktop_connector/src/types.rs | 2 ++ 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/socktop/src/ui/disks.rs b/socktop/src/ui/disks.rs index d549b87..c1d2bb1 100644 --- a/socktop/src/ui/disks.rs +++ b/socktop/src/ui/disks.rs @@ -24,8 +24,14 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { return; } + // Filter duplicates by keeping first occurrence of each unique name + let mut seen_names = std::collections::HashSet::new(); + let unique_disks: Vec<_> = mm.disks.iter() + .filter(|d| seen_names.insert(d.name.clone())) + .collect(); + let per_disk_h = 3u16; - let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize; + let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize; let constraints: Vec = (0..max_cards) .map(|_| Constraint::Length(per_disk_h)) @@ -36,7 +42,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { .split(inner); for (i, slot) in rows.iter().enumerate() { - let d = &mm.disks[i]; + let d = unique_disks[i]; let used = d.total.saturating_sub(d.available); let ratio = if d.total > 0 { used as f64 / d.total as f64 @@ -53,10 +59,20 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { ratatui::style::Color::Red }; + // Add indentation for partitions + let indent = if d.is_partition { " └─" } else { "" }; + + // Add temperature if available + let temp_str = d.temperature + .map(|t| format!(" {}°C", t.round() as i32)) + .unwrap_or_default(); + let title = format!( - "{} {} {} / {} ({}%)", + "{}{} {}{} {} / {} ({}%)", + indent, disk_icon(&d.name), truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2), + temp_str, human(used), human(d.total), pct diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index ef190cb..50f4c40 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -331,12 +331,56 @@ pub async fn collect_disks(state: &AppState) -> Vec { } let mut disks_list = state.disks.lock().await; disks_list.refresh(false); // don't drop missing disks + + // Collect disk temperatures from components + let disk_temps = { + let mut components = state.components.lock().await; + components.refresh(false); + let mut temps = std::collections::HashMap::new(); + for c in components.iter() { + let label = c.label().to_ascii_lowercase(); + // Match common disk/SSD/NVMe sensor labels + if label.contains("nvme") || label.contains("ssd") || label.contains("disk") { + if let Some(temp) = c.temperature() { + // Extract device name from label (e.g., "nvme0" from "Composite nvme0") + for part in label.split_whitespace() { + if part.starts_with("nvme") || part.starts_with("sd") { + temps.insert(part.to_string(), temp); + } + } + } + } + } + temps + }; + let disks: Vec = disks_list .iter() - .map(|d| DiskInfo { - name: d.name().to_string_lossy().into_owned(), - total: d.total_space(), - available: d.available_space(), + .map(|d| { + let name = d.name().to_string_lossy().into_owned(); + // Determine if this is a partition (contains 'p' followed by digit, or just digit after device name) + let is_partition = name.contains("p1") || name.contains("p2") || name.contains("p3") + || name.ends_with('1') || name.ends_with('2') || name.ends_with('3') + || name.ends_with('4') || name.ends_with('5') || name.ends_with('6') + || name.ends_with('7') || name.ends_with('8') || name.ends_with('9'); + + // Try to find temperature for this disk + let temperature = disk_temps.iter() + .find_map(|(key, &temp)| { + if name.starts_with(key) { + Some(temp) + } else { + None + } + }); + + DiskInfo { + name, + total: d.total_space(), + available: d.available_space(), + temperature, + is_partition, + } }) .collect(); { diff --git a/socktop_agent/src/types.rs b/socktop_agent/src/types.rs index 4b7d37c..75e7d97 100644 --- a/socktop_agent/src/types.rs +++ b/socktop_agent/src/types.rs @@ -9,6 +9,8 @@ pub struct DiskInfo { pub name: String, pub total: u64, pub available: u64, + pub temperature: Option, + pub is_partition: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/socktop_connector/src/types.rs b/socktop_connector/src/types.rs index 125e11c..84fdfa9 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -15,6 +15,8 @@ pub struct DiskInfo { pub name: String, pub total: u64, pub available: u64, + pub temperature: Option, + pub is_partition: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] From 689498c5f436c649d73204d542b7f996d9b82d74 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 10:46:51 -0700 Subject: [PATCH 08/19] fix: show parent disks with aggregated partition stats --- socktop/src/ui/disks.rs | 9 ++- socktop_agent/src/metrics.rs | 142 +++++++++++++++++++++++++++++------ 2 files changed, 123 insertions(+), 28 deletions(-) diff --git a/socktop/src/ui/disks.rs b/socktop/src/ui/disks.rs index c1d2bb1..2d3ac61 100644 --- a/socktop/src/ui/disks.rs +++ b/socktop/src/ui/disks.rs @@ -26,7 +26,9 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { // Filter duplicates by keeping first occurrence of each unique name let mut seen_names = std::collections::HashSet::new(); - let unique_disks: Vec<_> = mm.disks.iter() + let unique_disks: Vec<_> = mm + .disks + .iter() .filter(|d| seen_names.insert(d.name.clone())) .collect(); @@ -61,9 +63,10 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { // Add indentation for partitions let indent = if d.is_partition { " └─" } else { "" }; - + // Add temperature if available - let temp_str = d.temperature + let temp_str = d + .temperature .map(|t| format!(" {}°C", t.round() as i32)) .unwrap_or_default(); diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 50f4c40..96fef3a 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -331,7 +331,7 @@ pub async fn collect_disks(state: &AppState) -> Vec { } let mut disks_list = state.disks.lock().await; disks_list.refresh(false); // don't drop missing disks - + // Collect disk temperatures from components let disk_temps = { let mut components = state.components.lock().await; @@ -340,40 +340,48 @@ pub async fn collect_disks(state: &AppState) -> Vec { for c in components.iter() { let label = c.label().to_ascii_lowercase(); // Match common disk/SSD/NVMe sensor labels - if label.contains("nvme") || label.contains("ssd") || label.contains("disk") { - if let Some(temp) = c.temperature() { - // Extract device name from label (e.g., "nvme0" from "Composite nvme0") - for part in label.split_whitespace() { - if part.starts_with("nvme") || part.starts_with("sd") { - temps.insert(part.to_string(), temp); - } + if (label.contains("nvme") || label.contains("ssd") || label.contains("disk")) + && let Some(temp) = c.temperature() + { + // Extract device name from label (e.g., "nvme0" from "Composite nvme0") + for part in label.split_whitespace() { + if part.starts_with("nvme") || part.starts_with("sd") { + temps.insert(part.to_string(), temp); } } } } temps }; - - let disks: Vec = disks_list + + // First collect all partitions from sysinfo + let partitions: Vec = disks_list .iter() .map(|d| { let name = d.name().to_string_lossy().into_owned(); - // Determine if this is a partition (contains 'p' followed by digit, or just digit after device name) - let is_partition = name.contains("p1") || name.contains("p2") || name.contains("p3") - || name.ends_with('1') || name.ends_with('2') || name.ends_with('3') - || name.ends_with('4') || name.ends_with('5') || name.ends_with('6') - || name.ends_with('7') || name.ends_with('8') || name.ends_with('9'); - + // Determine if this is a partition + let is_partition = name.contains("p1") + || name.contains("p2") + || name.contains("p3") + || name.ends_with('1') + || name.ends_with('2') + || name.ends_with('3') + || name.ends_with('4') + || name.ends_with('5') + || name.ends_with('6') + || name.ends_with('7') + || name.ends_with('8') + || name.ends_with('9'); + // Try to find temperature for this disk - let temperature = disk_temps.iter() - .find_map(|(key, &temp)| { - if name.starts_with(key) { - Some(temp) - } else { - None - } - }); - + let temperature = disk_temps.iter().find_map(|(key, &temp)| { + if name.starts_with(key) { + Some(temp) + } else { + None + } + }); + DiskInfo { name, total: d.total_space(), @@ -383,6 +391,90 @@ pub async fn collect_disks(state: &AppState) -> Vec { } }) .collect(); + + // Now create parent disk entries by aggregating partition data + let mut parent_disks: std::collections::HashMap)> = + std::collections::HashMap::new(); + + for partition in &partitions { + if partition.is_partition { + // Extract parent disk name + // nvme0n1p1 -> nvme0n1, sda1 -> sda, mmcblk0p1 -> mmcblk0 + let parent_name = if let Some(pos) = partition.name.rfind('p') { + // Check if character after 'p' is a digit + if partition.name.chars().nth(pos + 1).is_some_and(|c| c.is_ascii_digit()) { + &partition.name[..pos] + } else { + // Handle sda1, sdb2, etc (just trim trailing digit) + partition.name.trim_end_matches(char::is_numeric) + } + } else { + // Handle sda1, sdb2, etc (just trim trailing digit) + partition.name.trim_end_matches(char::is_numeric) + }; + + // Aggregate partition stats into parent + let entry = parent_disks + .entry(parent_name.to_string()) + .or_insert((0, 0, partition.temperature)); + entry.0 += partition.total; + entry.1 += partition.available; + // Keep temperature if any partition has it + if entry.2.is_none() { + entry.2 = partition.temperature; + } + } + } + + // Create parent disk entries + let mut disks: Vec = parent_disks + .into_iter() + .map(|(name, (total, available, temperature))| DiskInfo { + name, + total, + available, + temperature, + is_partition: false, + }) + .collect(); + + // Sort parent disks by name + disks.sort_by(|a, b| a.name.cmp(&b.name)); + + // Add partitions after their parent disk + for partition in partitions { + if partition.is_partition { + // Find parent disk index + let parent_name = if let Some(pos) = partition.name.rfind('p') { + if partition.name.chars().nth(pos + 1).is_some_and(|c| c.is_ascii_digit()) { + &partition.name[..pos] + } else { + partition.name.trim_end_matches(char::is_numeric) + } + } else { + partition.name.trim_end_matches(char::is_numeric) + }; + + // Find where to insert this partition (after its parent) + if let Some(parent_idx) = disks.iter().position(|d| d.name == parent_name) { + // Insert after parent and any existing partitions of that parent + let mut insert_idx = parent_idx + 1; + while insert_idx < disks.len() + && disks[insert_idx].is_partition + && disks[insert_idx].name.starts_with(parent_name) + { + insert_idx += 1; + } + disks.insert(insert_idx, partition); + } else { + // Parent not found (shouldn't happen), just add at end + disks.push(partition); + } + } else { + // Not a partition (e.g., zram0), add at end + disks.push(partition); + } + } { let mut cache = state.cache_disks.lock().await; cache.set(disks.clone()); From bd0d15a1ae0c3a85f1723648a49b5258cdd3d75c Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 10:52:44 -0700 Subject: [PATCH 09/19] fix: correct disk size aggregation and nvme temperature detection --- socktop_agent/src/metrics.rs | 62 ++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 96fef3a..5fa78da 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -333,32 +333,42 @@ pub async fn collect_disks(state: &AppState) -> Vec { disks_list.refresh(false); // don't drop missing disks // Collect disk temperatures from components + // NVMe temps show up as "Composite" under different chip names let disk_temps = { let mut components = state.components.lock().await; components.refresh(false); - let mut temps = std::collections::HashMap::new(); + let mut composite_temps = Vec::new(); + for c in components.iter() { let label = c.label().to_ascii_lowercase(); - // Match common disk/SSD/NVMe sensor labels - if (label.contains("nvme") || label.contains("ssd") || label.contains("disk")) - && let Some(temp) = c.temperature() - { - // Extract device name from label (e.g., "nvme0" from "Composite nvme0") - for part in label.split_whitespace() { - if part.starts_with("nvme") || part.starts_with("sd") { - temps.insert(part.to_string(), temp); - } - } + + // Collect all "Composite" temperatures (these are NVMe drives) + if label == "composite" && let Some(temp) = c.temperature() { + composite_temps.push(temp); } } + + // Store composite temps indexed by their order (nvme0n1, nvme1n1, nvme2n1, etc.) + let mut temps = std::collections::HashMap::new(); + for (idx, temp) in composite_temps.iter().enumerate() { + temps.insert(format!("nvme{}n1", idx), *temp); + } temps }; - // First collect all partitions from sysinfo + // First collect all partitions from sysinfo, deduplicating by device name + // (same partition can be mounted at multiple mount points) + let mut seen_partitions = std::collections::HashSet::new(); let partitions: Vec = disks_list .iter() - .map(|d| { + .filter_map(|d| { let name = d.name().to_string_lossy().into_owned(); + + // Skip if we've already seen this partition/device + if !seen_partitions.insert(name.clone()) { + return None; + } + // Determine if this is a partition let is_partition = name.contains("p1") || name.contains("p2") @@ -382,13 +392,13 @@ pub async fn collect_disks(state: &AppState) -> Vec { } }); - DiskInfo { + Some(DiskInfo { name, total: d.total_space(), available: d.available_space(), temperature, is_partition, - } + }) }) .collect(); @@ -402,7 +412,12 @@ pub async fn collect_disks(state: &AppState) -> Vec { // nvme0n1p1 -> nvme0n1, sda1 -> sda, mmcblk0p1 -> mmcblk0 let parent_name = if let Some(pos) = partition.name.rfind('p') { // Check if character after 'p' is a digit - if partition.name.chars().nth(pos + 1).is_some_and(|c| c.is_ascii_digit()) { + if partition + .name + .chars() + .nth(pos + 1) + .is_some_and(|c| c.is_ascii_digit()) + { &partition.name[..pos] } else { // Handle sda1, sdb2, etc (just trim trailing digit) @@ -414,9 +429,11 @@ pub async fn collect_disks(state: &AppState) -> Vec { }; // Aggregate partition stats into parent - let entry = parent_disks - .entry(parent_name.to_string()) - .or_insert((0, 0, partition.temperature)); + let entry = parent_disks.entry(parent_name.to_string()).or_insert(( + 0, + 0, + partition.temperature, + )); entry.0 += partition.total; entry.1 += partition.available; // Keep temperature if any partition has it @@ -446,7 +463,12 @@ pub async fn collect_disks(state: &AppState) -> Vec { if partition.is_partition { // Find parent disk index let parent_name = if let Some(pos) = partition.name.rfind('p') { - if partition.name.chars().nth(pos + 1).is_some_and(|c| c.is_ascii_digit()) { + if partition + .name + .chars() + .nth(pos + 1) + .is_some_and(|c| c.is_ascii_digit()) + { &partition.name[..pos] } else { partition.name.trim_end_matches(char::is_numeric) From bae2ecb79a401493a35d11433994ba3ea39eb6cb Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 11:06:30 -0700 Subject: [PATCH 10/19] fix: lookup temperature for parent disk, not partition --- socktop_agent/src/metrics.rs | 40 +++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 5fa78da..8b3c09d 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -338,21 +338,27 @@ pub async fn collect_disks(state: &AppState) -> Vec { let mut components = state.components.lock().await; components.refresh(false); let mut composite_temps = Vec::new(); - + for c in components.iter() { let label = c.label().to_ascii_lowercase(); - + // Collect all "Composite" temperatures (these are NVMe drives) - if label == "composite" && let Some(temp) = c.temperature() { + if label == "composite" + && let Some(temp) = c.temperature() + { + tracing::debug!("Found Composite temp: {}°C", temp); composite_temps.push(temp); } } - + // Store composite temps indexed by their order (nvme0n1, nvme1n1, nvme2n1, etc.) let mut temps = std::collections::HashMap::new(); for (idx, temp) in composite_temps.iter().enumerate() { - temps.insert(format!("nvme{}n1", idx), *temp); + let key = format!("nvme{}n1", idx); + tracing::debug!("Mapping {} -> {}°C", key, temp); + temps.insert(key, *temp); } + tracing::debug!("Final disk_temps map: {:?}", temps); temps }; @@ -363,12 +369,12 @@ pub async fn collect_disks(state: &AppState) -> Vec { .iter() .filter_map(|d| { let name = d.name().to_string_lossy().into_owned(); - + // Skip if we've already seen this partition/device if !seen_partitions.insert(name.clone()) { return None; } - + // Determine if this is a partition let is_partition = name.contains("p1") || name.contains("p2") @@ -386,11 +392,16 @@ pub async fn collect_disks(state: &AppState) -> Vec { // Try to find temperature for this disk let temperature = disk_temps.iter().find_map(|(key, &temp)| { if name.starts_with(key) { + tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp); Some(temp) } else { None } }); + + if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") { + tracing::debug!("No temperature found for disk: {}", name); + } Some(DiskInfo { name, @@ -428,17 +439,26 @@ pub async fn collect_disks(state: &AppState) -> Vec { partition.name.trim_end_matches(char::is_numeric) }; + // Look up temperature for the PARENT disk, not the partition + let parent_temp = disk_temps.iter().find_map(|(key, &temp)| { + if parent_name.starts_with(key) { + Some(temp) + } else { + None + } + }); + // Aggregate partition stats into parent let entry = parent_disks.entry(parent_name.to_string()).or_insert(( 0, 0, - partition.temperature, + parent_temp, )); entry.0 += partition.total; entry.1 += partition.available; - // Keep temperature if any partition has it + // Keep temperature if any partition has it (or if we just found one) if entry.2.is_none() { - entry.2 = partition.temperature; + entry.2 = parent_temp; } } } From 47e96c7d923cbce5039a2894b9cbe27f0207ec2e Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 11:15:36 -0700 Subject: [PATCH 11/19] fix: refresh component values to collect NVMe temperatures --- socktop_agent/src/metrics.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index 8b3c09d..d74e48b 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -336,7 +336,7 @@ pub async fn collect_disks(state: &AppState) -> Vec { // NVMe temps show up as "Composite" under different chip names let disk_temps = { let mut components = state.components.lock().await; - components.refresh(false); + components.refresh(true); // true = refresh values, not just the list let mut composite_temps = Vec::new(); for c in components.iter() { @@ -398,7 +398,7 @@ pub async fn collect_disks(state: &AppState) -> Vec { None } }); - + if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") { tracing::debug!("No temperature found for disk: {}", name); } @@ -449,11 +449,9 @@ pub async fn collect_disks(state: &AppState) -> Vec { }); // Aggregate partition stats into parent - let entry = parent_disks.entry(parent_name.to_string()).or_insert(( - 0, - 0, - parent_temp, - )); + let entry = parent_disks + .entry(parent_name.to_string()) + .or_insert((0, 0, parent_temp)); entry.0 += partition.total; entry.1 += partition.available; // Keep temperature if any partition has it (or if we just found one) From 4196066e576d340e1d0028eb5b50a93c37ff8fc6 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 11:40:49 -0700 Subject: [PATCH 12/19] fix: NVMe temperature detection - contains() check and /dev/ prefix --- socktop_agent/src/metrics.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index d74e48b..0940da1 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -336,14 +336,16 @@ pub async fn collect_disks(state: &AppState) -> Vec { // NVMe temps show up as "Composite" under different chip names let disk_temps = { let mut components = state.components.lock().await; - components.refresh(true); // true = refresh values, not just the list + components.refresh(true); // true = refresh values, not just the list + let mut composite_temps = Vec::new(); for c in components.iter() { let label = c.label().to_ascii_lowercase(); // Collect all "Composite" temperatures (these are NVMe drives) - if label == "composite" + // Labels are like "nvme Composite CT1000N7BSS503" or "nvme Composite Sabrent Rocket 4.0" + if label.contains("composite") && let Some(temp) = c.temperature() { tracing::debug!("Found Composite temp: {}°C", temp); @@ -440,8 +442,10 @@ pub async fn collect_disks(state: &AppState) -> Vec { }; // Look up temperature for the PARENT disk, not the partition + // Strip /dev/ prefix if present for matching + let parent_name_for_match = parent_name.strip_prefix("/dev/").unwrap_or(parent_name); let parent_temp = disk_temps.iter().find_map(|(key, &temp)| { - if parent_name.starts_with(key) { + if parent_name_for_match.starts_with(key) { Some(temp) } else { None From 1cb05d404b75c2bf1d760091cc56a76f80a50ae5 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 11:43:58 -0700 Subject: [PATCH 13/19] fix: add backward compatibility for DiskInfo fields --- socktop_connector/src/types.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/socktop_connector/src/types.rs b/socktop_connector/src/types.rs index 84fdfa9..47df1c0 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -15,7 +15,9 @@ pub struct DiskInfo { pub name: String, pub total: u64, pub available: u64, + #[serde(default)] pub temperature: Option, + #[serde(default)] pub is_partition: bool, } From e51cdb0c50bd4e6ecfdf18c561ac9399313aef22 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 6 Oct 2025 12:05:12 -0700 Subject: [PATCH 14/19] display tweaks make it more pretty --- socktop/src/ui/disks.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/socktop/src/ui/disks.rs b/socktop/src/ui/disks.rs index 2d3ac61..963cdf0 100644 --- a/socktop/src/ui/disks.rs +++ b/socktop/src/ui/disks.rs @@ -62,7 +62,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { }; // Add indentation for partitions - let indent = if d.is_partition { " └─" } else { "" }; + let indent = if d.is_partition { "└─" } else { "" }; // Add temperature if available let temp_str = d @@ -71,7 +71,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { .unwrap_or_default(); let title = format!( - "{}{} {}{} {} / {} ({}%)", + "{}{}{}{} {} / {} ({}%)", indent, disk_icon(&d.name), truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2), @@ -81,14 +81,23 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { pct ); + // Indent the entire card (block) for partitions to align with └─ prefix (4 chars) + let card_indent = if d.is_partition { 4 } else { 0 }; + let card_rect = Rect { + x: slot.x + card_indent, + y: slot.y, + width: slot.width.saturating_sub(card_indent), + height: slot.height, + }; + let card = Block::default().borders(Borders::ALL).title(title); - f.render_widget(card, *slot); + f.render_widget(card, card_rect); let inner_card = Rect { - x: slot.x + 1, - y: slot.y + 1, - width: slot.width.saturating_sub(2), - height: slot.height.saturating_sub(2), + x: card_rect.x + 1, + y: card_rect.y + 1, + width: card_rect.width.saturating_sub(2), + height: card_rect.height.saturating_sub(2), }; if inner_card.height == 0 { continue; From 25632f3427a8059cd36242915492a605ea930d25 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 26 Oct 2025 02:16:42 -0700 Subject: [PATCH 15/19] Add About modal with sock ASCII art --- socktop/src/app.rs | 9 ++- socktop/src/ui/header.rs | 2 +- socktop/src/ui/modal.rs | 104 ++++++++++++++++++++++++++++++++++ socktop/src/ui/modal_types.rs | 1 + 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 7609ddd..36e750b 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -614,7 +614,8 @@ impl App { { self.clear_process_details(); } - // Modal was dismissed, continue to normal processing + // Modal was dismissed, skip normal key processing + continue; } ModalAction::Confirm => { // Handle confirmation action here if needed in the future @@ -654,6 +655,12 @@ impl App { ) { self.should_quit = true; } + + // Show About modal on 'a' or 'A' + if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) { + self.modal_manager.push_modal(ModalType::About); + } + // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index 9e81674..6987176 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -46,7 +46,7 @@ pub fn draw_header( parts.push(tok_txt.into()); } parts.push(intervals); - parts.push("(q to quit)".into()); + parts.push("(a: about, q: quit)".into()); let title = parts.join(" | "); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); } diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index a3233e0..f767482 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -55,6 +55,7 @@ impl ModalManager { self.journal_scroll_max = 0; ModalButton::Ok } + Some(ModalType::About) => ModalButton::Ok, Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Info { .. }) => ModalButton::Ok, None => ModalButton::Ok, @@ -66,6 +67,7 @@ impl ModalManager { self.active_button = match next { ModalType::ConnectionError { .. } => ModalButton::Retry, ModalType::ProcessDetails { .. } => ModalButton::Ok, + ModalType::About => ModalButton::Ok, ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Info { .. } => ModalButton::Ok, }; @@ -205,6 +207,10 @@ impl ModalManager { self.pop_modal(); ModalAction::Dismiss } + (Some(ModalType::About), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Info { .. }), ModalButton::Ok) => { @@ -253,6 +259,10 @@ impl ModalManager { // Process details modal uses almost full screen (95% width, 90% height) self.centered_rect(95, 90, area) } + ModalType::About => { + // About modal uses medium size + self.centered_rect(60, 60, area) + } _ => { // Other modals use smaller size self.centered_rect(70, 50, area) @@ -276,6 +286,7 @@ impl ModalManager { ModalType::ProcessDetails { pid } => { self.render_process_details(f, modal_area, *pid, data) } + ModalType::About => self.render_about(f, modal_area), ModalType::Confirmation { title, message, @@ -378,6 +389,99 @@ impl ModalManager { ); } + fn render_about(&self, f: &mut Frame, area: Rect) { + const ASCII_ART: &str = r#" +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀ +⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀ +⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀ +⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀ +⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + +███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗ +██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ +███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝ +╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝ +███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ +╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ +"#; + + let version = env!("CARGO_PKG_VERSION"); + + let about_text = format!( + "{}\n\ + Version {}\n\ + \n\ + A terminal first remote monitoring tool\n\ + \n\ + Website: https://socktop.io\n\ + GitHub: https://github.com/jasonwitty/socktop\n\ + \n\ + License: MIT License\n\ + \n\ + Created by Jason Witty\n\ + jasonpwitty+socktop@proton.me", + ASCII_ART, version + ); + + // Render the border block + let block = Block::default() + .title(" About socktop ") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black).fg(Color::Green)); + f.render_widget(block, area); + + // Calculate inner area manually to avoid any parent styling + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(3), // Leave room for button at bottom + }; + + // Render content area with explicit black background + f.render_widget( + Paragraph::new(about_text) + .style(Style::default().fg(Color::Cyan).bg(Color::Black)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }), + inner_area, + ); + + // Button area + let button_area = Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(3), + width: area.width.saturating_sub(2), + height: 1, + }; + + let ok_style = if self.active_button == ModalButton::Ok { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue).bg(Color::Black) + }; + + f.render_widget( + Paragraph::new("[ Enter ] Close") + .style(ok_style) + .alignment(Alignment::Center), + button_area, + ); + } + fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { let vert = Layout::default() .direction(Direction::Vertical) diff --git a/socktop/src/ui/modal_types.rs b/socktop/src/ui/modal_types.rs index 27af4aa..1c6de20 100644 --- a/socktop/src/ui/modal_types.rs +++ b/socktop/src/ui/modal_types.rs @@ -38,6 +38,7 @@ pub enum ModalType { ProcessDetails { pid: u32, }, + About, #[allow(dead_code)] Confirmation { title: String, From 0603746d7c84d52a0df91e9666f6dfb5ebd06315 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 26 Oct 2025 02:18:01 -0700 Subject: [PATCH 16/19] cargo fmt --- socktop/src/app.rs | 4 ++-- socktop/src/ui/modal.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 36e750b..bb0153d 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -655,12 +655,12 @@ impl App { ) { self.should_quit = true; } - + // Show About modal on 'a' or 'A' if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) { self.modal_manager.push_modal(ModalType::About); } - + // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index f767482..46769ef 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -416,7 +416,7 @@ impl ModalManager { "#; let version = env!("CARGO_PKG_VERSION"); - + let about_text = format!( "{}\n\ Version {}\n\ From c9bde52cb10ff575605f6c8e6d9534517e621b97 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 26 Oct 2025 02:30:46 -0700 Subject: [PATCH 17/19] move logo to them file. --- socktop/src/ui/modal.rs | 25 ++----------------------- socktop/src/ui/theme.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index 46769ef..e0ca2fd 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -390,30 +390,9 @@ impl ModalManager { } fn render_about(&self, f: &mut Frame, area: Rect) { - const ASCII_ART: &str = r#" -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀ -⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀ -⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀ -⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀ -⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗ -██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ -███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝ -╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝ -███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ -╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ -"#; + //get ASCII art from a constant stored in theme.rs + use super::theme::ASCII_ART; let version = env!("CARGO_PKG_VERSION"); diff --git a/socktop/src/ui/theme.rs b/socktop/src/ui/theme.rs index 7b4d1bf..f726b85 100644 --- a/socktop/src/ui/theme.rs +++ b/socktop/src/ui/theme.rs @@ -49,7 +49,7 @@ pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: "; pub const BTN_RETRY_TEXT: &str = " 🔄 Retry "; pub const BTN_EXIT_TEXT: &str = " ❌ Exit "; -// Large multi-line warning icon +// warning icon pub const LARGE_ERROR_ICON: &[&str] = &[ " /\\ ", " / \\ ", @@ -60,3 +60,30 @@ pub const LARGE_ERROR_ICON: &[&str] = &[ " / !! \\ ", " /______________\\ ", ]; + + +//about logo +pub const ASCII_ART: &str = r#" +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀ +⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀ +⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀ +⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀ +⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + +███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗ +██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ +███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝ +╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝ +███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ +╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ +"#; \ No newline at end of file From ffe451edaac57ec89b9c8a2b7b655fef33648e51 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 26 Oct 2025 02:32:11 -0700 Subject: [PATCH 18/19] cargo fmt --- socktop/src/ui/modal.rs | 1 - socktop/src/ui/theme.rs | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index e0ca2fd..7b5eadb 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -390,7 +390,6 @@ impl ModalManager { } fn render_about(&self, f: &mut Frame, area: Rect) { - //get ASCII art from a constant stored in theme.rs use super::theme::ASCII_ART; diff --git a/socktop/src/ui/theme.rs b/socktop/src/ui/theme.rs index f726b85..5fad8d8 100644 --- a/socktop/src/ui/theme.rs +++ b/socktop/src/ui/theme.rs @@ -61,7 +61,6 @@ pub const LARGE_ERROR_ICON: &[&str] = &[ " /______________\\ ", ]; - //about logo pub const ASCII_ART: &str = r#" ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ @@ -86,4 +85,4 @@ pub const ASCII_ART: &str = r#" ╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝ ███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ -"#; \ No newline at end of file +"#; From 6f238cdf257ab7da118fe9e65b0ebc63056b58b4 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 17 Nov 2025 00:05:02 -0800 Subject: [PATCH 19/19] tweak hotkeys, add info panel, optimize fonts and hotkeys for about and info panel. --- socktop/src/app.rs | 74 +++++++++++++++++------ socktop/src/ui/cpu.rs | 4 +- socktop/src/ui/header.rs | 2 +- socktop/src/ui/modal.rs | 92 +++++++++++++++++++++++++++-- socktop/src/ui/modal_types.rs | 1 + socktop/src/ui/processes.rs | 108 +++++++++++++++++++++++++++++----- 6 files changed, 242 insertions(+), 39 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index bb0153d..9f35a79 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -29,7 +29,8 @@ use crate::ui::cpu::{ }; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::processes::{ - ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection, + ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection, + processes_handle_mouse_with_selection, }; use crate::ui::{ disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, @@ -661,6 +662,11 @@ impl App { self.modal_manager.push_modal(ModalType::About); } + // Show Help modal on 'h' or 'H' + if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) { + self.modal_manager.push_modal(ModalType::Help); + } + // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); @@ -681,22 +687,14 @@ impl App { let content = per_core_content_area(top[1]); // First try process selection (only handles arrows if a process is selected) - let process_handled = if let Some(p_area) = self.last_procs_area { - let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) - let total_rows = self - .last_metrics - .as_ref() - .map(|m| m.top_processes.len()) - .unwrap_or(0); - processes_handle_key_with_selection( - &mut self.procs_scroll_offset, - &mut self.selected_process_pid, - &mut self.selected_process_index, - k, - page, - total_rows, - self.last_metrics.as_ref(), - ) + let process_handled = if self.last_procs_area.is_some() { + processes_handle_key_with_selection(ProcessKeyParams { + selected_process_pid: &mut self.selected_process_pid, + selected_process_index: &mut self.selected_process_index, + key: k, + metrics: self.last_metrics.as_ref(), + sort_by: self.procs_sort_by, + }) } else { false }; @@ -710,6 +708,48 @@ impl App { ); } + // Auto-scroll to keep selected process visible + if let (Some(selected_idx), Some(p_area)) = + (self.selected_process_index, self.last_procs_area) + { + // Calculate viewport size (excluding borders and header) + let viewport_rows = p_area.height.saturating_sub(3) as usize; // borders (2) + header (1) + + // Build sorted index list to find display position + if let Some(m) = self.last_metrics.as_ref() { + let mut idxs: Vec = (0..m.top_processes.len()).collect(); + match self.procs_sort_by { + ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].cpu_usage; + let bb = m.top_processes[b].cpu_usage; + bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal) + }), + ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].mem_bytes; + let bb = m.top_processes[b].mem_bytes; + bb.cmp(&aa) + }), + } + + // Find the display position of the selected process + if let Some(display_pos) = + idxs.iter().position(|&idx| idx == selected_idx) + { + // Adjust scroll offset to keep selection visible + if display_pos < self.procs_scroll_offset { + // Selection is above viewport, scroll up + self.procs_scroll_offset = display_pos; + } else if display_pos + >= self.procs_scroll_offset + viewport_rows + { + // Selection is below viewport, scroll down + self.procs_scroll_offset = + display_pos.saturating_sub(viewport_rows - 1); + } + } + } + } + // Check if process selection changed and clear details if so if self.selected_process_pid != self.prev_selected_process_pid { self.clear_process_details(); diff --git a/socktop/src/ui/cpu.rs b/socktop/src/ui/cpu.rs index bd2e3a1..831ae9b 100644 --- a/socktop/src/ui/cpu.rs +++ b/socktop/src/ui/cpu.rs @@ -42,8 +42,8 @@ pub fn per_core_content_area(area: Rect) -> Rect { /// Handles key events for per-core CPU bars. pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) { match key.code { - KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1), - KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1), + KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1), + KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1), KeyCode::PageUp => { let step = page_size.max(1); *scroll_offset = scroll_offset.saturating_sub(step); diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index 6987176..f190d2b 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -46,7 +46,7 @@ pub fn draw_header( parts.push(tok_txt.into()); } parts.push(intervals); - parts.push("(a: about, q: quit)".into()); + parts.push("(a: about, h: help, q: quit)".into()); let title = parts.join(" | "); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); } diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index 7b5eadb..c687f09 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -56,6 +56,7 @@ impl ModalManager { ModalButton::Ok } Some(ModalType::About) => ModalButton::Ok, + Some(ModalType::Help) => ModalButton::Ok, Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Info { .. }) => ModalButton::Ok, None => ModalButton::Ok, @@ -68,6 +69,7 @@ impl ModalManager { ModalType::ConnectionError { .. } => ModalButton::Retry, ModalType::ProcessDetails { .. } => ModalButton::Ok, ModalType::About => ModalButton::Ok, + ModalType::Help => ModalButton::Ok, ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Info { .. } => ModalButton::Ok, }; @@ -211,6 +213,10 @@ impl ModalManager { self.pop_modal(); ModalAction::Dismiss } + (Some(ModalType::Help), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Info { .. }), ModalButton::Ok) => { @@ -261,7 +267,11 @@ impl ModalManager { } ModalType::About => { // About modal uses medium size - self.centered_rect(60, 60, area) + self.centered_rect(90, 90, area) + } + ModalType::Help => { + // Help modal uses medium size + self.centered_rect(80, 80, area) } _ => { // Other modals use smaller size @@ -287,6 +297,7 @@ impl ModalManager { self.render_process_details(f, modal_area, *pid, data) } ModalType::About => self.render_about(f, modal_area), + ModalType::Help => self.render_help(f, modal_area), ModalType::Confirmation { title, message, @@ -415,7 +426,7 @@ impl ModalManager { let block = Block::default() .title(" About socktop ") .borders(Borders::ALL) - .style(Style::default().bg(Color::Black).fg(Color::Green)); + .style(Style::default().bg(Color::Black).fg(Color::DarkGray)); f.render_widget(block, area); // Calculate inner area manually to avoid any parent styling @@ -423,7 +434,7 @@ impl ModalManager { x: area.x + 1, y: area.y + 1, width: area.width.saturating_sub(2), - height: area.height.saturating_sub(3), // Leave room for button at bottom + height: area.height.saturating_sub(2), // Leave room for button at bottom }; // Render content area with explicit black background @@ -438,7 +449,80 @@ impl ModalManager { // Button area let button_area = Rect { x: area.x + 1, - y: area.y + area.height.saturating_sub(3), + y: area.y + area.height.saturating_sub(2), + width: area.width.saturating_sub(2), + height: 1, + }; + + let ok_style = if self.active_button == ModalButton::Ok { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue).bg(Color::Black) + }; + + f.render_widget( + Paragraph::new("[ Enter ] Close") + .style(ok_style) + .alignment(Alignment::Center), + button_area, + ); + } + + fn render_help(&self, f: &mut Frame, area: Rect) { + let help_text = "\ +GLOBAL + q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help + +PROCESS LIST + ↑/↓ ............ Select/navigate processes + Enter .......... Open Process Details + x/X ............ Clear selection + Click header ... Sort by column (CPU/Mem) + Click row ...... Select process + +CPU PER-CORE + ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down + Home/End ....... Jump to first/last core + +PROCESS DETAILS MODAL + x/X ............ Close modal (all parent modals) + p/P ............ Navigate to parent process + j/k ............ Scroll threads ↓/↑ (1 line) + d/u ............ Scroll threads ↓/↑ (10 lines) + [ / ] .......... Scroll journal ↑/↓ + Esc/Enter ...... Close modal"; + + // Render the border block + let block = Block::default() + .title(" Hotkey Help ") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black).fg(Color::DarkGray)); + f.render_widget(block, area); + + // Calculate inner area manually to avoid any parent styling + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), // Leave room for button at bottom + }; + + // Render content area with explicit black background + f.render_widget( + Paragraph::new(help_text) + .style(Style::default().fg(Color::Cyan).bg(Color::Black)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }), + inner_area, + ); + + // Button area + let button_area = Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(2), width: area.width.saturating_sub(2), height: 1, }; diff --git a/socktop/src/ui/modal_types.rs b/socktop/src/ui/modal_types.rs index 1c6de20..c101d2f 100644 --- a/socktop/src/ui/modal_types.rs +++ b/socktop/src/ui/modal_types.rs @@ -39,6 +39,7 @@ pub enum ModalType { pid: u32, }, About, + Help, #[allow(dead_code)] Confirmation { title: String, diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index d506016..2c99e64 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -254,6 +254,15 @@ fn fmt_cpu_pct(v: f32) -> String { } /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) +/// Parameters for process key event handling +pub struct ProcessKeyParams<'a> { + pub selected_process_pid: &'a mut Option, + pub selected_process_index: &'a mut Option, + pub key: crossterm::event::KeyEvent, + pub metrics: Option<&'a Metrics>, + pub sort_by: ProcSortBy, +} + /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality #[allow(dead_code)] pub fn processes_handle_key( @@ -264,24 +273,93 @@ pub fn processes_handle_key( crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); } -/// Enhanced keyboard handler that also manages process selection -pub fn processes_handle_key_with_selection( - _scroll_offset: &mut usize, - selected_process_pid: &mut Option, - selected_process_index: &mut Option, - key: crossterm::event::KeyEvent, - _page_size: usize, - _total_rows: usize, - _metrics: Option<&Metrics>, -) -> bool { +pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { use crossterm::event::KeyCode; - match key.code { + match params.key.code { + KeyCode::Up => { + // Build sorted index list to navigate through display order + if let Some(m) = params.metrics { + let mut idxs: Vec = (0..m.top_processes.len()).collect(); + match params.sort_by { + ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].cpu_usage; + let bb = m.top_processes[b].cpu_usage; + bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) + }), + ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].mem_bytes; + let bb = m.top_processes[b].mem_bytes; + bb.cmp(&aa) + }), + } + + if params.selected_process_index.is_none() || params.selected_process_pid.is_none() + { + // No selection - select the first process in sorted order + if !idxs.is_empty() { + let first_idx = idxs[0]; + *params.selected_process_index = Some(first_idx); + *params.selected_process_pid = Some(m.top_processes[first_idx].pid); + } + } else if let Some(current_idx) = *params.selected_process_index { + // Find current position in sorted list + if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) + && pos > 0 + { + // Move up in sorted list + let new_idx = idxs[pos - 1]; + *params.selected_process_index = Some(new_idx); + *params.selected_process_pid = Some(m.top_processes[new_idx].pid); + } + } + } + true // Handled + } + KeyCode::Down => { + // Build sorted index list to navigate through display order + if let Some(m) = params.metrics { + let mut idxs: Vec = (0..m.top_processes.len()).collect(); + match params.sort_by { + ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].cpu_usage; + let bb = m.top_processes[b].cpu_usage; + bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) + }), + ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].mem_bytes; + let bb = m.top_processes[b].mem_bytes; + bb.cmp(&aa) + }), + } + + if params.selected_process_index.is_none() || params.selected_process_pid.is_none() + { + // No selection - select the first process in sorted order + if !idxs.is_empty() { + let first_idx = idxs[0]; + *params.selected_process_index = Some(first_idx); + *params.selected_process_pid = Some(m.top_processes[first_idx].pid); + } + } else if let Some(current_idx) = *params.selected_process_index { + // Find current position in sorted list + if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) + && pos + 1 < idxs.len() + { + // Move down in sorted list + let new_idx = idxs[pos + 1]; + *params.selected_process_index = Some(new_idx); + *params.selected_process_pid = Some(m.top_processes[new_idx].pid); + } + } + } + true // Handled + } KeyCode::Char('x') | KeyCode::Char('X') => { // Unselect any selected process - if selected_process_pid.is_some() || selected_process_index.is_some() { - *selected_process_pid = None; - *selected_process_index = None; + if params.selected_process_pid.is_some() || params.selected_process_index.is_some() { + *params.selected_process_pid = None; + *params.selected_process_index = None; true // Handled } else { false // No selection to clear @@ -289,7 +367,7 @@ pub fn processes_handle_key_with_selection( } KeyCode::Enter => { // Signal that Enter was pressed with a selection - selected_process_pid.is_some() // Return true if we have a selection to handle + params.selected_process_pid.is_some() // Return true if we have a selection to handle } _ => { // No other keys handled - let scrollbar handle all navigation