From e66008f3417a4ef4debeebc9ccf1a874f9e780bf Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 2 Oct 2025 16:54:27 -0700 Subject: [PATCH] initial check for process summary screen This check in offers alpha support for per process metrics, you can view threads, process CPU usage over time, IO, memory, CPU time, parent process, command, uptime and journal entries. This is unfinished but all major functionality is available and I wanted to make it available to feedback and testing. --- .gitignore | 4 + Cargo.lock | 499 ++++++- socktop/src/app.rs | 309 ++++- socktop/src/ui/modal.rs | 1272 +++++++++++++++++- socktop/src/ui/processes.rs | 229 +++- socktop/src/ui/theme.rs | 9 + socktop_agent/Cargo.toml | 2 + socktop_agent/src/cache.rs | 95 ++ socktop_agent/src/lib.rs | 17 + socktop_agent/src/metrics.rs | 662 ++++++++- socktop_agent/src/state.rs | 19 + socktop_agent/src/types.rs | 73 + socktop_agent/src/ws.rs | 88 ++ socktop_agent/tests/cache_tests.rs | 132 ++ socktop_agent/tests/process_details.rs | 89 ++ socktop_connector/src/connector.rs | 60 +- socktop_connector/src/connector_impl.rs | 17 +- socktop_connector/src/lib.rs | 3 +- socktop_connector/src/networking/requests.rs | 34 + socktop_connector/src/types.rs | 83 ++ socktop_connector/src/wasm/requests.rs | 25 +- 21 files changed, 3625 insertions(+), 96 deletions(-) create mode 100644 socktop_agent/src/cache.rs create mode 100644 socktop_agent/src/lib.rs create mode 100644 socktop_agent/tests/cache_tests.rs create mode 100644 socktop_agent/tests/process_details.rs diff --git a/.gitignore b/.gitignore index c8dfcec..1370721 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /target .vscode/ /socktop-wasm-test/target + +# Documentation files from development sessions (context-specific, not for public repo) +/OPTIMIZATION_PROCESS_DETAILS.md +/THREAD_SUPPORT.md diff --git a/Cargo.lock b/Cargo.lock index 330e942..a20b12b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,7 +137,7 @@ dependencies = [ "axum-core", "axum-macros", "base64", - "bytes", + "bytes 1.10.1", "futures-util", "http", "http-body", @@ -172,7 +172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", - "bytes", + "bytes 1.10.1", "futures-util", "http", "http-body", @@ -204,7 +204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1ad46c3ec4e12f4a4b6835e173ba21c25e484c9d02b49770bf006ce5367c036" dependencies = [ "arc-swap", - "bytes", + "bytes 1.10.1", "futures-util", "http", "http-body", @@ -227,7 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.1", "libc", "miniz_oxide", "object", @@ -247,7 +247,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -264,6 +264,12 @@ dependencies = [ "which", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -302,6 +308,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + [[package]] name = "bytes" version = "1.10.1" @@ -343,6 +359,12 @@ dependencies = [ "nom", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.1" @@ -373,6 +395,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "cmake" version = "0.1.54" @@ -389,7 +420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", - "cfg-if", + "cfg-if 1.0.1", "itoa", "rustversion", "ryu", @@ -427,7 +458,37 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", +] + +[[package]] +name = "crossbeam-queue" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c979cd6cfe72335896575c6b5688da489e420d36a27a0b9eb0c73db574b4a4b" +dependencies = [ + "crossbeam-utils 0.6.6", +] + +[[package]] +name = "crossbeam-utils" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04973fa96e96579258a5091af6003abde64af786b860f18622b82e026cca60e6" +dependencies = [ + "cfg-if 0.1.10", + "lazy_static", +] + +[[package]] +name = "crossbeam-utils" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" +dependencies = [ + "autocfg", + "cfg-if 0.1.10", + "lazy_static", ] [[package]] @@ -436,14 +497,14 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", "libc", "mio 0.8.11", - "parking_lot", + "parking_lot 0.12.4", "signal-hook", "signal-hook-mio", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -452,14 +513,14 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.1", "crossterm_winapi", "mio 1.0.4", - "parking_lot", + "parking_lot 0.12.4", "rustix 0.38.44", "signal-hook", "signal-hook-mio", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -468,7 +529,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ - "winapi", + "winapi 0.3.9", ] [[package]] @@ -553,7 +614,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "dirs-sys-next", ] @@ -565,7 +626,7 @@ checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", "redox_users", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -662,6 +723,28 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fuchsia-zircon" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" +dependencies = [ + "bitflags 1.3.2", + "fuchsia-zircon-sys", +] + +[[package]] +name = "fuchsia-zircon-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + [[package]] name = "futures" version = "0.3.31" @@ -767,7 +850,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "libc", "wasi 0.11.1+wasi-snapshot-preview1", ] @@ -778,7 +861,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", @@ -818,7 +901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", - "bytes", + "bytes 1.10.1", "fnv", "futures-core", "futures-sink", @@ -847,6 +930,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "home" version = "0.5.11" @@ -864,7 +953,7 @@ checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" dependencies = [ "libc", "match_cfg", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -873,7 +962,7 @@ version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ - "bytes", + "bytes 1.10.1", "fnv", "itoa", ] @@ -884,7 +973,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "bytes", + "bytes 1.10.1", "http", ] @@ -894,7 +983,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-core", "http", "http-body", @@ -919,7 +1008,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-channel", "futures-util", "h2", @@ -929,7 +1018,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "smallvec", + "smallvec 1.15.1", "tokio", ] @@ -939,7 +1028,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-core", "http", "http-body", @@ -1010,7 +1099,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec", + "smallvec 1.15.1", "zerovec", ] @@ -1072,7 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", - "smallvec", + "smallvec 1.15.1", "utf8_iter", ] @@ -1131,8 +1220,17 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags", - "cfg-if", + "bitflags 2.9.1", + "cfg-if 1.0.1", + "libc", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ "libc", ] @@ -1180,6 +1278,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1213,7 +1321,7 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "windows-targets 0.53.3", ] @@ -1223,7 +1331,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags", + "bitflags 2.9.1", "libc", ] @@ -1245,6 +1353,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "lock_api" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4da24a77a3d8a6d4862d95f72e6fdb9c09a643ecdb402d754004a557f2bec75" +dependencies = [ + "scopeguard", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -1300,6 +1417,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + [[package]] name = "memchr" version = "2.7.5" @@ -1327,6 +1450,25 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" +dependencies = [ + "cfg-if 0.1.10", + "fuchsia-zircon", + "fuchsia-zircon-sys", + "iovec", + "kernel32-sys", + "libc", + "log", + "miow 0.2.2", + "net2", + "slab", + "winapi 0.2.8", +] + [[package]] name = "mio" version = "0.8.11" @@ -1351,12 +1493,67 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mio-named-pipes" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0840c1c50fd55e521b247f949c241c9997709f23bd7f023b9762cd561e935656" +dependencies = [ + "log", + "mio 0.6.23", + "miow 0.3.7", + "winapi 0.3.9", +] + +[[package]] +name = "mio-uds" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" +dependencies = [ + "iovec", + "libc", + "mio 0.6.23", +] + +[[package]] +name = "miow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" +dependencies = [ + "kernel32-sys", + "net2", + "winapi 0.2.8", + "ws2_32-sys", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "multimap" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "net2" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13b648036a2339d06de780866fbdfda0dde886de7b3af2ddeba8b14f4ee34ac" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "winapi 0.3.9", +] + [[package]] name = "nom" version = "7.1.3" @@ -1373,7 +1570,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" dependencies = [ - "winapi", + "winapi 0.3.9", ] [[package]] @@ -1383,7 +1580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ "overload", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -1401,13 +1598,23 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "nvml-wrapper" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5" dependencies = [ - "bitflags", + "bitflags 2.9.1", "libloading", "nvml-wrapper-sys", "static_assertions", @@ -1430,7 +1637,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -1464,14 +1671,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking_lot" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" +dependencies = [ + "lock_api 0.3.4", + "parking_lot_core 0.6.3", + "rustc_version", +] + [[package]] name = "parking_lot" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.4.13", + "parking_lot_core 0.9.11", +] + +[[package]] +name = "parking_lot_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66b810a62be75176a80873726630147a5ca780cd33921e0b5709033e66b0a" +dependencies = [ + "cfg-if 0.1.10", + "cloudabi", + "libc", + "redox_syscall 0.1.57", + "rustc_version", + "smallvec 0.6.14", + "winapi 0.3.9", ] [[package]] @@ -1480,10 +1713,10 @@ version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "libc", - "redox_syscall", - "smallvec", + "redox_syscall 0.5.17", + "smallvec 1.15.1", "windows-targets 0.52.6", ] @@ -1627,7 +1860,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ - "bytes", + "bytes 1.10.1", "prost-derive", ] @@ -1788,7 +2021,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cassowary", "compact_str", "crossterm 0.28.1", @@ -1816,13 +2049,19 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -1887,7 +2126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.1", "getrandom 0.2.16", "libc", "untrusted", @@ -1906,13 +2145,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1925,7 +2173,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -2028,6 +2276,21 @@ dependencies = [ "untrusted", ] +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.219" @@ -2088,7 +2351,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "cpufeatures", "digest", ] @@ -2145,6 +2408,15 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2205,6 +2477,7 @@ dependencies = [ "tempfile", "time", "tokio", + "tokio-process", "tokio-tungstenite 0.21.0", "tonic-build", "tracing", @@ -2387,7 +2660,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", ] [[package]] @@ -2438,11 +2711,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", - "bytes", + "bytes 1.10.1", "io-uring", "libc", "mio 1.0.4", - "parking_lot", + "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", "slab", @@ -2451,6 +2724,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tokio-executor" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb2d1b8f4548dbf5e1f7818512e9c406860678f29c300cdf0ebac72d1a3a1671" +dependencies = [ + "crossbeam-utils 0.7.2", + "futures 0.1.31", +] + +[[package]] +name = "tokio-io" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" +dependencies = [ + "bytes 0.4.12", + "futures 0.1.31", + "log", +] + [[package]] name = "tokio-macros" version = "2.5.0" @@ -2462,6 +2756,44 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-process" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382d90f43fa31caebe5d3bc6cfd854963394fff3b8cb59d5146607aaae7e7e43" +dependencies = [ + "crossbeam-queue", + "futures 0.1.31", + "lazy_static", + "libc", + "log", + "mio 0.6.23", + "mio-named-pipes", + "tokio-io", + "tokio-reactor", + "tokio-signal", + "winapi 0.3.9", +] + +[[package]] +name = "tokio-reactor" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09bc590ec4ba8ba87652da2068d150dcada2cfa2e07faae270a5e0409aa51351" +dependencies = [ + "crossbeam-utils 0.7.2", + "futures 0.1.31", + "lazy_static", + "log", + "mio 0.6.23", + "num_cpus", + "parking_lot 0.9.0", + "slab", + "tokio-executor", + "tokio-io", + "tokio-sync", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -2482,6 +2814,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-signal" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c34c6e548f101053321cba3da7cbb87a610b85555884c41b07da2eb91aff12" +dependencies = [ + "futures 0.1.31", + "libc", + "mio 0.6.23", + "mio-uds", + "signal-hook-registry", + "tokio-executor", + "tokio-io", + "tokio-reactor", + "winapi 0.3.9", +] + +[[package]] +name = "tokio-sync" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfe50152bc8164fcc456dab7891fa9bf8beaf01c5ee7e1dd43a397c3cf87dee" +dependencies = [ + "fnv", + "futures 0.1.31", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -2515,7 +2874,7 @@ version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ - "bytes", + "bytes 1.10.1", "futures-core", "futures-sink", "pin-project-lite", @@ -2632,7 +2991,7 @@ dependencies = [ "once_cell", "regex", "sharded-slab", - "smallvec", + "smallvec 1.15.1", "thread_local", "tracing", "tracing-core", @@ -2646,7 +3005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" dependencies = [ "byteorder", - "bytes", + "bytes 1.10.1", "data-encoding", "http", "httparse", @@ -2665,7 +3024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" dependencies = [ "byteorder", - "bytes", + "bytes 1.10.1", "data-encoding", "http", "httparse", @@ -2784,7 +3143,7 @@ version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -2810,7 +3169,7 @@ version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ - "cfg-if", + "cfg-if 1.0.1", "js-sys", "once_cell", "wasm-bindgen", @@ -2871,6 +3230,12 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + [[package]] name = "winapi" version = "0.3.9" @@ -2881,6 +3246,12 @@ dependencies = [ "winapi-x86_64-pc-windows-gnu", ] +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" @@ -3307,7 +3678,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -3317,7 +3688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f902b4592b911109e7352bcfec7b754b07ec71e514d7dfa280eaef924c1cb08" dependencies = [ "chrono", - "futures", + "futures 0.3.31", "log", "serde", "thiserror 2.0.12", @@ -3343,6 +3714,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "ws2_32-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 5974111..71e47d0 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -17,7 +17,7 @@ use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Rect}, }; -use tokio::time::sleep; +use tokio::time::{sleep, timeout}; use crate::history::{PerCoreHistory, push_capped}; use crate::retry::{RetryTiming, compute_retry_timing}; @@ -28,7 +28,9 @@ use crate::ui::cpu::{ per_core_handle_scrollbar_mouse, }; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; -use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse}; +use crate::ui::processes::{ + ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection, +}; use crate::ui::{ disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, swap::draw_swap, @@ -76,12 +78,32 @@ pub struct App { pub procs_sort_by: ProcSortBy, last_procs_area: Option, + // Process selection state + pub selected_process_pid: Option, + pub selected_process_index: Option, // Index in the visible/sorted list + prev_selected_process_pid: Option, // Track previous selection to detect changes + last_procs_poll: Instant, last_disks_poll: Instant, procs_interval: Duration, disks_interval: Duration, metrics_interval: Duration, + // Process details polling + pub process_details: Option, + pub journal_entries: Option, + pub process_cpu_history: VecDeque, // CPU history for sparkline (last 60 samples) + pub process_mem_history: VecDeque, // Memory usage history in bytes (last 60 samples) + pub process_io_read_history: VecDeque, // Disk read DELTA history in bytes (last 60 samples) + pub process_io_write_history: VecDeque, // Disk write DELTA history in bytes (last 60 samples) + last_io_read_bytes: Option, // Previous read bytes for delta calculation + last_io_write_bytes: Option, // Previous write bytes for delta calculation + pub process_details_unsupported: bool, // Track if agent doesn't support process details + last_process_details_poll: Instant, + last_journal_poll: Instant, + process_details_interval: Duration, + journal_interval: Duration, + // For reconnects ws_url: String, tls_ca: Option, @@ -120,6 +142,9 @@ impl App { procs_drag: None, procs_sort_by: ProcSortBy::CpuDesc, last_procs_area: None, + selected_process_pid: None, + selected_process_index: None, + prev_selected_process_pid: None, last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -129,6 +154,23 @@ impl App { procs_interval: Duration::from_secs(2), disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), + process_details: None, + journal_entries: None, + process_cpu_history: VecDeque::with_capacity(600), + process_mem_history: VecDeque::with_capacity(600), + process_io_read_history: VecDeque::with_capacity(600), + process_io_write_history: VecDeque::with_capacity(600), + last_io_read_bytes: None, + last_io_write_bytes: None, + process_details_unsupported: false, + last_process_details_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + last_journal_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + process_details_interval: Duration::from_millis(500), + journal_interval: Duration::from_secs(5), ws_url: String::new(), tls_ca: None, verify_hostname: false, @@ -565,14 +607,42 @@ impl App { continue; // Skip normal key processing } ModalAction::Cancel | ModalAction::Dismiss => { + // If ProcessDetails modal was dismissed, clear the data to save resources + if let Some(crate::ui::modal::ModalType::ProcessDetails { + .. + }) = self.modal_manager.current_modal() + { + self.clear_process_details(); + } // Modal was dismissed, continue to normal processing } ModalAction::Confirm => { // Handle confirmation action here if needed in the future } + ModalAction::SwitchToParentProcess(_current_pid) => { + // Get parent PID from current process details + if let Some(details) = &self.process_details { + if let Some(parent_pid) = details.process.parent_pid { + // Clear current process details + self.clear_process_details(); + // Update selected process to parent + self.selected_process_pid = Some(parent_pid); + // Open modal for parent process + self.modal_manager.push_modal( + crate::ui::modal::ModalType::ProcessDetails { + pid: parent_pid, + }, + ); + } + } + continue; + } + ModalAction::Handled => { + // Modal consumed the key, don't pass to main window + continue; + } ModalAction::None => { - // Modal is still active but didn't consume the key - continue; // Skip normal key processing + // Modal didn't handle the key, pass through to normal handling } } } @@ -603,7 +673,49 @@ impl App { .split(rows[1]); let content = per_core_content_area(top[1]); - per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize); + // First try process selection (only handles arrows if a process is selected) + let process_handled = if let Some(p_area) = self.last_procs_area { + let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) + let total_rows = self + .last_metrics + .as_ref() + .map(|m| m.top_processes.len()) + .unwrap_or(0); + processes_handle_key_with_selection( + &mut self.procs_scroll_offset, + &mut self.selected_process_pid, + &mut self.selected_process_index, + k, + page, + total_rows, + self.last_metrics.as_ref(), + ) + } else { + false + }; + + // If process selection didn't handle it, use CPU scrolling + if !process_handled { + per_core_handle_key( + &mut self.per_core_scroll, + k, + content.height as usize, + ); + } + + // Check if process selection changed and clear details if so + if self.selected_process_pid != self.prev_selected_process_pid { + self.clear_process_details(); + self.prev_selected_process_pid = self.selected_process_pid; + } + + // Check if Enter was pressed with a process selected + if process_handled && k.code == KeyCode::Enter { + if let Some(selected_pid) = self.selected_process_pid { + self.modal_manager + .push_modal(ModalType::ProcessDetails { pid: selected_pid }); + } + } let total_rows = self .last_metrics @@ -615,14 +727,13 @@ impl App { total_rows, content.height as usize, ); - - if let Some(p_area) = self.last_procs_area { - // page size = visible rows (inner height minus header = 1) - let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) - processes_handle_key(&mut self.procs_scroll_offset, k, page); - } } Event::Mouse(m) => { + // If modal is active, don't handle mouse events on the main window + if self.modal_manager.is_active() { + continue; + } + // Layout to get areas let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); @@ -671,18 +782,32 @@ impl App { content.height as usize, ); - // Processes table: sort by column on header click + // Processes table: sort by column on header click and handle row selection if let (Some(mm), Some(p_area)) = (self.last_metrics.as_ref(), self.last_procs_area) - && let Some(new_sort) = processes_handle_mouse( - &mut self.procs_scroll_offset, - &mut self.procs_drag, - m, - p_area, - mm.top_processes.len(), - ) { - self.procs_sort_by = new_sort; + use crate::ui::processes::ProcessMouseParams; + if let Some(new_sort) = + processes_handle_mouse_with_selection(ProcessMouseParams { + scroll_offset: &mut self.procs_scroll_offset, + selected_process_pid: &mut self.selected_process_pid, + selected_process_index: &mut self.selected_process_index, + drag: &mut self.procs_drag, + mouse: m, + area: p_area, + total_rows: mm.top_processes.len(), + metrics: self.last_metrics.as_ref(), + sort_by: self.procs_sort_by, + }) + { + self.procs_sort_by = new_sort; + } + } + + // Check if process selection changed via mouse and clear details if so + if self.selected_process_pid != self.prev_selected_process_pid { + self.clear_process_details(); + self.prev_selected_process_pid = self.selected_process_pid; } } Event::Resize(_, _) => {} @@ -732,6 +857,99 @@ impl App { } self.last_disks_poll = Instant::now(); } + + // Poll process details when modal is active and process is selected + if let Some(pid) = self.selected_process_pid { + // Check if ProcessDetails modal is currently active + if let Some(crate::ui::modal::ModalType::ProcessDetails { .. }) = + self.modal_manager.current_modal() + { + // Poll process details every 500ms when modal is active + if self.last_process_details_poll.elapsed() + >= self.process_details_interval + { + // Use timeout to prevent blocking the event loop + match timeout( + Duration::from_millis(2000), + ws.request(AgentRequest::ProcessMetrics { pid }), + ) + .await + { + Ok(Ok(AgentResponse::ProcessMetrics(details))) => { + // Update history for sparklines + let cpu_usage = details.process.cpu_usage; + push_capped(&mut self.process_cpu_history, cpu_usage, 600); + + let mem_bytes = details.process.mem_bytes; + push_capped(&mut self.process_mem_history, mem_bytes, 600); + + // I/O bytes from agent are cumulative, calculate deltas + if let Some(read) = details.process.read_bytes { + let delta = if let Some(last) = self.last_io_read_bytes + { + read.saturating_sub(last) + } else { + 0 // First sample, no delta available + }; + push_capped( + &mut self.process_io_read_history, + delta, + 600, + ); + self.last_io_read_bytes = Some(read); + } + if let Some(write) = details.process.write_bytes { + let delta = if let Some(last) = self.last_io_write_bytes + { + write.saturating_sub(last) + } else { + 0 // First sample, no delta available + }; + push_capped( + &mut self.process_io_write_history, + delta, + 600, + ); + self.last_io_write_bytes = Some(write); + } + + self.process_details = Some(details); + self.process_details_unsupported = false; + } + Ok(Err(_)) | Err(_) => { + // Agent doesn't support this feature or timeout occurred + // Mark as unsupported so we can show appropriate message + self.process_details_unsupported = true; + } + Ok(Ok(_)) => { + // Wrong response type + self.process_details_unsupported = true; + } + } + self.last_process_details_poll = Instant::now(); + } + + // Poll journal entries every 5s when modal is active + if self.last_journal_poll.elapsed() >= self.journal_interval { + // Use timeout to prevent blocking the event loop + match timeout( + Duration::from_millis(2000), + ws.request(AgentRequest::JournalEntries { pid }), + ) + .await + { + Ok(Ok(AgentResponse::JournalEntries(journal))) => { + self.journal_entries = Some(journal); + } + Ok(Err(_)) | Err(_) | Ok(Ok(_)) => { + // Agent doesn't support this feature, error occurred, or wrong response type + // Keep journal_entries as None + } + } + self.last_journal_poll = Instant::now(); + } + } + } } Err(e) => { // Connection error - show modal if not already shown @@ -760,6 +978,19 @@ impl App { Ok(()) } + /// Clear process details when modal is closed or selection changes + pub fn clear_process_details(&mut self) { + self.process_details = None; + self.journal_entries = None; + self.process_cpu_history.clear(); + self.process_mem_history.clear(); + self.process_io_read_history.clear(); + self.process_io_write_history.clear(); + self.last_io_read_bytes = None; + self.last_io_write_bytes = None; + self.process_details_unsupported = false; + } + fn update_with_metrics(&mut self, mut m: Metrics) { if let Some(prev) = &self.last_metrics { // Preserve slower fields when the fast payload omits them @@ -919,11 +1150,27 @@ impl App { self.last_metrics.as_ref(), self.procs_scroll_offset, self.procs_sort_by, + self.selected_process_pid, + self.selected_process_index, ); // Render modals on top of everything else if self.modal_manager.is_active() { - self.modal_manager.render(f); + use crate::ui::modal::{ProcessHistoryData, ProcessModalData}; + self.modal_manager.render( + f, + ProcessModalData { + details: self.process_details.as_ref(), + journal: self.journal_entries.as_ref(), + history: ProcessHistoryData { + cpu: &self.process_cpu_history, + mem: &self.process_mem_history, + io_read: &self.process_io_read_history, + io_write: &self.process_io_write_history, + }, + unsupported: self.process_details_unsupported, + }, + ); } } } @@ -946,6 +1193,9 @@ impl Default for App { procs_drag: None, procs_sort_by: ProcSortBy::CpuDesc, last_procs_area: None, + selected_process_pid: None, + selected_process_index: None, + prev_selected_process_pid: None, last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -955,6 +1205,23 @@ impl Default for App { procs_interval: Duration::from_secs(2), disks_interval: Duration::from_secs(5), metrics_interval: Duration::from_millis(500), + process_details: None, + journal_entries: None, + process_cpu_history: VecDeque::with_capacity(600), + process_mem_history: VecDeque::with_capacity(600), + process_io_read_history: VecDeque::with_capacity(600), + process_io_write_history: VecDeque::with_capacity(600), + last_io_read_bytes: None, + last_io_write_bytes: None, + process_details_unsupported: false, + last_process_details_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + last_journal_poll: Instant::now() + .checked_sub(Duration::from_secs(10)) + .unwrap_or_else(Instant::now), + process_details_interval: Duration::from_millis(500), + journal_interval: Duration::from_secs(5), ws_url: String::new(), tls_ca: None, verify_hostname: false, diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index f020d62..d6e0d71 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -16,9 +16,37 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + widgets::{ + Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, Table, Wrap, + }, }; +/// History data for process metrics rendering +pub struct ProcessHistoryData<'a> { + pub cpu: &'a std::collections::VecDeque, + pub mem: &'a std::collections::VecDeque, + pub io_read: &'a std::collections::VecDeque, + pub io_write: &'a std::collections::VecDeque, +} + +/// Process data for modal rendering +pub struct ProcessModalData<'a> { + pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, + pub journal: Option<&'a socktop_connector::JournalResponse>, + pub history: ProcessHistoryData<'a>, + pub unsupported: bool, +} + +/// Parameters for rendering scatter plot +struct ScatterPlotParams<'a> { + process: &'a socktop_connector::DetailedProcessInfo, + main_user_ms: f64, + main_system_ms: f64, + max_user: f64, + max_system: f64, +} + #[derive(Debug, Clone)] pub enum ModalType { ConnectionError { @@ -27,6 +55,9 @@ pub enum ModalType { retry_count: u32, auto_retry_countdown: Option, }, + ProcessDetails { + pid: u32, + }, #[allow(dead_code)] Confirmation { title: String, @@ -35,17 +66,22 @@ pub enum ModalType { cancel_text: String, }, #[allow(dead_code)] - Info { title: String, message: String }, + Info { + title: String, + message: String, + }, } #[derive(Debug, Clone, PartialEq)] pub enum ModalAction { - None, + None, // Modal didn't handle the key, pass to main window + Handled, // Modal handled the key, don't pass to main window RetryConnection, ExitApp, Confirm, Cancel, Dismiss, + SwitchToParentProcess(u32), // Switch to viewing parent process details } #[derive(Debug, Clone, PartialEq)] @@ -61,6 +97,10 @@ pub enum ModalButton { pub struct ModalManager { stack: Vec, active_button: ModalButton, + pub thread_scroll_offset: usize, + pub journal_scroll_offset: usize, + thread_scroll_max: usize, + journal_scroll_max: usize, } impl ModalManager { @@ -68,16 +108,32 @@ impl ModalManager { Self { stack: Vec::new(), active_button: ModalButton::Retry, + thread_scroll_offset: 0, + journal_scroll_offset: 0, + thread_scroll_max: 0, + journal_scroll_max: 0, } } pub fn is_active(&self) -> bool { !self.stack.is_empty() } + pub fn current_modal(&self) -> Option<&ModalType> { + self.stack.last() + } + pub fn push_modal(&mut self, modal: ModalType) { self.stack.push(modal); self.active_button = match self.stack.last() { Some(ModalType::ConnectionError { .. }) => ModalButton::Retry, + Some(ModalType::ProcessDetails { .. }) => { + // Reset scroll state for new process details + self.thread_scroll_offset = 0; + self.journal_scroll_offset = 0; + self.thread_scroll_max = 0; + self.journal_scroll_max = 0; + ModalButton::Ok + } Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Info { .. }) => ModalButton::Ok, None => ModalButton::Ok, @@ -88,6 +144,7 @@ impl ModalManager { if let Some(next) = self.stack.last() { self.active_button = match next { ModalType::ConnectionError { .. } => ModalButton::Retry, + ModalType::ProcessDetails { .. } => ModalButton::Ok, ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Info { .. } => ModalButton::Ok, }; @@ -135,6 +192,85 @@ impl ModalManager { ModalAction::None } } + KeyCode::Char('x') | KeyCode::Char('X') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + // Close all ProcessDetails modals at once (handles parent navigation chain) + while matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.pop_modal(); + } + ModalAction::Dismiss + } else { + ModalAction::None + } + } + KeyCode::Char('j') | KeyCode::Char('J') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self + .thread_scroll_offset + .saturating_add(1) + .min(self.thread_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('k') | KeyCode::Char('K') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(1); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('d') | KeyCode::Char('D') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self + .thread_scroll_offset + .saturating_add(10) + .min(self.thread_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('u') | KeyCode::Char('U') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(10); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('[') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.journal_scroll_offset = self.journal_scroll_offset.saturating_sub(1); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char(']') => { + if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) { + self.journal_scroll_offset = self + .journal_scroll_offset + .saturating_add(1) + .min(self.journal_scroll_max); + ModalAction::Handled + } else { + ModalAction::None + } + } + KeyCode::Char('p') | KeyCode::Char('P') => { + // Switch to parent process if it exists + if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() { + // We need to get the parent PID from the process details + // For now, return a special action that the app can handle + // The app has access to the process details and can extract parent_pid + ModalAction::SwitchToParentProcess(*pid) + } else { + ModalAction::None + } + } _ => ModalAction::None, } } @@ -144,6 +280,10 @@ impl ModalManager { ModalAction::RetryConnection } (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp, + (Some(ModalType::ProcessDetails { .. }), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Info { .. }), ModalButton::Ok) => { @@ -166,10 +306,10 @@ impl ModalManager { self.next_button(); } - pub fn render(&self, f: &mut Frame) { - if let Some(m) = self.stack.last() { + pub fn render(&mut self, f: &mut Frame, data: ProcessModalData) { + if let Some(m) = self.stack.last().cloned() { self.render_background_dim(f); - self.render_modal_content(f, m); + self.render_modal_content(f, &m, data); } } @@ -184,9 +324,19 @@ impl ModalManager { ); } - fn render_modal_content(&self, f: &mut Frame, modal: &ModalType) { + fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) { let area = f.area(); - let modal_area = self.centered_rect(70, 50, area); + // Different sizes for different modal types + let modal_area = match modal { + ModalType::ProcessDetails { .. } => { + // Process details modal uses almost full screen (95% width, 90% height) + self.centered_rect(95, 90, area) + } + _ => { + // Other modals use smaller size + self.centered_rect(70, 50, area) + } + }; f.render_widget(Clear, modal_area); match modal { ModalType::ConnectionError { @@ -202,6 +352,9 @@ impl ModalManager { *retry_count, *auto_retry_countdown, ), + ModalType::ProcessDetails { pid } => { + self.render_process_details(f, modal_area, *pid, data) + } ModalType::Confirmation { title, message, @@ -595,6 +748,1109 @@ impl ModalManager { ]) .split(vert[1])[1] } + + fn render_process_details( + &mut self, + f: &mut Frame, + area: Rect, + pid: u32, + data: ProcessModalData, + ) { + let title = format!("Process Details - PID {pid}"); + + // Use neutral colors to match main UI aesthetic + let block = Block::default().title(title).borders(Borders::ALL); + + // Split the modal into the 3-row layout as designed + let inner = block.inner(area); + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(18), // Top row: CPU sparkline | Thread scatter plot + Constraint::Length(25), // Middle row: Memory/IO graphs | Thread table | Command details (fixed height for consistent scrolling) + Constraint::Min(6), // Bottom row: Journal events (gets remaining space) + Constraint::Length(1), // Help line + ]) + .split(inner); + + // Render the border + f.render_widget(block, area); + + if let Some(details) = data.details { + // Top Row: CPU sparkline (left) | Thread scatter plot (right) + self.render_top_row_with_sparkline( + f, + main_chunks[0], + &details.process, + data.history.cpu, + ); + + // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) + self.render_middle_row_with_metadata( + f, + main_chunks[1], + &details.process, + data.history.mem, + data.history.io_read, + data.history.io_write, + ); + + // Bottom Row: Journal Events + if let Some(journal) = data.journal { + self.render_journal_events(f, main_chunks[2], journal); + } else { + self.render_loading_journal_events(f, main_chunks[2]); + } + } else if data.unsupported { + // Agent doesn't support this feature + self.render_unsupported_message(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } else { + // Loading states for all sections + self.render_loading_top_row(f, main_chunks[0]); + self.render_loading_middle_row(f, main_chunks[1]); + self.render_loading_journal_events(f, main_chunks[2]); + } + + // Help line + let help_text = vec![Line::from(vec![ + Span::styled( + "X ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("close ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "P ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "goto parent ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::styled( + "j/k ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("threads ", Style::default().add_modifier(Modifier::DIM)), + Span::styled( + "[ ] ", + Style::default() + .fg(super::theme::PROCESS_DETAILS_ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), + ])]; + let help = Paragraph::new(help_text).alignment(Alignment::Center); + f.render_widget(help, main_chunks[3]); + } + + fn render_thread_scatter_plot( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let plot_block = Block::default() + .title("Thread & Process CPU Time") + .borders(Borders::ALL); + + let inner = plot_block.inner(area); + + // Convert CPU times from microseconds to milliseconds for better readability + let main_user_ms = process.cpu_time_user as f64 / 1000.0; + let main_system_ms = process.cpu_time_system as f64 / 1000.0; + + // Calculate max values for scaling + let mut max_user = main_user_ms; + let mut max_system = main_system_ms; + + for child in &process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + max_user = max_user.max(child_user_ms); + max_system = max_system.max(child_system_ms); + } + + // Add some padding to the scale + max_user = (max_user * 1.1).max(1.0); + max_system = (max_system * 1.1).max(1.0); + + // Render the existing scatter plot but in the smaller space + self.render_scatter_plot_content( + f, + inner, + ScatterPlotParams { + process, + main_user_ms, + main_system_ms, + max_user, + max_system, + }, + ); + + // Render the border + f.render_widget(plot_block, area); + } + + fn render_memory_io_graphs( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + let graphs_block = Block::default().title("Memory & I/O").borders(Borders::ALL); + + let mem_mb = process.mem_bytes as f64 / 1_048_576.0; + let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; + + let mut content_lines = vec![ + Line::from(vec![Span::styled( + "🧠 Memory", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + Span::styled(" 📊 RSS: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{mem_mb:.1} MB")), + ]), + ]; + + // Add memory sparkline if we have history + if mem_history.len() >= 2 { + let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB + let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); + + // Create mini sparkline using Unicode blocks + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = mem_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_mem as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Blue)), + ])); + } else { + content_lines.push(Line::from(vec![Span::styled( + " Collecting...", + Style::default().add_modifier(Modifier::DIM), + )])); + } + + content_lines.push(Line::from(vec![ + Span::styled(" Virtual: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{virtual_mb:.1} MB")), + ])); + + // Add shared memory if available + if let Some(shared_bytes) = process.shared_mem_bytes { + let shared_mb = shared_bytes as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{shared_mb:.1} MB")), + ])); + } + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "💾 Disk I/O", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Add I/O stats if available + match (process.read_bytes, process.write_bytes) { + (Some(read), Some(write)) => { + let read_mb = read as f64 / 1_048_576.0; + let write_mb = write as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled(" 📖 Read: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{read_mb:.1} MB")), + ])); + + // Add read I/O sparkline if we have history + if io_read_history.len() >= 2 { + let read_data: Vec = io_read_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_read = read_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = read_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_read as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Green)), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled( + " ✍️ Write: ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::raw(format!("{write_mb:.1} MB")), + ])); + + // Add write I/O sparkline if we have history + if io_write_history.len() >= 2 { + let write_data: Vec = io_write_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB + let max_write = write_data.iter().copied().max().unwrap_or(1).max(1); + + let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let sparkline_str: String = write_data + .iter() + .map(|&val| { + let level = ((val as f64 / max_write as f64) * 7.0).round() as usize; + blocks[level.min(7)] + }) + .collect(); + + content_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(sparkline_str, Style::default().fg(Color::Yellow)), + ])); + } + } + _ => { + content_lines.push(Line::from(vec![Span::styled( + " Not available", + Style::default().add_modifier(Modifier::DIM), + )])); + } + } + + let content = Paragraph::new(content_lines).block(graphs_block); + + f.render_widget(content, area); + } + + fn render_thread_table( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let total_items = process.threads.len() + process.child_processes.len(); + + // Manually calculate inner area (like processes.rs does) + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + // Calculate visible rows: inner height minus header (1 line) and header bottom margin (1 line) + let visible_rows = inner_area.height.saturating_sub(2).max(1) as usize; + + // Calculate and store max scroll for key handler bounds checking + self.thread_scroll_max = if total_items > visible_rows { + total_items.saturating_sub(visible_rows) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.thread_scroll_offset.min(self.thread_scroll_max); + + // Combine threads and processes into rows + let mut rows = Vec::new(); + + // Add threads first + for thread in &process.threads { + rows.push(Row::new(vec![ + Line::from(Span::styled("[T]", Style::default().fg(Color::Cyan))), + Line::from(format!("{}", thread.tid)), + Line::from(thread.name.clone()), + Line::from(thread.status.clone()), + ])); + } + + // Add child processes + for child in &process.child_processes { + rows.push(Row::new(vec![ + Line::from(Span::styled("[P]", Style::default().fg(Color::Green))), + Line::from(format!("{}", child.pid)), + Line::from(child.name.clone()), + Line::from(format!("{:.1}%", child.cpu_usage)), + ])); + } + + // Create table header + let header = Row::new(vec!["Type", "TID/PID", "Name", "Status/CPU"]) + .style(Style::default().add_modifier(Modifier::BOLD)) + .bottom_margin(1); + + let block = Block::default() + .title(format!( + "Threads ({}) & Children ({}) - j/k to scroll, u/d for 10x", + process.threads.len(), + process.child_processes.len() + )) + .borders(Borders::ALL); + + let table = Table::new( + rows.iter().skip(scroll_offset).take(visible_rows).cloned(), + [ + Constraint::Length(6), + Constraint::Length(10), + Constraint::Min(15), + Constraint::Length(12), + ], + ) + .header(header) + .block(block) + .highlight_style(Style::default()); + + f.render_widget(table, area); + + // Render scrollbar if there are more items than visible + if total_items > visible_rows { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + // This ensures the scrollbar position matches our actual scroll range + let mut scrollbar_state = + ScrollbarState::new(self.thread_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_journal_events( + &mut self, + f: &mut Frame, + area: Rect, + journal: &socktop_connector::JournalResponse, + ) { + let total_entries = journal.entries.len(); + let visible_lines = area.height.saturating_sub(2) as usize; // Account for borders + + // Calculate and store max scroll for key handler bounds checking + self.journal_scroll_max = if total_entries > visible_lines { + total_entries.saturating_sub(visible_lines) + } else { + 0 + }; + + // Clamp scroll offset to valid range + let scroll_offset = self.journal_scroll_offset.min(self.journal_scroll_max); + + let journal_block = Block::default() + .title(format!( + "Journal Events ({total_entries} entries) - Use [ ] to scroll" + )) + .borders(Borders::ALL); + + let content_lines: Vec = if journal.entries.is_empty() { + vec![ + Line::from(""), + Line::from(Span::styled( + "No journal entries found for this process", + Style::default().add_modifier(Modifier::DIM), + )), + ] + } else { + journal + .entries + .iter() + .skip(scroll_offset) + .take(visible_lines) + .map(|entry| { + let priority_style = match entry.priority { + socktop_connector::LogLevel::Error + | socktop_connector::LogLevel::Critical => Style::default().fg(Color::Red), + socktop_connector::LogLevel::Warning => Style::default().fg(Color::Yellow), + socktop_connector::LogLevel::Info | socktop_connector::LogLevel::Notice => { + Style::default().fg(Color::Blue) + } + _ => Style::default(), + }; + + let timestamp = &entry.timestamp[..entry.timestamp.len().min(16)]; // Show just time + let message_max_len = area.width.saturating_sub(30) as usize; // Leave space for timestamp + priority + let message = &entry.message[..entry.message.len().min(message_max_len)]; + + Line::from(vec![ + Span::styled(timestamp, Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::styled( + format!("{:>7}", format!("{:?}", entry.priority)), + priority_style, + ), + Span::raw(" "), + Span::raw(message), + if entry.message.len() > message_max_len { + Span::styled("...", Style::default().add_modifier(Modifier::DIM)) + } else { + Span::raw("") + }, + ]) + }) + .collect() + }; + + let content = Paragraph::new(content_lines).block(journal_block); + + f.render_widget(content, area); + + // Render scrollbar if there are more entries than visible + if total_entries > visible_lines { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + // Use the same max_scroll value we use for clamping + let mut scrollbar_state = + ScrollbarState::new(self.journal_scroll_max).position(scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_scatter_plot_content(&self, f: &mut Frame, area: Rect, params: ScatterPlotParams) { + if area.width < 20 || area.height < 10 { + // Area too small for meaningful plot + let content = Paragraph::new(vec![Line::from(Span::styled( + "Area too small for plot", + Style::default().fg(MODAL_HINT_FG), + ))]) + .alignment(Alignment::Center) + .style(Style::default().bg(MODAL_BG)); + f.render_widget(content, area); + return; + } + + // Calculate plot dimensions (leave space for axes labels) + let plot_width = area.width.saturating_sub(8) as usize; // Leave space for Y-axis labels + let plot_height = area.height.saturating_sub(3) as usize; // Leave space for X-axis labels + + if plot_width == 0 || plot_height == 0 { + return; + } + + // Create a 2D grid to represent the plot + let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; + + // Plot main process + let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let main_y = plot_height.saturating_sub(1).saturating_sub( + ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + if main_x < plot_width && main_y < plot_height { + plot_grid[main_y][main_x] = '●'; // Main process marker + } + + // Plot threads (use different marker) + for thread in ¶ms.process.threads { + let thread_user_ms = thread.cpu_time_user as f64 / 1000.0; + let thread_system_ms = thread.cpu_time_system as f64 / 1000.0; + + let thread_x = ((thread_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let thread_y = plot_height.saturating_sub(1).saturating_sub( + ((thread_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if thread_x < plot_width && thread_y < plot_height { + if plot_grid[thread_y][thread_x] == ' ' { + plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) + } else if plot_grid[thread_y][thread_x] == '○' { + plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point + } else { + plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point + } + } + } + + // Plot child processes + for child in ¶ms.process.child_processes { + let child_user_ms = child.cpu_time_user as f64 / 1000.0; + let child_system_ms = child.cpu_time_system as f64 / 1000.0; + + let child_x = ((child_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; + let child_y = plot_height.saturating_sub(1).saturating_sub( + ((child_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, + ); + + if child_x < plot_width && child_y < plot_height { + if plot_grid[child_y][child_x] == ' ' { + plot_grid[child_y][child_x] = '•'; // Child process marker + } else { + plot_grid[child_y][child_x] = '◉'; // Multiple items at same point + } + } + } + + // Render the plot + let mut lines = Vec::new(); + + // Add Y-axis labels and plot content + for (i, row) in plot_grid.iter().enumerate() { + let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); + // Always format with 4 characters width, right-aligned, to prevent axis shifting + let y_label = if y_value >= 100.0 { + format!("{y_value:>4.0}") + } else { + format!("{y_value:>4.1}") + }; + + let plot_content: String = row.iter().collect(); + + lines.push(Line::from(vec![ + Span::styled(y_label, Style::default()), + Span::styled(" │", Style::default()), + Span::styled(plot_content, Style::default()), + ])); + } + + // Add X-axis + let x_axis_padding = " ".to_string(); // Match Y-axis label width + let x_axis_line = "─".repeat(plot_width + 1); + lines.push(Line::from(vec![ + Span::styled(x_axis_padding, Style::default()), + Span::styled(x_axis_line, Style::default()), + ])); + + // Add X-axis labels + let x_label_start = "0.0".to_string(); + let x_label_mid = format!("{:.1}", params.max_user / 2.0); + let x_label_end = format!("{:.1}", params.max_user); + + let spacing = plot_width / 3; + let x_labels = format!( + " {}{}{}{}{}", + x_label_start, + " ".repeat(spacing.saturating_sub(x_label_start.len())), + x_label_mid, + " ".repeat(spacing.saturating_sub(x_label_mid.len())), + x_label_end + ); + + lines.push(Line::from(vec![Span::styled(x_labels, Style::default())])); + + // Add axis titles + lines.push(Line::from(vec![Span::styled( + " User CPU Time (ms) →", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Add legend + lines.insert( + 0, + Line::from(vec![Span::styled( + "System CPU Time (ms) ↑", + Style::default().add_modifier(Modifier::BOLD), + )]), + ); + lines.insert( + 1, + Line::from(vec![Span::styled( + "● Main Process ○ Thread • Child Process ◉ Multiple", + Style::default().add_modifier(Modifier::DIM), + )]), + ); + + let content = Paragraph::new(lines) + .style(Style::default()) + .alignment(Alignment::Left); + + f.render_widget(content, area); + } + + fn render_loading_top_row(&self, f: &mut Frame, area: Rect) { + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + self.render_loading_metadata(f, top_chunks[0]); + self.render_loading_scatter(f, top_chunks[1]); + } + + fn render_loading_middle_row(&self, f: &mut Frame, area: Rect) { + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_loading_graphs(f, middle_chunks[0]); + self.render_loading_table(f, middle_chunks[1]); + self.render_loading_command(f, middle_chunks[2]); + } + + fn render_loading_metadata(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Info & CPU History") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading process metadata...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_scatter(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Thread CPU Time Distribution") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading CPU time data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_graphs(&self, f: &mut Frame, area: Rect) { + let block = Block::default().title("Memory & I/O").borders(Borders::ALL); + + let content = Paragraph::new("Loading memory & I/O data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_table(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Child Processes") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading child process data...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_command(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Command & Details") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading command details...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_loading_journal_events(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Journal Events") + .borders(Borders::ALL); + + let content = Paragraph::new("Loading journal entries...") + .block(block) + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + + f.render_widget(content, area); + } + + fn render_unsupported_message(&self, f: &mut Frame, area: Rect) { + let block = Block::default() + .title("Process Details") + .borders(Borders::ALL); + + let content = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "⚠ Agent Update Required", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "This agent version does not support per-process metrics.", + Style::default().add_modifier(Modifier::DIM), + )), + Line::from(Span::styled( + "Please update your socktop_agent to the latest version.", + Style::default().add_modifier(Modifier::DIM), + )), + ]) + .block(block) + .alignment(Alignment::Center); + + f.render_widget(content, area); + } + + fn render_top_row_with_sparkline( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(60), // CPU sparkline + Constraint::Percentage(40), // Thread scatter plot + ]) + .split(area); + + self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); + self.render_thread_scatter_plot(f, top_chunks[1], process); + } + + fn render_cpu_sparkline( + &self, + f: &mut Frame, + area: Rect, + _process: &socktop_connector::DetailedProcessInfo, + cpu_history: &std::collections::VecDeque, + ) { + // Calculate actual average and current + let current_cpu = cpu_history.back().copied().unwrap_or(0.0); + let avg_cpu = if cpu_history.is_empty() { + 0.0 + } else { + cpu_history.iter().sum::() / cpu_history.len() as f32 + }; + let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); + + // Similar to main CPU rendering but for process CPU + if cpu_history.len() < 2 { + let block = Block::default().title(title).borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let content = Paragraph::new("Collecting CPU history data...") + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::DIM)); + f.render_widget(content, inner); + return; + } + + let max_points = area.width.saturating_sub(10) as usize; // Leave room for Y-axis labels + let start = cpu_history.len().saturating_sub(max_points); + + // Create data points for the chart + let data: Vec<(f64, f64)> = cpu_history + .iter() + .skip(start) + .enumerate() + .map(|(i, &val)| (i as f64, val as f64)) + .collect(); + + let datasets = vec![ + Dataset::default() + .name("CPU %") + .marker(ratatui::symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&data), + ]; + + let x_max = data.len().max(1) as f64; + let y_max = cpu_history.iter().copied().fold(0.0f32, f32::max).max(10.0) as f64; // At least 10% scale + + let y_labels = vec![ + Line::from("0%"), + Line::from(format!("{:.0}%", y_max / 2.0)), + Line::from(format!("{y_max:.0}%")), + ]; + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::ALL).title(title)) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .bounds([0.0, x_max]), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::Gray)) + .labels(y_labels) + .bounds([0.0, y_max]), + ); + + f.render_widget(chart, area); + } + + fn render_middle_row_with_metadata( + &mut self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + mem_history: &std::collections::VecDeque, + io_read_history: &std::collections::VecDeque, + io_write_history: &std::collections::VecDeque, + ) { + // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(area); + + self.render_memory_io_graphs( + f, + middle_chunks[0], + process, + mem_history, + io_read_history, + io_write_history, + ); + self.render_thread_table(f, middle_chunks[1], process); + self.render_command_and_metadata(f, middle_chunks[2], process); + } + + fn render_command_and_metadata( + &self, + f: &mut Frame, + area: Rect, + process: &socktop_connector::DetailedProcessInfo, + ) { + let details_block = Block::default() + .title("Command & Details") + .borders(Borders::ALL); + + // Calculate uptime + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let uptime_secs = now.saturating_sub(process.start_time); + let uptime_str = format_uptime(uptime_secs); + + // Format CPU times + let user_time_sec = process.cpu_time_user as f64 / 1_000_000.0; + let system_time_sec = process.cpu_time_system as f64 / 1_000_000.0; + + let mut content_lines = vec![ + Line::from(vec![ + Span::styled("⚡ Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&process.status), + ]), + Line::from(vec![ + Span::styled( + "⏱️ Uptime: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(uptime_str), + ]), + Line::from(vec![ + Span::styled( + "🧵 Threads: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", process.thread_count)), + ]), + Line::from(vec![ + Span::styled( + "👶 Children: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{}", process.child_processes.len())), + ]), + ]; + + // Add file descriptors if available + if let Some(fd_count) = process.fd_count { + content_lines.push(Line::from(vec![ + Span::styled("📁 FDs: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{fd_count}")), + ])); + } + + content_lines.push(Line::from("")); + + // Process hierarchy with clickable parent PID + if let Some(ppid) = process.parent_pid { + content_lines.push(Line::from(vec![ + Span::styled( + "👪 Parent PID: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{ppid} "), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + ), + Span::styled( + "[P to open]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::DIM), + ), + ])); + } + + content_lines.push(Line::from(vec![ + Span::styled("👤 UID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.user_id)), + Span::styled(" 👥 GID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", process.group_id)), + ])); + + content_lines.push(Line::from("")); + content_lines.push(Line::from(vec![Span::styled( + "⏲️ CPU Time", + Style::default().add_modifier(Modifier::BOLD), + )])); + content_lines.push(Line::from(vec![ + Span::styled(" User: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{user_time_sec:.2}s")), + ])); + content_lines.push(Line::from(vec![ + Span::styled(" System: ", Style::default().add_modifier(Modifier::DIM)), + Span::raw(format!("{system_time_sec:.2}s")), + ])); + + content_lines.push(Line::from("")); + + // Executable path if available + if let Some(exe) = &process.executable_path { + content_lines.push(Line::from(vec![Span::styled( + "📂 Executable:", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(4)) as usize; + if exe.len() > max_width { + let truncated = format!("...{}", &exe[exe.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(Span::styled( + truncated, + Style::default().add_modifier(Modifier::DIM), + ))); + } else { + content_lines.push(Line::from(Span::styled( + exe, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + + // Working directory if available + if let Some(cwd) = &process.working_directory { + content_lines.push(Line::from(vec![Span::styled( + "📁 Working Dir:", + Style::default().add_modifier(Modifier::BOLD), + )])); + // Truncate if too long + let max_width = (area.width.saturating_sub(4)) as usize; + if cwd.len() > max_width { + let truncated = format!("...{}", &cwd[cwd.len().saturating_sub(max_width - 3)..]); + content_lines.push(Line::from(Span::styled( + truncated, + Style::default().add_modifier(Modifier::DIM), + ))); + } else { + content_lines.push(Line::from(Span::styled( + cwd, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + + content_lines.push(Line::from("")); + + // Add command line (wrap if needed) + content_lines.push(Line::from(vec![Span::styled( + "⚙️ Command:", + Style::default().add_modifier(Modifier::BOLD), + )])); + + // Split command into multiple lines if too long + let cmd_text = &process.command; + let max_width = (area.width.saturating_sub(4)) as usize; + if cmd_text.len() > max_width { + for chunk in cmd_text.as_bytes().chunks(max_width) { + if let Ok(s) = std::str::from_utf8(chunk) { + content_lines.push(Line::from(Span::styled( + s, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + } else { + content_lines.push(Line::from(Span::styled( + cmd_text, + Style::default().add_modifier(Modifier::DIM), + ))); + } + + let content = Paragraph::new(content_lines) + .block(details_block) + .wrap(Wrap { trim: false }); + + f.render_widget(content, area); + } +} + +fn format_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + + if days > 0 { + format!("{days}d {hours}h {minutes}m") + } else if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } } fn format_duration(duration: Duration) -> String { diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index 0855055..d506016 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -12,7 +12,10 @@ use std::cmp::Ordering; use crate::types::Metrics; use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse}; -use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK}; +use crate::ui::theme::{ + PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW, + SB_THUMB, SB_TRACK, +}; use crate::ui::util::human; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -37,6 +40,8 @@ pub fn draw_top_processes( m: Option<&Metrics>, scroll_offset: usize, sort_by: ProcSortBy, + selected_process_pid: Option, + selected_process_index: Option, ) { // Draw outer block and title let Some(mm) = m else { return }; @@ -110,12 +115,29 @@ pub fn draw_top_processes( _ => Color::Red, }; - let emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON { + let mut emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON { Style::default().add_modifier(Modifier::BOLD) } else { Style::default() }; + // Check if this process is selected - prioritize PID matching + let is_selected = if let Some(selected_pid) = selected_process_pid { + selected_pid == p.pid + } else if let Some(selected_idx) = selected_process_index { + selected_idx == ix // ix is the absolute index in the sorted list + } else { + false + }; + + // Apply selection highlighting + if is_selected { + emphasis = emphasis + .bg(PROCESS_SELECTION_BG) + .fg(PROCESS_SELECTION_FG) + .add_modifier(Modifier::BOLD); + } + let cpu_str = fmt_cpu_pct(cpu_val); ratatui::widgets::Row::new(vec![ @@ -151,6 +173,47 @@ pub fn draw_top_processes( .column_spacing(1); f.render_widget(table, content); + // Draw tooltip if a process is selected + if let Some(selected_pid) = selected_process_pid { + // Find the selected process to get its name + let process_info = if let Some(metrics) = m { + metrics + .top_processes + .iter() + .find(|p| p.pid == selected_pid) + .map(|p| format!("PID {} • {}", p.pid, p.name)) + .unwrap_or_else(|| format!("PID {selected_pid}")) + } else { + format!("PID {selected_pid}") + }; + + let tooltip_text = format!("{process_info} | Enter for details • X to unselect"); + let tooltip_width = tooltip_text.len() as u16 + 2; // Add padding + let tooltip_height = 3; + + // Position tooltip at bottom-right of the processes area + if area.width > tooltip_width + 2 && area.height > tooltip_height + 1 { + let tooltip_area = Rect { + x: area.x + area.width.saturating_sub(tooltip_width + 1), + y: area.y + area.height.saturating_sub(tooltip_height + 1), + width: tooltip_width, + height: tooltip_height, + }; + + let tooltip_block = Block::default().borders(Borders::ALL).style( + Style::default() + .bg(PROCESS_TOOLTIP_BG) + .fg(PROCESS_TOOLTIP_FG), + ); + + let tooltip_paragraph = Paragraph::new(tooltip_text) + .block(tooltip_block) + .wrap(ratatui::widgets::Wrap { trim: true }); + + f.render_widget(tooltip_paragraph, tooltip_area); + } + } + // Draw scrollbar like CPU pane let scroll_area = Rect { x: inner.x + inner.width.saturating_sub(1), @@ -191,6 +254,8 @@ fn fmt_cpu_pct(v: f32) -> String { } /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) +/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality +#[allow(dead_code)] pub fn processes_handle_key( scroll_offset: &mut usize, key: crossterm::event::KeyEvent, @@ -199,8 +264,44 @@ pub fn processes_handle_key( crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); } +/// Enhanced keyboard handler that also manages process selection +pub fn processes_handle_key_with_selection( + _scroll_offset: &mut usize, + selected_process_pid: &mut Option, + selected_process_index: &mut Option, + key: crossterm::event::KeyEvent, + _page_size: usize, + _total_rows: usize, + _metrics: Option<&Metrics>, +) -> bool { + use crossterm::event::KeyCode; + + match key.code { + KeyCode::Char('x') | KeyCode::Char('X') => { + // Unselect any selected process + if selected_process_pid.is_some() || selected_process_index.is_some() { + *selected_process_pid = None; + *selected_process_index = None; + true // Handled + } else { + false // No selection to clear + } + } + KeyCode::Enter => { + // Signal that Enter was pressed with a selection + selected_process_pid.is_some() // Return true if we have a selection to handle + } + _ => { + // No other keys handled - let scrollbar handle all navigation + false + } + } +} + /// Handle mouse for content scrolling and scrollbar dragging. /// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked. +/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality +#[allow(dead_code)] pub fn processes_handle_mouse( scroll_offset: &mut usize, drag: &mut Option, @@ -264,3 +365,127 @@ pub fn processes_handle_mouse( ); None } + +/// Parameters for process mouse event handling +pub struct ProcessMouseParams<'a> { + pub scroll_offset: &'a mut usize, + pub selected_process_pid: &'a mut Option, + pub selected_process_index: &'a mut Option, + pub drag: &'a mut Option, + pub mouse: MouseEvent, + pub area: Rect, + pub total_rows: usize, + pub metrics: Option<&'a Metrics>, + pub sort_by: ProcSortBy, +} + +/// Enhanced mouse handler that also manages process selection +/// Returns Some(new_sort) if the header was clicked, or handles row selection +pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Option { + // Inner and content areas (match draw_top_processes) + let inner = Rect { + x: params.area.x + 1, + y: params.area.y + 1, + width: params.area.width.saturating_sub(2), + height: params.area.height.saturating_sub(2), + }; + if inner.height == 0 || inner.width <= 2 { + return None; + } + let content = Rect { + x: inner.x, + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height, + }; + + // Scrollbar interactions (click arrows/page/drag) + per_core_handle_scrollbar_mouse( + params.scroll_offset, + params.drag, + params.mouse, + params.area, + params.total_rows, + ); + + // Wheel scrolling when inside the content + crate::ui::cpu::per_core_handle_mouse( + params.scroll_offset, + params.mouse, + content, + content.height as usize, + ); + + // Header click to change sort + let header_area = Rect { + x: content.x, + y: content.y, + width: content.width, + height: 1, + }; + let inside_header = params.mouse.row == header_area.y + && params.mouse.column >= header_area.x + && params.mouse.column < header_area.x + header_area.width; + + if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + // Split header into the same columns + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints(COLS.to_vec()) + .split(header_area); + if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width { + return Some(ProcSortBy::CpuDesc); + } + if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width { + return Some(ProcSortBy::MemDesc); + } + } + + // Row click for process selection + let data_start_row = content.y + 1; // Skip header + let data_area_height = content.height.saturating_sub(1); // Exclude header + + if matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && params.mouse.row >= data_start_row + && params.mouse.row < data_start_row + data_area_height + && params.mouse.column >= content.x + && params.mouse.column < content.x + content.width + { + let clicked_row = (params.mouse.row - data_start_row) as usize; + + // Find the actual process using the same sorting logic as the drawing code + if let Some(m) = params.metrics { + // Create the same sorted index array as in draw_top_processes + let mut idxs: Vec = (0..m.top_processes.len()).collect(); + match params.sort_by { + ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].cpu_usage; + let bb = m.top_processes[b].cpu_usage; + bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal) + }), + ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { + let aa = m.top_processes[a].mem_bytes; + let bb = m.top_processes[b].mem_bytes; + bb.cmp(&aa) + }), + } + + // Calculate which process was actually clicked based on sorted order + let visible_process_position = *params.scroll_offset + clicked_row; + if visible_process_position < idxs.len() { + let actual_process_index = idxs[visible_process_position]; + let clicked_process = &m.top_processes[actual_process_index]; + *params.selected_process_pid = Some(clicked_process.pid); + *params.selected_process_index = Some(actual_process_index); + } + } + } + + // Clamp to valid range + per_core_clamp( + params.scroll_offset, + params.total_rows, + (content.height.saturating_sub(1)) as usize, + ); + None +} diff --git a/socktop/src/ui/theme.rs b/socktop/src/ui/theme.rs index 5e79fef..7b4d1bf 100644 --- a/socktop/src/ui/theme.rs +++ b/socktop/src/ui/theme.rs @@ -30,6 +30,15 @@ pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46); pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255); +// Process selection colors +pub const PROCESS_SELECTION_BG: Color = Color::Rgb(147, 112, 219); // Medium slate blue (purple) +pub const PROCESS_SELECTION_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast +pub const PROCESS_TOOLTIP_BG: Color = Color::Rgb(147, 112, 219); // Same purple as selection +pub const PROCESS_TOOLTIP_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast + +// Process details modal colors (matches main UI aesthetic - no custom colors, terminal defaults) +pub const PROCESS_DETAILS_ACCENT: Color = Color::Rgb(147, 112, 219); // Purple accent for highlights + // Emoji / icon strings (centralized so they can be themed/swapped later) pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR "; pub const ICON_CLUSTER: &str = "⚠️"; diff --git a/socktop_agent/Cargo.toml b/socktop_agent/Cargo.toml index 8a58762..68add85 100644 --- a/socktop_agent/Cargo.toml +++ b/socktop_agent/Cargo.toml @@ -28,6 +28,8 @@ anyhow = "1" hostname = "0.3" prost = { workspace = true } time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] } +# For executing journalctl commands +tokio-process = "0.2" [build-dependencies] prost-build = "0.13" diff --git a/socktop_agent/src/cache.rs b/socktop_agent/src/cache.rs new file mode 100644 index 0000000..816c5b4 --- /dev/null +++ b/socktop_agent/src/cache.rs @@ -0,0 +1,95 @@ +//! Caching for process metrics and journal entries + +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +use crate::types::{ProcessMetricsResponse, JournalResponse}; + +#[derive(Debug, Clone)] +struct CacheEntry { + data: T, + cached_at: Instant, + ttl: Duration, +} + +impl CacheEntry { + fn is_expired(&self) -> bool { + self.cached_at.elapsed() > self.ttl + } +} + +#[derive(Debug)] +pub struct ProcessCache { + process_metrics: RwLock>>, + journal_entries: RwLock>>, +} + +impl ProcessCache { + pub fn new() -> Self { + Self { + process_metrics: RwLock::new(HashMap::new()), + journal_entries: RwLock::new(HashMap::new()), + } + } + + /// Get cached process metrics if available and not expired (250ms TTL) + pub async fn get_process_metrics(&self, pid: u32) -> Option { + let cache = self.process_metrics.read().await; + if let Some(entry) = cache.get(&pid) { + if !entry.is_expired() { + return Some(entry.data.clone()); + } + } + None + } + + /// Cache process metrics with 250ms TTL + pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) { + let mut cache = self.process_metrics.write().await; + cache.insert(pid, CacheEntry { + data, + cached_at: Instant::now(), + ttl: Duration::from_millis(250), + }); + } + + /// Get cached journal entries if available and not expired (1s TTL) + pub async fn get_journal_entries(&self, pid: u32) -> Option { + let cache = self.journal_entries.read().await; + if let Some(entry) = cache.get(&pid) { + if !entry.is_expired() { + return Some(entry.data.clone()); + } + } + None + } + + /// Cache journal entries with 1s TTL + pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) { + let mut cache = self.journal_entries.write().await; + cache.insert(pid, CacheEntry { + data, + cached_at: Instant::now(), + ttl: Duration::from_secs(1), + }); + } + + /// Clean up expired entries periodically + pub async fn cleanup_expired(&self) { + { + let mut cache = self.process_metrics.write().await; + cache.retain(|_, entry| !entry.is_expired()); + } + { + let mut cache = self.journal_entries.write().await; + cache.retain(|_, entry| !entry.is_expired()); + } + } +} + +impl Default for ProcessCache { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/socktop_agent/src/lib.rs b/socktop_agent/src/lib.rs new file mode 100644 index 0000000..991c725 --- /dev/null +++ b/socktop_agent/src/lib.rs @@ -0,0 +1,17 @@ +//! Library interface for socktop_agent functionality +//! This allows testing of agent functions. + +pub mod gpu; +pub mod metrics; +pub mod proto; +pub mod state; +pub mod tls; +pub mod types; +pub mod ws; + +// Re-export commonly used types and functions for testing +pub use metrics::{collect_journal_entries, collect_process_metrics}; +pub use state::{AppState, CacheEntry}; +pub use types::{ + DetailedProcessInfo, JournalEntry, JournalResponse, LogLevel, ProcessMetricsResponse, +}; diff --git a/socktop_agent/src/metrics.rs b/socktop_agent/src/metrics.rs index c8dacc3..4cdc4ca 100644 --- a/socktop_agent/src/metrics.rs +++ b/socktop_agent/src/metrics.rs @@ -2,7 +2,10 @@ use crate::gpu::collect_all_gpus; use crate::state::AppState; -use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload}; +use crate::types::{ + DetailedProcessInfo, DiskInfo, JournalEntry, JournalResponse, LogLevel, Metrics, NetworkInfo, + ProcessInfo, ProcessMetricsResponse, ProcessesPayload, +}; use once_cell::sync::OnceCell; #[cfg(target_os = "linux")] use std::collections::HashMap; @@ -10,13 +13,55 @@ use std::collections::HashMap; use std::fs; #[cfg(target_os = "linux")] use std::io; +use std::process::Command; use std::sync::Mutex; use std::time::Duration as StdDuration; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; use tracing::warn; // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. + +// Helper functions to get CPU time from /proc/stat on Linux +#[cfg(target_os = "linux")] +fn get_cpu_time_user(pid: u32) -> u64 { + if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let fields: Vec<&str> = stat.split_whitespace().collect(); + if fields.len() > 13 { + // Field 13 (0-indexed) is utime (user CPU time in clock ticks) + if let Ok(utime) = fields[13].parse::() { + // Convert clock ticks to milliseconds (assuming 100 Hz) + return utime * 10; // 1 tick = 10ms at 100 Hz + } + } + } + 0 +} + +#[cfg(target_os = "linux")] +fn get_cpu_time_system(pid: u32) -> u64 { + if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let fields: Vec<&str> = stat.split_whitespace().collect(); + if fields.len() > 14 { + // Field 14 (0-indexed) is stime (system CPU time in clock ticks) + if let Ok(stime) = fields[14].parse::() { + // Convert clock ticks to milliseconds (assuming 100 Hz) + return stime * 10; // 1 tick = 10ms at 100 Hz + } + } + } + 0 +} + +#[cfg(not(target_os = "linux"))] +fn get_cpu_time_user(_pid: u32) -> u64 { + 0 // Not implemented for non-Linux platforms +} + +#[cfg(not(target_os = "linux"))] +fn get_cpu_time_system(_pid: u32) -> u64 { + 0 // Not implemented for non-Linux platforms +} // Runtime toggles (read once) fn gpu_enabled() -> bool { static ON: OnceCell = OnceCell::new(); @@ -549,3 +594,616 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { } payload } + +/// Lightweight child process enumeration using direct /proc access +/// This avoids the expensive refresh_processes_specifics(All) call +#[cfg(target_os = "linux")] +fn enumerate_child_processes_lightweight( + parent_pid: u32, + system: &sysinfo::System, +) -> Vec { + let mut children = Vec::new(); + + // Read /proc to find all child processes + // This is much faster than refresh_processes_specifics(All) + if let Ok(entries) = fs::read_dir("/proc") { + for entry in entries.flatten() { + if let Ok(file_name) = entry.file_name().into_string() { + if let Ok(pid) = file_name.parse::() { + // Check if this process is a child of our target + if let Some(child_parent_pid) = read_parent_pid_from_proc(pid) { + if child_parent_pid == parent_pid { + // Found a child! Collect its details from /proc + if let Some(child_info) = collect_process_info_from_proc(pid, system) { + children.push(child_info); + } + } + } + } + } + } + } + + children +} + +/// Read parent PID from /proc/{pid}/stat +#[cfg(target_os = "linux")] +fn read_parent_pid_from_proc(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // Format: pid (comm) state ppid ... + // We need to handle process names with spaces/parentheses + let ppid_start = stat.rfind(')')?; + let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect(); + // After the closing paren: state ppid ... + // Field 1 (0-indexed) is ppid + fields.get(1)?.parse::().ok() +} + +/// Collect process information from /proc files +#[cfg(target_os = "linux")] +fn collect_process_info_from_proc( + pid: u32, + system: &sysinfo::System, +) -> Option { + // Try to get basic info from sysinfo if it's already loaded (cheap lookup) + // Otherwise read from /proc directly + let (name, cpu_usage, mem_bytes, virtual_mem_bytes) = + if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) { + ( + proc.name().to_string_lossy().to_string(), + proc.cpu_usage(), + proc.memory(), + proc.virtual_memory(), + ) + } else { + // Process not in sysinfo cache, read minimal info from /proc + let name = fs::read_to_string(format!("/proc/{pid}/comm")) + .ok()? + .trim() + .to_string(); + + // Read memory from /proc/{pid}/status + let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let mut mem_bytes = 0u64; + let mut virtual_mem_bytes = 0u64; + + for line in status_content.lines() { + if let Some(value) = line.strip_prefix("VmRSS:") { + if let Some(kb) = value.split_whitespace().next() { + mem_bytes = kb.parse::().unwrap_or(0) * 1024; + } + } else if let Some(value) = line.strip_prefix("VmSize:") { + if let Some(kb) = value.split_whitespace().next() { + virtual_mem_bytes = kb.parse::().unwrap_or(0) * 1024; + } + } + } + + (name, 0.0, mem_bytes, virtual_mem_bytes) + }; + + // Read command line + let command = fs::read_to_string(format!("/proc/{pid}/cmdline")) + .ok() + .map(|s| s.replace('\0', " ").trim().to_string()) + .unwrap_or_default(); + + // Read status information + let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let mut uid = 0u32; + let mut gid = 0u32; + let mut thread_count = 0u32; + let mut status = "Unknown".to_string(); + + for line in status_content.lines() { + if let Some(value) = line.strip_prefix("Uid:") { + if let Some(uid_str) = value.split_whitespace().next() { + uid = uid_str.parse().unwrap_or(0); + } + } else if let Some(value) = line.strip_prefix("Gid:") { + if let Some(gid_str) = value.split_whitespace().next() { + gid = gid_str.parse().unwrap_or(0); + } + } else if let Some(value) = line.strip_prefix("Threads:") { + thread_count = value.trim().parse().unwrap_or(0); + } else if let Some(value) = line.strip_prefix("State:") { + status = value + .trim() + .chars() + .next() + .map(|c| match c { + 'R' => "Running", + 'S' => "Sleeping", + 'D' => "Disk Sleep", + 'Z' => "Zombie", + 'T' => "Stopped", + 't' => "Tracing Stop", + 'X' | 'x' => "Dead", + 'K' => "Wakekill", + 'W' => "Waking", + 'P' => "Parked", + 'I' => "Idle", + _ => "Unknown", + }) + .unwrap_or("Unknown") + .to_string(); + } + } + + // Read start time from stat + let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let stat_end = stat.rfind(')')?; + let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect(); + // Field 19 (0-indexed) is starttime in clock ticks since boot + fields.get(19)?.parse::().ok()? + } else { + 0 + }; + + // Read I/O stats if available + let (read_bytes, write_bytes) = + if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) { + let mut read_bytes = None; + let mut write_bytes = None; + + for line in io_content.lines() { + if let Some(value) = line.strip_prefix("read_bytes:") { + read_bytes = value.trim().parse().ok(); + } else if let Some(value) = line.strip_prefix("write_bytes:") { + write_bytes = value.trim().parse().ok(); + } + } + + (read_bytes, write_bytes) + } else { + (None, None) + }; + + // Read working directory + let working_directory = fs::read_link(format!("/proc/{pid}/cwd")) + .ok() + .map(|p| p.to_string_lossy().to_string()); + + // Read executable path + let executable_path = fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .map(|p| p.to_string_lossy().to_string()); + + Some(DetailedProcessInfo { + pid, + name, + command, + cpu_usage, + mem_bytes, + virtual_mem_bytes, + shared_mem_bytes: None, // Would need to parse /proc/{pid}/statm for this + thread_count, + fd_count: None, // Would need to count entries in /proc/{pid}/fd + status, + parent_pid: None, // We already know the parent + user_id: uid, + group_id: gid, + start_time, + cpu_time_user: get_cpu_time_user(pid), + cpu_time_system: get_cpu_time_system(pid), + read_bytes, + write_bytes, + working_directory, + executable_path, + child_processes: Vec::new(), // Don't recurse + threads: Vec::new(), // Not collected for child processes + }) +} + +/// Fallback for non-Linux: use sysinfo (less efficient but functional) +#[cfg(not(target_os = "linux"))] +fn enumerate_child_processes_lightweight( + parent_pid: u32, + system: &sysinfo::System, +) -> Vec { + let mut children = Vec::new(); + + // On non-Linux, we have to iterate through all processes in sysinfo + // This is less efficient but maintains cross-platform compatibility + for (child_pid, child_process) in system.processes() { + if let Some(parent) = child_process.parent() { + if parent.as_u32() == parent_pid { + let child_info = DetailedProcessInfo { + pid: child_pid.as_u32(), + name: child_process.name().to_string_lossy().to_string(), + command: child_process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>() + .join(" "), + cpu_usage: child_process.cpu_usage(), + mem_bytes: child_process.memory(), + virtual_mem_bytes: child_process.virtual_memory(), + shared_mem_bytes: None, + thread_count: child_process + .tasks() + .map(|tasks| tasks.len() as u32) + .unwrap_or(0), + fd_count: None, + status: format!("{:?}", child_process.status()), + parent_pid: Some(parent_pid), + // On non-Linux platforms, sysinfo UID/GID might not be accurate + // Just use 0 as placeholder since we can't read /proc + user_id: 0, + group_id: 0, + start_time: child_process.start_time(), + cpu_time_user: 0, // Not available on non-Linux in our implementation + cpu_time_system: 0, + read_bytes: Some(child_process.disk_usage().read_bytes), + write_bytes: Some(child_process.disk_usage().written_bytes), + working_directory: child_process.cwd().map(|p| p.to_string_lossy().to_string()), + executable_path: child_process.exe().map(|p| p.to_string_lossy().to_string()), + child_processes: Vec::new(), + threads: Vec::new(), // Not collected for non-Linux + }; + children.push(child_info); + } + } + } + + children +} + +/// Collect thread information for a specific process (Linux only) +#[cfg(target_os = "linux")] +fn collect_thread_info(pid: u32) -> Vec { + let mut threads = Vec::new(); + + // Read /proc/{pid}/task directory + let task_dir = format!("/proc/{pid}/task"); + let Ok(entries) = fs::read_dir(&task_dir) else { + return threads; + }; + + for entry in entries.flatten() { + let file_name = entry.file_name(); + let tid_str = file_name.to_string_lossy(); + let Ok(tid) = tid_str.parse::() else { + continue; + }; + + // Read thread name from comm + let name = fs::read_to_string(format!("/proc/{pid}/task/{tid}/comm")) + .unwrap_or_else(|_| format!("Thread-{tid}")) + .trim() + .to_string(); + + // Read thread stat for CPU times and status + let stat_path = format!("/proc/{pid}/task/{tid}/stat"); + let Ok(stat_content) = fs::read_to_string(&stat_path) else { + continue; + }; + + // Parse stat file (similar format to process stat) + // Fields: pid comm state ... utime stime ... + let fields: Vec<&str> = stat_content.split_whitespace().collect(); + if fields.len() < 15 { + continue; + } + + // Field 2 is state (R, S, D, Z, T, etc.) + let status = fields + .get(2) + .and_then(|s| s.chars().next()) + .map(|c| match c { + 'R' => "Running", + 'S' => "Sleeping", + 'D' => "Disk Sleep", + 'Z' => "Zombie", + 'T' => "Stopped", + 't' => "Tracing Stop", + 'X' | 'x' => "Dead", + _ => "Unknown", + }) + .unwrap_or("Unknown") + .to_string(); + + // Field 13 is utime (user CPU time in clock ticks) + // Field 14 is stime (system CPU time in clock ticks) + let utime = fields + .get(13) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let stime = fields + .get(14) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + // Convert clock ticks to microseconds (assuming 100 Hz) + // 1 tick = 10ms = 10,000 microseconds + let cpu_time_user = utime * 10_000; + let cpu_time_system = stime * 10_000; + + threads.push(crate::types::ThreadInfo { + tid, + name, + cpu_time_user, + cpu_time_system, + status, + }); + } + + threads +} + +/// Fallback for non-Linux: return empty thread list +#[cfg(not(target_os = "linux"))] +fn collect_thread_info(_pid: u32) -> Vec { + Vec::new() +} + +/// Collect detailed metrics for a specific process +pub async fn collect_process_metrics( + pid: u32, + state: &AppState, +) -> Result { + let mut system = state.sys.lock().await; + + // OPTIMIZED: Only refresh the specific process we care about + // This avoids polluting the main process list with threads and prevents race conditions + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]), + false, + ProcessRefreshKind::nothing() + .with_memory() + .with_cpu() + .with_disk_usage(), + ); + + let process = system + .process(sysinfo::Pid::from_u32(pid)) + .ok_or_else(|| format!("Process {pid} not found"))?; + + // Get current timestamp + let cached_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Time error: {e}"))? + .as_secs(); + + // Extract all needed data from process while we have the lock + let name = process.name().to_string_lossy().to_string(); + let command = process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>() + .join(" "); + let cpu_usage = process.cpu_usage(); + let mem_bytes = process.memory(); + let virtual_mem_bytes = process.virtual_memory(); + let thread_count = process.tasks().map(|tasks| tasks.len() as u32).unwrap_or(0); + let status = format!("{:?}", process.status()); + let parent_pid = process.parent().map(|p| p.as_u32()); + let start_time = process.start_time(); + + // Read UID and GID directly from /proc/{pid}/status for accuracy + let (user_id, group_id) = + if let Ok(status_content) = fs::read_to_string(format!("/proc/{pid}/status")) { + let mut uid = 0u32; + let mut gid = 0u32; + + for line in status_content.lines() { + if let Some(value) = line.strip_prefix("Uid:") { + // Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem) + // We want the real UID (first value) + if let Some(uid_str) = value.split_whitespace().next() { + uid = uid_str.parse().unwrap_or(0); + } + } else if let Some(value) = line.strip_prefix("Gid:") { + // Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem) + // We want the real GID (first value) + if let Some(gid_str) = value.split_whitespace().next() { + gid = gid_str.parse().unwrap_or(0); + } + } + } + + (uid, gid) + } else { + // Fallback if /proc read fails (non-Linux or permission issue) + (0, 0) + }; + + // Read I/O stats directly from /proc/{pid}/io + // Use rchar/wchar to capture ALL I/O including cached reads (like htop/btop do) + // sysinfo's total_read_bytes/total_written_bytes only count actual disk I/O + let (read_bytes, write_bytes) = + if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) { + let mut rchar = 0u64; + let mut wchar = 0u64; + + for line in io_content.lines() { + if let Some(value) = line.strip_prefix("rchar: ") { + rchar = value.trim().parse().unwrap_or(0); + } else if let Some(value) = line.strip_prefix("wchar: ") { + wchar = value.trim().parse().unwrap_or(0); + } + } + + (Some(rchar), Some(wchar)) + } else { + // Fallback to sysinfo if we can't read /proc (permissions, non-Linux, etc.) + let disk_usage = process.disk_usage(); + ( + Some(disk_usage.total_read_bytes), + Some(disk_usage.total_written_bytes), + ) + }; + let working_directory = process.cwd().map(|p| p.to_string_lossy().to_string()); + let executable_path = process.exe().map(|p| p.to_string_lossy().to_string()); + + // Collect child processes using lightweight /proc access + // This avoids the expensive system.refresh_processes_specifics(All) call + let child_processes = enumerate_child_processes_lightweight(pid, &system); + + // Release the system lock early (automatic when system goes out of scope) + drop(system); + + // Collect thread information (Linux only) + let threads = collect_thread_info(pid); + + // Now construct the detailed info without holding the lock + let detailed_info = DetailedProcessInfo { + pid, + name, + command, + cpu_usage, + mem_bytes, + virtual_mem_bytes, + shared_mem_bytes: None, // Not available from sysinfo + thread_count, + fd_count: None, // Not available from sysinfo on all platforms + status, + parent_pid, + user_id, + group_id, + start_time, + cpu_time_user: get_cpu_time_user(pid), + cpu_time_system: get_cpu_time_system(pid), + read_bytes, + write_bytes, + working_directory, + executable_path, + child_processes, + threads, + }; + + Ok(ProcessMetricsResponse { + process: detailed_info, + cached_at, + }) +} + +/// Collect journal entries for a specific process +pub fn collect_journal_entries(pid: u32) -> Result { + let output = Command::new("journalctl") + .args([ + &format!("_PID={pid}"), + "--output=json", + "--lines=100", + "--no-pager", + ]) + .output() + .map_err(|e| format!("Failed to execute journalctl: {e}"))?; + + if !output.status.success() { + return Err(format!( + "journalctl failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut entries = Vec::new(); + + // Parse each line as JSON (journalctl outputs one JSON object per line) + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } + + let json: serde_json::Value = + serde_json::from_str(line).map_err(|e| format!("Failed to parse journal JSON: {e}"))?; + + // Extract relevant fields + let timestamp_str = json + .get("__REALTIME_TIMESTAMP") + .and_then(|v| v.as_str()) + .unwrap_or("0"); + + // Convert timestamp to ISO 8601 format + let timestamp = if let Ok(ts_micros) = timestamp_str.parse::() { + let ts_secs = ts_micros / 1_000_000; + let ts_nanos = (ts_micros % 1_000_000) * 1000; + let time = SystemTime::UNIX_EPOCH + + Duration::from_secs(ts_secs) + + Duration::from_nanos(ts_nanos); + // Simple ISO 8601 format - we can improve this if needed + format!("{time:?}") + .replace("SystemTime { tv_sec: ", "") + .replace(", tv_nsec: ", ".") + .replace(" }", "") + } else { + timestamp_str.to_string() + }; + + let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) { + Some("0") => LogLevel::Emergency, + Some("1") => LogLevel::Alert, + Some("2") => LogLevel::Critical, + Some("3") => LogLevel::Error, + Some("4") => LogLevel::Warning, + Some("5") => LogLevel::Notice, + Some("6") => LogLevel::Info, + Some("7") => LogLevel::Debug, + _ => LogLevel::Info, + }; + + let message = json + .get("MESSAGE") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let unit = json + .get("_SYSTEMD_UNIT") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let entry_pid = json + .get("_PID") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + let comm = json + .get("_COMM") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let uid = json + .get("_UID") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + let gid = json + .get("_GID") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + entries.push(JournalEntry { + timestamp, + priority, + message, + unit, + pid: entry_pid, + comm, + uid, + gid, + }); + } + + // Sort by timestamp (newest first) + entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + let response_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Time error: {e}"))? + .as_secs(); + + let total_count = entries.len() as u32; + let truncated = entries.len() >= 100; // We requested 100 lines, so if we got 100, there might be more + + Ok(JournalResponse { + entries, + total_count, + truncated, + cached_at: response_timestamp, + }) +} diff --git a/socktop_agent/src/state.rs b/socktop_agent/src/state.rs index ac7a67c..344b877 100644 --- a/socktop_agent/src/state.rs +++ b/socktop_agent/src/state.rs @@ -63,6 +63,11 @@ pub struct AppState { pub cache_metrics: Arc>>, pub cache_disks: Arc>>>, pub cache_processes: Arc>>, + + // Process detail caches (per-PID) + pub cache_process_metrics: + Arc>>>, + pub cache_journal_entries: Arc>>>, } #[derive(Clone, Debug)] @@ -71,6 +76,12 @@ pub struct CacheEntry { pub value: Option, } +impl Default for CacheEntry { + fn default() -> Self { + Self::new() + } +} + impl CacheEntry { pub fn new() -> Self { Self { @@ -90,6 +101,12 @@ impl CacheEntry { } } +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + impl AppState { pub fn new() -> Self { let sys = System::new(); @@ -116,6 +133,8 @@ impl AppState { cache_metrics: Arc::new(Mutex::new(CacheEntry::new())), cache_disks: Arc::new(Mutex::new(CacheEntry::new())), cache_processes: Arc::new(Mutex::new(CacheEntry::new())), + cache_process_metrics: Arc::new(Mutex::new(HashMap::new())), + cache_journal_entries: Arc::new(Mutex::new(HashMap::new())), } } } diff --git a/socktop_agent/src/types.rs b/socktop_agent/src/types.rs index dc2b05f..4b7d37c 100644 --- a/socktop_agent/src/types.rs +++ b/socktop_agent/src/types.rs @@ -47,3 +47,76 @@ pub struct ProcessesPayload { pub process_count: usize, pub top_processes: Vec, } + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadInfo { + pub tid: u32, // Thread ID + pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm) + pub cpu_time_user: u64, // User CPU time in microseconds + pub cpu_time_system: u64, // System CPU time in microseconds + pub status: String, // Thread status (Running, Sleeping, etc.) +} + +#[derive(Debug, Clone, Serialize)] +pub struct DetailedProcessInfo { + pub pid: u32, + pub name: String, + pub command: String, + pub cpu_usage: f32, + pub mem_bytes: u64, + pub virtual_mem_bytes: u64, + pub shared_mem_bytes: Option, + pub thread_count: u32, + pub fd_count: Option, + pub status: String, + pub parent_pid: Option, + pub user_id: u32, + pub group_id: u32, + pub start_time: u64, // Unix timestamp + pub cpu_time_user: u64, // Microseconds + pub cpu_time_system: u64, // Microseconds + pub read_bytes: Option, + pub write_bytes: Option, + pub working_directory: Option, + pub executable_path: Option, + pub child_processes: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProcessMetricsResponse { + pub process: DetailedProcessInfo, + pub cached_at: u64, // Unix timestamp when this data was cached +} + +#[derive(Debug, Clone, Serialize)] +pub struct JournalEntry { + pub timestamp: String, // ISO 8601 formatted timestamp + pub priority: LogLevel, + pub message: String, + pub unit: Option, // systemd unit name + pub pid: Option, + pub comm: Option, // process command name + pub uid: Option, + pub gid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub enum LogLevel { + Emergency = 0, + Alert = 1, + Critical = 2, + Error = 3, + Warning = 4, + Notice = 5, + Info = 6, + Debug = 7, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JournalResponse { + pub entries: Vec, + pub total_count: u32, + pub truncated: bool, + pub cached_at: u64, // Unix timestamp when this data was cached +} diff --git a/socktop_agent/src/ws.rs b/socktop_agent/src/ws.rs index 9586f14..b7bfe16 100644 --- a/socktop_agent/src/ws.rs +++ b/socktop_agent/src/ws.rs @@ -17,6 +17,8 @@ use crate::proto::pb; use crate::state::AppState; // Compression threshold based on typical payload size +// Temporarily increased for testing - revert to 768 for production +//const COMPRESSION_THRESHOLD: usize = 50_000; const COMPRESSION_THRESHOLD: usize = 768; // Reusable buffer for compression to avoid allocations @@ -111,6 +113,92 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { } drop(cache); // Explicit drop to release mutex early } + Message::Text(ref text) if text.starts_with("get_process_metrics:") => { + if let Some(pid_str) = text.strip_prefix("get_process_metrics:") { + if let Ok(pid) = pid_str.parse::() { + let ttl = std::time::Duration::from_millis(250); // 250ms TTL + + // Check cache first + { + let cache = state.cache_process_metrics.lock().await; + if let Some(entry) = cache.get(&pid) { + if entry.is_fresh(ttl) + && let Some(cached_response) = entry.get() + { + let _ = send_json(&mut socket, cached_response).await; + continue; + } + } + } + + // Collect fresh data + match crate::metrics::collect_process_metrics(pid, &state).await { + Ok(response) => { + // Cache the response + { + let mut cache = state.cache_process_metrics.lock().await; + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(response.clone()); + } + let _ = send_json(&mut socket, &response).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": "get_process_metrics", + "pid": pid + }); + let _ = send_json(&mut socket, &error_response).await; + } + } + } + } + } + Message::Text(ref text) if text.starts_with("get_journal_entries:") => { + if let Some(pid_str) = text.strip_prefix("get_journal_entries:") { + if let Ok(pid) = pid_str.parse::() { + let ttl = std::time::Duration::from_secs(1); // 1s TTL + + // Check cache first + { + let cache = state.cache_journal_entries.lock().await; + if let Some(entry) = cache.get(&pid) { + if entry.is_fresh(ttl) + && let Some(cached_response) = entry.get() + { + let _ = send_json(&mut socket, cached_response).await; + continue; + } + } + } + + // Collect fresh data + match crate::metrics::collect_journal_entries(pid) { + Ok(response) => { + // Cache the response + { + let mut cache = state.cache_journal_entries.lock().await; + cache + .entry(pid) + .or_insert_with(crate::state::CacheEntry::new) + .set(response.clone()); + } + let _ = send_json(&mut socket, &response).await; + } + Err(err) => { + let error_response = serde_json::json!({ + "error": err, + "request": "get_journal_entries", + "pid": pid + }); + let _ = send_json(&mut socket, &error_response).await; + } + } + } + } + } Message::Close(_) => break, _ => {} } diff --git a/socktop_agent/tests/cache_tests.rs b/socktop_agent/tests/cache_tests.rs new file mode 100644 index 0000000..821de87 --- /dev/null +++ b/socktop_agent/tests/cache_tests.rs @@ -0,0 +1,132 @@ +//! Tests for the process cache functionality + +use socktop_agent::state::{AppState, CacheEntry}; +use socktop_agent::types::{DetailedProcessInfo, JournalResponse, ProcessMetricsResponse}; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::test] +async fn test_process_cache_ttl() { + let state = AppState::new(); + let pid = 12345; + + // Create mock data + let process_info = DetailedProcessInfo { + pid, + name: "test_process".to_string(), + command: "test command".to_string(), + cpu_usage: 50.0, + mem_bytes: 1024 * 1024, + virtual_mem_bytes: 2048 * 1024, + shared_mem_bytes: Some(512 * 1024), + thread_count: 4, + fd_count: Some(10), + status: "Running".to_string(), + parent_pid: Some(1), + user_id: 1000, + group_id: 1000, + start_time: 1234567890, + cpu_time_user: 100000, + cpu_time_system: 50000, + read_bytes: Some(1024), + write_bytes: Some(2048), + working_directory: Some("/tmp".to_string()), + executable_path: Some("/usr/bin/test".to_string()), + child_processes: vec![], + threads: vec![], + }; + + let metrics_response = ProcessMetricsResponse { + process: process_info, + cached_at: 1234567890, + }; + + let journal_response = JournalResponse { + entries: vec![], + total_count: 0, + truncated: false, + cached_at: 1234567890, + }; + + // Test process metrics caching + { + let mut cache = state.cache_process_metrics.lock().await; + cache + .entry(pid) + .or_insert_with(CacheEntry::new) + .set(metrics_response.clone()); + } + + // Should get cached value immediately + { + let cache = state.cache_process_metrics.lock().await; + let ttl = Duration::from_millis(250); + if let Some(entry) = cache.get(&pid) { + assert!(entry.is_fresh(ttl)); + assert!(entry.get().is_some()); + assert_eq!(entry.get().unwrap().process.pid, pid); + } else { + panic!("Expected cached entry"); + } + } + println!("✓ Process metrics cached and retrieved successfully"); + + // Test journal entries caching + { + let mut cache = state.cache_journal_entries.lock().await; + cache + .entry(pid) + .or_insert_with(CacheEntry::new) + .set(journal_response.clone()); + } + + // Should get cached value immediately + { + let cache = state.cache_journal_entries.lock().await; + let ttl = Duration::from_secs(1); + if let Some(entry) = cache.get(&pid) { + assert!(entry.is_fresh(ttl)); + assert!(entry.get().is_some()); + assert_eq!(entry.get().unwrap().total_count, 0); + } else { + panic!("Expected cached entry"); + } + } + println!("✓ Journal entries cached and retrieved successfully"); + + // Wait for process metrics to expire (250ms + buffer) + sleep(Duration::from_millis(300)).await; + + // Process metrics should be expired now + { + let cache = state.cache_process_metrics.lock().await; + let ttl = Duration::from_millis(250); + if let Some(entry) = cache.get(&pid) { + assert!(!entry.is_fresh(ttl)); + } + } + println!("✓ Process metrics correctly expired after TTL"); + + // Journal entries should still be valid (1s TTL) + { + let cache = state.cache_journal_entries.lock().await; + let ttl = Duration::from_secs(1); + if let Some(entry) = cache.get(&pid) { + assert!(entry.is_fresh(ttl)); + } + } + println!("✓ Journal entries still valid within TTL"); + + // Wait for journal entries to expire (additional 800ms to reach 1s total) + sleep(Duration::from_millis(800)).await; + + // Journal entries should be expired now + { + let cache = state.cache_journal_entries.lock().await; + let ttl = Duration::from_secs(1); + if let Some(entry) = cache.get(&pid) { + assert!(!entry.is_fresh(ttl)); + } + } + println!("✓ Journal entries correctly expired after TTL"); +} diff --git a/socktop_agent/tests/process_details.rs b/socktop_agent/tests/process_details.rs new file mode 100644 index 0000000..dfc52c3 --- /dev/null +++ b/socktop_agent/tests/process_details.rs @@ -0,0 +1,89 @@ +//! Tests for process detail collection functionality + +use socktop_agent::metrics::{collect_journal_entries, collect_process_metrics}; +use socktop_agent::state::AppState; +use std::process; + +#[tokio::test] +async fn test_collect_process_metrics_self() { + // Test collecting metrics for our own process + let pid = process::id(); + let state = AppState::new(); + + match collect_process_metrics(pid, &state).await { + Ok(response) => { + assert_eq!(response.process.pid, pid); + assert!(!response.process.name.is_empty()); + // Command might be empty on some systems, so don't assert on it + assert!(response.cached_at > 0); + println!( + "✓ Process metrics collected for PID {}: {} ({})", + pid, response.process.name, response.process.command + ); + } + Err(e) => { + // This might fail if sysinfo can't find the process, which is possible + println!("⚠ Warning: Failed to collect process metrics for self: {e}"); + } + } +} + +#[tokio::test] +async fn test_collect_journal_entries_self() { + // Test collecting journal entries for our own process + let pid = process::id(); + + match collect_journal_entries(pid) { + Ok(response) => { + assert!(response.cached_at > 0); + println!( + "✓ Journal entries collected for PID {}: {} entries", + pid, response.total_count + ); + if !response.entries.is_empty() { + let entry = &response.entries[0]; + println!(" Latest entry: {}", entry.message); + } + } + Err(e) => { + // This might fail if journalctl is not available or restricted + println!("⚠ Warning: Failed to collect journal entries for self: {e}"); + } + } +} + +#[tokio::test] +async fn test_collect_process_metrics_invalid_pid() { + // Test with an invalid PID + let invalid_pid = 999999; + let state = AppState::new(); + + match collect_process_metrics(invalid_pid, &state).await { + Ok(_) => { + println!("⚠ Warning: Unexpectedly found process for invalid PID {invalid_pid}"); + } + Err(e) => { + println!("✓ Correctly failed for invalid PID {invalid_pid}: {e}"); + assert!(e.contains("not found")); + } + } +} + +#[tokio::test] +async fn test_collect_journal_entries_invalid_pid() { + // Test with an invalid PID - journalctl might still return empty results + let invalid_pid = 999999; + + match collect_journal_entries(invalid_pid) { + Ok(response) => { + println!( + "✓ Journal query completed for invalid PID {} (empty result expected): {} entries", + invalid_pid, response.total_count + ); + // Should be empty or very few entries + } + Err(e) => { + println!("✓ Journal query failed for invalid PID {invalid_pid}: {e}"); + } + } +} diff --git a/socktop_connector/src/connector.rs b/socktop_connector/src/connector.rs index c9825c2..3ed5b35 100644 --- a/socktop_connector/src/connector.rs +++ b/socktop_connector/src/connector.rs @@ -66,7 +66,7 @@ use tokio_tungstenite::{Connector, connect_async_tls_with_config}; use crate::error::{ConnectorError, Result}; use crate::types::{AgentRequest, AgentResponse}; #[cfg(any(feature = "networking", feature = "wasm"))] -use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload}; +use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, ProcessMetricsResponse, JournalResponse}; #[cfg(feature = "tls")] fn ensure_crypto_provider() { use std::sync::Once; @@ -186,6 +186,18 @@ impl SocktopConnector { .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; Ok(AgentResponse::Processes(processes)) } + AgentRequest::ProcessMetrics { pid } => { + let process_metrics = request_process_metrics(stream, pid) + .await + .ok_or_else(|| ConnectorError::invalid_response("Failed to get process metrics"))?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid } => { + let journal_entries = request_journal_entries(stream, pid) + .await + .ok_or_else(|| ConnectorError::invalid_response("Failed to get journal entries"))?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } @@ -437,6 +449,38 @@ async fn request_processes(ws: &mut WsStream) -> Option { } } +// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse +#[cfg(feature = "networking")] +async fn request_process_metrics(ws: &mut WsStream, pid: u32) -> Option { + let request = format!("get_process_metrics:{}", pid); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => { + gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) + } + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} + +// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse +#[cfg(feature = "networking")] +async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { + let request = format!("get_journal_entries:{}", pid); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => { + gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::(&s).ok()) + } + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} + // Decompress a gzip-compressed binary frame into a String. /// Unified gzip decompression to string for both networking and WASM #[cfg(any(feature = "networking", feature = "wasm"))] @@ -805,6 +849,20 @@ impl SocktopConnector { Ok(AgentResponse::Processes(processes)) } } + AgentRequest::ProcessMetrics { pid: _ } => { + // Parse JSON response for process metrics + let process_metrics: ProcessMetricsResponse = serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!("Failed to parse process metrics: {e}")) + })?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid: _ } => { + // Parse JSON response for journal entries + let journal_entries: JournalResponse = serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!("Failed to parse journal entries: {e}")) + })?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } diff --git a/socktop_connector/src/connector_impl.rs b/socktop_connector/src/connector_impl.rs index bcac691..0b5e97c 100644 --- a/socktop_connector/src/connector_impl.rs +++ b/socktop_connector/src/connector_impl.rs @@ -6,7 +6,8 @@ use crate::{AgentRequest, AgentResponse}; #[cfg(feature = "networking")] use crate::networking::{ - WsStream, connect_to_agent, request_disks, request_metrics, request_processes, + WsStream, connect_to_agent, request_disks, request_journal_entries, request_metrics, + request_process_metrics, request_processes, }; #[cfg(all(feature = "wasm", not(feature = "networking")))] @@ -72,6 +73,20 @@ impl SocktopConnector { .ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?; Ok(AgentResponse::Processes(processes)) } + AgentRequest::ProcessMetrics { pid } => { + let process_metrics = + request_process_metrics(stream, pid).await.ok_or_else(|| { + ConnectorError::invalid_response("Failed to get process metrics") + })?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid } => { + let journal_entries = + request_journal_entries(stream, pid).await.ok_or_else(|| { + ConnectorError::invalid_response("Failed to get journal entries") + })?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } } diff --git a/socktop_connector/src/lib.rs b/socktop_connector/src/lib.rs index a819aaf..e82a73d 100644 --- a/socktop_connector/src/lib.rs +++ b/socktop_connector/src/lib.rs @@ -161,7 +161,8 @@ pub use config::ConnectorConfig; pub use connector_impl::SocktopConnector; pub use error::{ConnectorError, Result}; pub use types::{ - AgentRequest, AgentResponse, DiskInfo, GpuInfo, Metrics, NetworkInfo, ProcessInfo, + AgentRequest, AgentResponse, DetailedProcessInfo, DiskInfo, GpuInfo, JournalEntry, + JournalResponse, LogLevel, Metrics, NetworkInfo, ProcessInfo, ProcessMetricsResponse, ProcessesPayload, }; diff --git a/socktop_connector/src/networking/requests.rs b/socktop_connector/src/networking/requests.rs index 9cb85bc..726fbe2 100644 --- a/socktop_connector/src/networking/requests.rs +++ b/socktop_connector/src/networking/requests.rs @@ -1,6 +1,7 @@ //! WebSocket request handlers for native (non-WASM) environments. use crate::networking::WsStream; +use crate::types::{JournalResponse, ProcessMetricsResponse}; use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip}; use crate::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, pb}; @@ -82,3 +83,36 @@ pub async fn request_processes(ws: &mut WsStream) -> Option { _ => None, } } + +/// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse +pub async fn request_process_metrics( + ws: &mut WsStream, + pid: u32, +) -> Option { + let request = format!("get_process_metrics:{pid}"); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => gunzip_to_string(&b) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()), + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} + +/// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse +pub async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option { + let request = format!("get_journal_entries:{pid}"); + if ws.send(Message::Text(request)).await.is_err() { + return None; + } + match ws.next().await { + Some(Ok(Message::Binary(b))) => gunzip_to_string(&b) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()), + Some(Ok(Message::Text(json))) => serde_json::from_str::(&json).ok(), + _ => None, + } +} diff --git a/socktop_connector/src/types.rs b/socktop_connector/src/types.rs index b06bbda..125e11c 100644 --- a/socktop_connector/src/types.rs +++ b/socktop_connector/src/types.rs @@ -73,6 +73,79 @@ pub struct ProcessesPayload { pub top_processes: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ThreadInfo { + pub tid: u32, // Thread ID + pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm) + pub cpu_time_user: u64, // User CPU time in microseconds + pub cpu_time_system: u64, // System CPU time in microseconds + pub status: String, // Thread status (Running, Sleeping, etc.) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DetailedProcessInfo { + pub pid: u32, + pub name: String, + pub command: String, + pub cpu_usage: f32, + pub mem_bytes: u64, + pub virtual_mem_bytes: u64, + pub shared_mem_bytes: Option, + pub thread_count: u32, + pub fd_count: Option, + pub status: String, + pub parent_pid: Option, + pub user_id: u32, + pub group_id: u32, + pub start_time: u64, // Unix timestamp + pub cpu_time_user: u64, // Microseconds + pub cpu_time_system: u64, // Microseconds + pub read_bytes: Option, + pub write_bytes: Option, + pub working_directory: Option, + pub executable_path: Option, + pub child_processes: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProcessMetricsResponse { + pub process: DetailedProcessInfo, + pub cached_at: u64, // Unix timestamp when this data was cached +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JournalEntry { + pub timestamp: String, // ISO 8601 formatted timestamp + pub priority: LogLevel, + pub message: String, + pub unit: Option, // systemd unit name + pub pid: Option, + pub comm: Option, // process command name + pub uid: Option, + pub gid: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum LogLevel { + Emergency = 0, + Alert = 1, + Critical = 2, + Error = 3, + Warning = 4, + Notice = 5, + Info = 6, + Debug = 7, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JournalResponse { + pub entries: Vec, + pub total_count: u32, + pub truncated: bool, + pub cached_at: u64, // Unix timestamp when this data was cached +} + /// Request types that can be sent to the agent #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] @@ -83,6 +156,10 @@ pub enum AgentRequest { Disks, #[serde(rename = "processes")] Processes, + #[serde(rename = "process_metrics")] + ProcessMetrics { pid: u32 }, + #[serde(rename = "journal_entries")] + JournalEntries { pid: u32 }, } impl AgentRequest { @@ -92,6 +169,8 @@ impl AgentRequest { AgentRequest::Metrics => "get_metrics".to_string(), AgentRequest::Disks => "get_disks".to_string(), AgentRequest::Processes => "get_processes".to_string(), + AgentRequest::ProcessMetrics { pid } => format!("get_process_metrics:{pid}"), + AgentRequest::JournalEntries { pid } => format!("get_journal_entries:{pid}"), } } } @@ -106,4 +185,8 @@ pub enum AgentResponse { Disks(Vec), #[serde(rename = "processes")] Processes(ProcessesPayload), + #[serde(rename = "process_metrics")] + ProcessMetrics(ProcessMetricsResponse), + #[serde(rename = "journal_entries")] + JournalEntries(JournalResponse), } diff --git a/socktop_connector/src/wasm/requests.rs b/socktop_connector/src/wasm/requests.rs index 9ecea53..17ecc15 100644 --- a/socktop_connector/src/wasm/requests.rs +++ b/socktop_connector/src/wasm/requests.rs @@ -3,7 +3,10 @@ use crate::error::{ConnectorError, Result}; use crate::pb::Processes; use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip, log_debug}; -use crate::{AgentRequest, AgentResponse, DiskInfo, Metrics, ProcessInfo, ProcessesPayload}; +use crate::{ + AgentRequest, AgentResponse, DiskInfo, JournalResponse, Metrics, ProcessInfo, + ProcessMetricsResponse, ProcessesPayload, +}; use prost::Message as ProstMessage; use std::cell::RefCell; @@ -206,6 +209,26 @@ pub async fn send_request_and_wait( Ok(AgentResponse::Processes(processes)) } } + AgentRequest::ProcessMetrics { pid: _ } => { + // Parse JSON response for process metrics + let process_metrics: ProcessMetricsResponse = + serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!( + "Failed to parse process metrics: {e}" + )) + })?; + Ok(AgentResponse::ProcessMetrics(process_metrics)) + } + AgentRequest::JournalEntries { pid: _ } => { + // Parse JSON response for journal entries + let journal_entries: JournalResponse = + serde_json::from_str(&response).map_err(|e| { + ConnectorError::serialization_error(format!( + "Failed to parse journal entries: {e}" + )) + })?; + Ok(AgentResponse::JournalEntries(journal_entries)) + } } }