initial check for process summary screen

This check in offers alpha support for per process metrics, you can view threads, process CPU usage over time, IO, memory, CPU time, parent process, command, uptime and journal entries. This is unfinished but all major functionality is available and I wanted to make it available to feedback and testing.
This commit is contained in:
jasonwitty 2025-10-02 16:54:27 -07:00
parent a238ce320b
commit e66008f341
21 changed files with 3625 additions and 96 deletions

4
.gitignore vendored
View File

@ -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

499
Cargo.lock generated
View File

@ -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"

View File

@ -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<ratatui::layout::Rect>,
// Process selection state
pub selected_process_pid: Option<u32>,
pub selected_process_index: Option<usize>, // Index in the visible/sorted list
prev_selected_process_pid: Option<u32>, // 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<socktop_connector::ProcessMetricsResponse>,
pub journal_entries: Option<socktop_connector::JournalResponse>,
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation
last_io_write_bytes: Option<u64>, // 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<String>,
@ -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,20 +782,34 @@ 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(),
)
{
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,

File diff suppressed because it is too large Load Diff

View File

@ -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<u32>,
selected_process_index: Option<usize>,
) {
// 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<u32>,
selected_process_index: &mut Option<usize>,
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<crate::ui::cpu::PerCoreScrollDrag>,
@ -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<u32>,
pub selected_process_index: &'a mut Option<usize>,
pub drag: &'a mut Option<crate::ui::cpu::PerCoreScrollDrag>,
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<ProcSortBy> {
// 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<usize> = (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
}

View File

@ -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 = "⚠️";

View File

@ -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"

View File

@ -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<T> {
data: T,
cached_at: Instant,
ttl: Duration,
}
impl<T> CacheEntry<T> {
fn is_expired(&self) -> bool {
self.cached_at.elapsed() > self.ttl
}
}
#[derive(Debug)]
pub struct ProcessCache {
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
}
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<ProcessMetricsResponse> {
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<JournalResponse> {
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()
}
}

17
socktop_agent/src/lib.rs Normal file
View File

@ -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,
};

View File

@ -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::<u64>() {
// 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::<u64>() {
// 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<bool> = 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<DetailedProcessInfo> {
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::<u32>() {
// 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<u32> {
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::<u32>().ok()
}
/// Collect process information from /proc files
#[cfg(target_os = "linux")]
fn collect_process_info_from_proc(
pid: u32,
system: &sysinfo::System,
) -> Option<DetailedProcessInfo> {
// 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::<u64>().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::<u64>().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::<u64>().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<DetailedProcessInfo> {
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::<Vec<_>>()
.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<crate::types::ThreadInfo> {
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::<u32>() 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::<u64>().ok())
.unwrap_or(0);
let stime = fields
.get(14)
.and_then(|s| s.parse::<u64>().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<crate::types::ThreadInfo> {
Vec::new()
}
/// Collect detailed metrics for a specific process
pub async fn collect_process_metrics(
pid: u32,
state: &AppState,
) -> Result<ProcessMetricsResponse, String> {
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::<Vec<_>>()
.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<JournalResponse, String> {
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::<u64>() {
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::<u32>().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::<u32>().ok());
let gid = json
.get("_GID")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<u32>().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,
})
}

View File

@ -63,6 +63,11 @@ pub struct AppState {
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
// Process detail caches (per-PID)
pub cache_process_metrics:
Arc<Mutex<HashMap<u32, CacheEntry<crate::types::ProcessMetricsResponse>>>>,
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
}
#[derive(Clone, Debug)]
@ -71,6 +76,12 @@ pub struct CacheEntry<T> {
pub value: Option<T>,
}
impl<T> Default for CacheEntry<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> CacheEntry<T> {
pub fn new() -> Self {
Self {
@ -90,6 +101,12 @@ impl<T> CacheEntry<T> {
}
}
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())),
}
}
}

View File

@ -47,3 +47,76 @@ pub struct ProcessesPayload {
pub process_count: usize,
pub top_processes: Vec<ProcessInfo>,
}
#[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<u64>,
pub thread_count: u32,
pub fd_count: Option<u32>,
pub status: String,
pub parent_pid: Option<u32>,
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<u64>,
pub write_bytes: Option<u64>,
pub working_directory: Option<String>,
pub executable_path: Option<String>,
pub child_processes: Vec<DetailedProcessInfo>,
pub threads: Vec<ThreadInfo>,
}
#[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<String>, // systemd unit name
pub pid: Option<u32>,
pub comm: Option<String>, // process command name
pub uid: Option<u32>,
pub gid: Option<u32>,
}
#[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<JournalEntry>,
pub total_count: u32,
pub truncated: bool,
pub cached_at: u64, // Unix timestamp when this data was cached
}

View File

@ -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::<u32>() {
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::<u32>() {
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,
_ => {}
}

View File

@ -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");
}

View File

@ -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}");
}
}
}

View File

@ -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<ProcessesPayload> {
}
}
// 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<ProcessMetricsResponse> {
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::<ProcessMetricsResponse>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessMetricsResponse>(&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<JournalResponse> {
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::<JournalResponse>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<JournalResponse>(&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))
}
}
}

View File

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

View File

@ -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,
};

View File

@ -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<ProcessesPayload> {
_ => 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<ProcessMetricsResponse> {
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::<ProcessMetricsResponse>(&s).ok()),
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessMetricsResponse>(&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<JournalResponse> {
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::<JournalResponse>(&s).ok()),
Some(Ok(Message::Text(json))) => serde_json::from_str::<JournalResponse>(&json).ok(),
_ => None,
}
}

View File

@ -73,6 +73,79 @@ pub struct ProcessesPayload {
pub top_processes: Vec<ProcessInfo>,
}
#[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<u64>,
pub thread_count: u32,
pub fd_count: Option<u32>,
pub status: String,
pub parent_pid: Option<u32>,
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<u64>,
pub write_bytes: Option<u64>,
pub working_directory: Option<String>,
pub executable_path: Option<String>,
pub child_processes: Vec<DetailedProcessInfo>,
pub threads: Vec<ThreadInfo>,
}
#[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<String>, // systemd unit name
pub pid: Option<u32>,
pub comm: Option<String>, // process command name
pub uid: Option<u32>,
pub gid: Option<u32>,
}
#[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<JournalEntry>,
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<DiskInfo>),
#[serde(rename = "processes")]
Processes(ProcessesPayload),
#[serde(rename = "process_metrics")]
ProcessMetrics(ProcessMetricsResponse),
#[serde(rename = "journal_entries")]
JournalEntries(JournalResponse),
}

View File

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