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/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 5974111..9f35a79 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,10 @@ 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, 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, swap::draw_swap, @@ -76,12 +79,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 +143,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 +155,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 +608,43 @@ impl App { continue; // Skip normal key processing } ModalAction::Cancel | ModalAction::Dismiss => { - // Modal was dismissed, continue to normal processing + // 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, skip normal key processing + continue; } 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 + && 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 } } } @@ -584,6 +656,17 @@ 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); + } + + // 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); @@ -603,7 +686,84 @@ 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 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 + }; + + // 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, + ); + } + + // 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(); + 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 + && 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 +775,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 +830,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 +905,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 +1026,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 +1198,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 +1241,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 +1253,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.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/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/disks.rs b/socktop/src/ui/disks.rs index d549b87..963cdf0 100644 --- a/socktop/src/ui/disks.rs +++ b/socktop/src/ui/disks.rs @@ -24,8 +24,16 @@ 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 +44,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,23 +61,43 @@ 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 ); + // 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; diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index 9e81674..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("(q to 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/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 f020d62..c687f09 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -1,66 +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::{Block, Borders, Clear, Paragraph, Wrap}, }; -#[derive(Debug, Clone)] -pub enum ModalType { - ConnectionError { - message: String, - disconnected_at: Instant, - retry_count: u32, - auto_retry_countdown: Option, - }, - #[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, - RetryConnection, - ExitApp, - Confirm, - Cancel, - Dismiss, -} - -#[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, + pub thread_scroll_max: usize, + pub journal_scroll_max: usize, } impl ModalManager { @@ -68,16 +29,34 @@ 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::About) => ModalButton::Ok, + Some(ModalType::Help) => ModalButton::Ok, Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Info { .. }) => ModalButton::Ok, None => ModalButton::Ok, @@ -88,6 +67,9 @@ impl ModalManager { if let Some(next) = self.stack.last() { self.active_button = match next { ModalType::ConnectionError { .. } => ModalButton::Retry, + ModalType::ProcessDetails { .. } => ModalButton::Ok, + ModalType::About => ModalButton::Ok, + ModalType::Help => ModalButton::Ok, ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Info { .. } => ModalButton::Ok, }; @@ -135,6 +117,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 +205,18 @@ impl ModalManager { ModalAction::RetryConnection } (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp, + (Some(ModalType::ProcessDetails { .. }), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } + (Some(ModalType::About), ModalButton::Ok) => { + 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) => { @@ -166,10 +239,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 +257,27 @@ 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) + } + ModalType::About => { + // About modal uses medium size + self.centered_rect(90, 90, area) + } + ModalType::Help => { + // Help modal uses medium size + self.centered_rect(80, 80, area) + } + _ => { + // Other modals use smaller size + self.centered_rect(70, 50, area) + } + }; f.render_widget(Clear, modal_area); match modal { ModalType::ConnectionError { @@ -202,6 +293,11 @@ impl ModalManager { *retry_count, *auto_retry_countdown, ), + ModalType::ProcessDetails { pid } => { + 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, @@ -212,279 +308,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, @@ -577,6 +400,150 @@ 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; + + 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::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(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(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, + }; + + 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) @@ -596,17 +563,3 @@ impl ModalManager { .split(vert[1])[1] } } - -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..c101d2f --- /dev/null +++ b/socktop/src/ui/modal_types.rs @@ -0,0 +1,76 @@ +//! 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, + }, + About, + Help, + #[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/src/ui/processes.rs b/socktop/src/ui/processes.rs index 0855055..2c99e64 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,17 @@ 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( scroll_offset: &mut usize, key: crossterm::event::KeyEvent, @@ -199,8 +273,113 @@ pub fn processes_handle_key( crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); } +pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { + use crossterm::event::KeyCode; + + 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 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 + } + } + KeyCode::Enter => { + // Signal that Enter was pressed with a selection + params.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 +443,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..5fad8d8 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 = "⚠️"; @@ -40,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] = &[ " /\\ ", " / \\ ", @@ -51,3 +60,29 @@ pub const LARGE_ERROR_ICON: &[&str] = &[ " / !! \\ ", " /______________\\ ", ]; + +//about logo +pub const ASCII_ART: &str = r#" +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀ +⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀ +⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀ +⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀ +⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + +███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗ +██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ +███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝ +╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝ +███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ +╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ +"#; 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..0940da1 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(); @@ -286,14 +331,194 @@ 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 - let disks: Vec = disks_list + + // 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(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) + // 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); + 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() { + let key = format!("nvme{}n1", idx); + tracing::debug!("Mapping {} -> {}°C", key, temp); + temps.insert(key, *temp); + } + tracing::debug!("Final disk_temps map: {:?}", temps); + temps + }; + + // 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| DiskInfo { - name: d.name().to_string_lossy().into_owned(), - total: d.total_space(), - available: d.available_space(), + .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") + || 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) { + 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, + total: d.total_space(), + available: d.available_space(), + temperature, + is_partition, + }) }) .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) + }; + + // 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_for_match.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, parent_temp)); + entry.0 += partition.total; + entry.1 += partition.available; + // Keep temperature if any partition has it (or if we just found one) + if entry.2.is_none() { + entry.2 = parent_temp; + } + } + } + + // 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()); @@ -549,3 +774,626 @@ 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() + && 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); + } + } + } + + 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:") + && 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() + && 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 + #[cfg(target_os = "linux")] + let (user_id, group_id) = + if let Ok(status_content) = std::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 (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) = std::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) + 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()); + + // 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..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)] @@ -47,3 +49,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..872a6ae 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,90 @@ 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:") + && 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) + && 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:") + && 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) + && 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..47df1c0 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -15,6 +15,10 @@ pub struct DiskInfo { pub name: String, pub total: u64, pub available: u64, + #[serde(default)] + pub temperature: Option, + #[serde(default)] + pub is_partition: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -73,6 +77,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 +160,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 +173,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 +189,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)) + } } }