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)) + } } }