feat: tui, agent interface, stats hook (#29)

Runtime dashboard now features a TUI option and an interface for LLM tool use. Removed some bloat from stats collecting and replaced with a hook function to dump runtime stats into.
This commit is contained in:
zacheryasc 2026-02-11 15:23:26 +00:00
parent 2da9198ee9
commit 076f1194bf
30 changed files with 4257 additions and 107 deletions

545
Cargo.lock generated
View file

@ -11,6 +11,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anes"
version = "0.1.6"
@ -35,24 +41,60 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chunked_transfer"
version = "1.5.0"
@ -111,6 +153,20 @@ version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "criterion"
version = "0.5.1"
@ -123,7 +179,7 @@ dependencies = [
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
@ -144,7 +200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
"itertools 0.10.5",
]
[[package]]
@ -181,12 +237,94 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "ctrlc"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
]
[[package]]
name = "either"
version = "1.15.0"
@ -199,6 +337,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.2.17"
@ -232,6 +386,17 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -256,6 +421,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.13.0"
@ -263,7 +434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
]
[[package]]
@ -275,6 +446,19 @@ dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
@ -283,7 +467,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@ -295,6 +479,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@ -323,12 +516,36 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "memchr"
version = "2.7.6"
@ -344,13 +561,37 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@ -362,6 +603,21 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell"
version = "1.21.3"
@ -374,6 +630,35 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@ -495,6 +780,27 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools 0.13.0",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "rayon"
version = "1.11.0"
@ -515,6 +821,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
@ -548,6 +863,10 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
name = "runtime-dashboard"
version = "0.1.0"
dependencies = [
"crossbeam-queue",
"crossterm",
"ctrlc",
"ratatui",
"serde",
"serde_json",
"swactor",
@ -556,12 +875,31 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@ -571,6 +909,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
@ -632,12 +976,77 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "swactor"
version = "0.1.0"
@ -829,6 +1238,35 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unindent"
version = "0.2.4"
@ -912,21 +1350,52 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -936,6 +1405,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.14"

View file

@ -40,3 +40,7 @@ harness = false
[[bench]]
name = "mt_benchmarks"
harness = false
[[example]]
name = "tcp_ping_pong"
required-features = ["transport"]

View file

@ -0,0 +1,175 @@
# Runtime Dashboard — Agent Diagnostic Interface
## Investigate Protocol
There is a line-oriented diagnostic protocol for programmatic runtime
investigation. It reads text commands from stdin and writes JSON responses
to stdout (one object per line). Human-readable output goes to stderr.
### Launching
```bash
cargo run -p runtime-dashboard --example investigate_demo
```
Or programmatically against any running runtime:
```rust
use runtime_dashboard::investigate::run_investigate;
run_investigate(runtime_arc)?; // blocks on stdin
```
### Response Envelope
Every response is a single JSON object on one line:
```json
{"ok": true, "command": "overview", "data": { ... }}
{"ok": false, "command": "bogus", "error": "unknown command `bogus` — try `help`"}
```
### Commands
#### overview
Overall runtime summary.
```
→ overview
← {"ok":true,"command":"overview","data":{
"workers": 4,
"actors": 120,
"total_messages_processed": 584210,
"total_mailbox_depth": 37,
"total_panics": 0,
"total_type_mismatches": 0,
"sends": {"local": 312000, "cross_worker": 271000, "inbox": 1210}
}}
```
#### workers
All workers with per-worker counters.
```
→ workers
← {"ok":true,"command":"workers","data":[
{"id":0,"actors":30,"mailbox_depth":12,"messages_processed":146000,
"local_sends":78000,"cross_sends":67000,"inbox_sends":300,
"type_mismatches":0,"panics":0},
...
]}
```
#### worker <id>
Single worker detail including tick-phase timing and its actors.
```
→ worker 2
← {"ok":true,"command":"worker","data":{
"id": 2, "actors": 30, "mailbox_depth": 8,
"messages_processed": 148000,
"local_sends": 80000, "cross_sends": 67500, "inbox_sends": 500,
"type_mismatches": 0, "panics": 0,
"tick_phases": {
"ticks": 412,
"active_pct": 78.4,
"avg_tick_us": 23.7,
"phases_us": [120, 980, 7200, 90, 1100, 280],
"phases_pct": [1.2, 10.0, 73.6, 0.9, 11.2, 2.9]
},
"actor_details": [
{"address": "a1b2c3d4…", "mailbox_depth": 3, "last_msg_type": "MyMsg"},
...
]
}}
```
The six tick phases (indices 0–5):
0. spawn_drain — draining spawn channel
1. transfer_drain — draining cross-worker transfer channel
2. tick_all — processing actor mailboxes (main work)
3. spawn_drain_2 — draining spawns created during tick
4. pending_local — delivering messages buffered within the worker
5. stats_publish — updating shared stat counters
#### actors [--sort mailbox|worker|address] [--limit N] [--worker W]
Actor listing with sorting, limit, and worker filter.
```
→ actors --sort mailbox --limit 5
→ actors --worker 0 --sort address
→ actors --limit 20
```
#### actor <hex_prefix>
Find actors whose address starts with the given hex prefix.
```
→ actor a1b2
← {"ok":true,"command":"actor","data":{
"prefix": "a1b2",
"matches": 1,
"actors": [{"address": "a1b2c3d4…", "address_full": "a1b2c3d4...(64 hex chars)", "worker_id": 2, "mailbox_depth": 3, "last_msg_type": "MyMsg"}]
}}
```
#### hot [N]
Top N actors by mailbox depth (default 10). Use this to find backpressure.
```
→ hot 5
```
#### phases [worker_id]
Tick-phase timing breakdown. Without an argument returns all workers.
```
→ phases
→ phases 2
```
#### diff <seconds>
Takes two snapshots separated by N seconds (max 30) and reports deltas.
This is the primary throughput measurement tool.
```
→ diff 2
← {"ok":true,"command":"diff","data":{
"elapsed_s": 2.001,
"actors_before": 120, "actors_after": 132,
"delta_messages": 8432,
"msg_per_sec": 4213.9,
"delta_local_sends": 4500,
"delta_cross_sends": 3900,
"mailbox_before": 37, "mailbox_after": 42,
"per_worker": [
{"worker_id": 0, "delta_messages": 2100, "msg_per_sec": 1049.5,
"actors_before": 30, "actors_after": 33,
"mailbox_before": 12, "mailbox_after": 14},
...
]
}}
```
#### help
Returns all commands with usage strings.
#### quit
Exits the session.
### Investigation Playbook
When diagnosing a runtime, a useful sequence:
1. `overview` — get the lay of the land
2. `diff 2` — measure live throughput and detect growth
3. `hot 10` — find actors with deepest mailboxes (backpressure)
4. `workers` — compare per-worker load distribution
5. `worker <id>` — drill into the busiest worker, check phase breakdown
6. `phases` — check if workers are spending time in unexpected phases
7. `actors --worker <id> --sort mailbox` — find hot actors on that worker
8. `actor <prefix>` — get full address and type for a specific actor
### Key Metrics to Watch
| Symptom | Check | Meaning |
|---------|-------|---------|
| High mailbox_depth | `hot 10` | Actor can't keep up — backpressure |
| Uneven msg_per_sec across workers | `diff 2` per_worker | Load imbalance |
| High cross_sends vs local_sends | `overview` sends | Actors that talk are on different workers |
| active_pct near 100% | `phases <id>` | Worker is saturated |
| High phase 1+4 % vs phase 2 | `phases <id>` | Delivery overhead dominates processing |
| Growing actors_after vs actors_before | `diff 5` | Unbounded actor spawning |
| type_mismatches > 0 | `overview` | Messages routed to wrong actor type |
| panics > 0 | `overview` | Actor handlers are panicking |

View file

@ -10,3 +10,21 @@ tracing-subscriber = { version = "0.3", features = ["registry"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tiny_http = "0.12"
crossbeam-queue = "0.3.12"
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", optional = true }
[dev-dependencies]
ctrlc = "3"
[features]
default = []
tui = ["dep:ratatui", "dep:crossterm"]
[[example]]
name = "tui_demo"
required-features = ["tui"]
[[example]]
name = "remote_tui"
required-features = ["tui"]

View file

@ -1,3 +1,14 @@
# runtime-dashboard
Visual dashboard for the swactor runtime.
Visual dashboard for the swactor runtime.
## usage
Run the demo, from swactor root:
```bash
# start the runtime
cargo run -p runtime-dashboard --example dashboard_demo
# (optional) run the TUI
cargo run -p runtime-dashboard --features tui --example tui_demo
```

View file

@ -5,6 +5,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::{BackoffPolicy, RuntimeConfig};
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::{start_dashboard, DashboardConfig};
// ---------------------------------------------------------------------------
@ -102,10 +103,12 @@ fn run_for(duration: Duration, mut tick: impl FnMut()) {
fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) {
eprintln!(" [1/4] Single-actor bombardment (5s)");
let rt = Runtime::new(bench_config(4, 64, 100_000));
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, 64, 100_000));
rt.set_stats_hook(collector.clone());
let addr = rt.spawn(SinkActor::new()).unwrap();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone());
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
for _ in 0..100 {
@ -120,12 +123,14 @@ fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) {
eprintln!(" [2/4] Multi-actor fan-out (5s)");
let rt = Runtime::new(bench_config(4, 128, 10_000));
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, 128, 10_000));
rt.set_stats_hook(collector.clone());
let addrs: Vec<_> = (0..50)
.map(|_| rt.spawn(SinkActor::new()).unwrap())
.collect();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone());
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
for &addr in &addrs {
@ -143,7 +148,9 @@ fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
eprintln!(" [3/4] Ring topology (5s)");
let ring_size = 100;
let rt = Runtime::new(bench_config(4, ring_size + 64, 1_024));
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, ring_size + 64, 1_024));
rt.set_stats_hook(collector.clone());
// Build ring backwards: last spawned actor is the entry point
let mut addrs = Vec::with_capacity(ring_size);
@ -163,7 +170,7 @@ fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
let entry = *addrs.last().unwrap();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone());
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
let _ = handle.runtime.send_to(entry, RingMsg);
@ -176,10 +183,12 @@ fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_spawn_storm(dash: &runtime_dashboard::DashboardHandle) {
eprintln!(" [4/4] Spawn storm (5s)");
let rt = Runtime::new(bench_config(4, 50_000, 1_024));
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, 50_000, 1_024));
rt.set_stats_hook(collector.clone());
let spawner = rt.spawn(SpawnerActor::new()).unwrap();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone());
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
let _ = handle.runtime.send_to(spawner, SpawnCmd);

View file

@ -7,6 +7,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::{start_dashboard, DashboardConfig};
// ── Demo actors ─────────────────────────────────────────────────────────
@ -31,7 +32,7 @@ impl ActorInterface for PingActor {
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
// Forward to the target — creates cross-worker traffic
if self.count < 200 {
if self.count < 10_000 {
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
}
}
@ -67,12 +68,10 @@ fn main() {
// Handle Ctrl+C gracefully
{
let stop = Arc::clone(&stop);
let _ = std::panic::catch_unwind(|| {
// Try to register a signal handler; fall back to running until killed
unsafe {
libc_signal(2, move || stop.store(true, Ordering::Relaxed));
}
});
ctrlc::set_handler(move || {
stop.store(true, Ordering::Relaxed);
})
.expect("failed to set Ctrl+C handler");
}
let dash = start_dashboard(DashboardConfig {
@ -81,12 +80,16 @@ fn main() {
});
dash.install_tracing();
let rt = Runtime::new(RuntimeConfig {
num_threads: 4,
let num_threads = 4;
let collector = StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 1024,
channel_buffer_size: 2000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
// Spawn ping actors for cross-worker traffic
let mut ping_addrs = Vec::new();
@ -103,7 +106,7 @@ fn main() {
}
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(handle.runtime.clone());
dash.set_runtime(handle.runtime.clone(), collector);
eprintln!("Dashboard at http://localhost:9090 — press Ctrl+C to stop");
@ -121,7 +124,7 @@ fn main() {
}
// Periodically spawn more actors
if round % 150 == 75 && counter_addrs.len() < 200 {
if round % 150 == 75 && counter_addrs.len() < 500 {
for _ in 0..8 {
match handle.runtime.spawn(CounterActor::new()) {
Ok(addr) => counter_addrs.push(addr),
@ -147,9 +150,3 @@ fn main() {
dash.shutdown();
handle.join();
}
// Minimal signal handling without external deps
unsafe fn libc_signal(_sig: i32, _handler: impl FnOnce()) {
// This is a no-op fallback; the loop checks the AtomicBool
// In practice, Ctrl+C will terminate the process
}

View file

@ -0,0 +1,122 @@
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::investigate::run_investigate;
// ── Demo actors (same as tui_demo) ─────────────────────────────────────
#[derive(Clone)]
struct Ping(ActorAddress);
struct PingActor {
count: u32,
limit: u32,
}
impl PingActor {
fn new(limit: u32) -> Self {
Self { count: 0, limit }
}
}
impl ActorInterface for PingActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
if self.count < self.limit {
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
}
}
}
#[derive(Clone)]
struct Tick;
struct CounterActor {
ticks: u64,
}
impl CounterActor {
fn new() -> Self {
Self { ticks: 0 }
}
}
impl ActorInterface for CounterActor {
type Incoming = Tick;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {
self.ticks += 1;
}
}
// ── Main ────────────────────────────────────────────────────────────────
fn main() -> std::io::Result<()> {
let num_threads = 4;
let collector = StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 1024,
channel_buffer_size: 2000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
// Spawn some actors
let mut ping_addrs = Vec::new();
for _ in 0..16 {
ping_addrs.push(rt.spawn(PingActor::new(500)).unwrap());
}
let mut counter_addrs = Vec::new();
for _ in 0..40 {
counter_addrs.push(rt.spawn(CounterActor::new()).unwrap());
}
let handle = rt.run().expect("failed to start runtime");
let runtime = Arc::clone(&handle.runtime);
// Wire up ping-pong
for i in 0..ping_addrs.len() {
let target = ping_addrs[(i + 1) % ping_addrs.len()];
let _ = runtime.send_to(ping_addrs[i], Ping(target));
}
// Feeder thread
let rt_feeder = Arc::clone(&runtime);
let ping_clone = ping_addrs.clone();
thread::spawn(move || {
let mut round: u64 = 0;
loop {
for addr in &counter_addrs {
let _ = rt_feeder.send_to(*addr, Tick);
}
if round % 40 == 0 && round > 0 {
for i in 0..ping_clone.len() {
let target = ping_clone[(i + 1) % ping_clone.len()];
let _ = rt_feeder.send_to(ping_clone[i], Ping(target));
}
}
round += 1;
thread::sleep(Duration::from_millis(100));
}
});
// Blocks on stdin — send commands, get JSON back
run_investigate(runtime, collector)?;
handle.shutdown();
handle.join();
Ok(())
}

View file

@ -5,6 +5,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::{start_dashboard, DashboardConfig};
// ── Demo actors ─────────────────────────────────────────────────────────
@ -56,12 +57,16 @@ fn main() {
});
dash.install_tracing();
let rt = Runtime::new(RuntimeConfig {
num_threads: 4,
let num_threads = 4;
let collector = StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 512,
channel_buffer_size: 1000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
let mut ping_addrs = Vec::new();
for _ in 0..12 {
@ -76,7 +81,7 @@ fn main() {
}
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(handle.runtime.clone());
dash.set_runtime(handle.runtime.clone(), collector);
eprintln!("Recording trace for 10 seconds...");
eprintln!("Dashboard at http://localhost:9090");

View file

@ -0,0 +1,13 @@
use runtime_dashboard::tui::types::RuntimeEndpoint;
use runtime_dashboard::tui::{TuiConfig, start_tui_remote};
fn main() -> std::io::Result<()> {
let url = std::env::args()
.nth(1)
.unwrap_or_else(|| "http://localhost:9090".into());
let endpoint = RuntimeEndpoint::from_url(&url);
eprintln!("Connecting to {} ...", endpoint);
start_tui_remote(endpoint, TuiConfig::default())
}

View file

@ -0,0 +1,295 @@
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::tui::{TuiConfig, start_tui};
// ── Demo actors ─────────────────────────────────────────────────────────
/// Ping-pong actor: bounces messages back and forth creating cross-worker traffic.
#[derive(Clone)]
struct Ping(ActorAddress);
struct PingActor {
count: u32,
limit: u32,
}
impl PingActor {
fn new(limit: u32) -> Self {
Self { count: 0, limit }
}
}
impl ActorInterface for PingActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
if self.count < self.limit {
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
}
}
}
/// Simple counter that tallies tick messages.
#[derive(Clone)]
struct Tick;
struct CounterActor {
ticks: u64,
}
impl CounterActor {
fn new() -> Self {
Self { ticks: 0 }
}
}
impl ActorInterface for CounterActor {
type Incoming = Tick;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {
self.ticks += 1;
}
}
/// Fan-out actor: on each message, forwards to all targets — amplifies traffic.
#[derive(Clone)]
struct Fanout(Vec<ActorAddress>);
struct FanoutActor {
targets: Vec<ActorAddress>,
}
impl ActorInterface for FanoutActor {
type Incoming = Fanout;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Fanout) {
self.targets = msg.0;
for &t in &self.targets {
let _ = ctx.send(t, Tick);
}
}
}
/// Chain actor: receives a hop count, decrements, and forwards to the next in chain.
#[derive(Clone)]
struct Hop {
remaining: u32,
chain: Vec<ActorAddress>,
index: usize,
}
struct ChainActor;
impl ActorInterface for ChainActor {
type Incoming = Hop;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Hop) {
if msg.remaining > 0 {
let next_idx = (msg.index + 1) % msg.chain.len();
let _ = ctx.send(
msg.chain[next_idx],
Hop {
remaining: msg.remaining - 1,
chain: msg.chain,
index: next_idx,
},
);
}
}
}
// ── Main ────────────────────────────────────────────────────────────────
fn main() -> std::io::Result<()> {
let num_threads = 8;
let collector = StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 4096,
channel_buffer_size: 4000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
// ── Ping-pong pairs (cross-worker bouncing) ────────────────────────
let mut ping_addrs = Vec::new();
for _ in 0..32 {
ping_addrs.push(rt.spawn(PingActor::new(500)).unwrap());
}
// ── Counter actors (sustained tick traffic) ────────────────────────
let mut counter_addrs: Vec<ActorAddress> = Vec::new();
for _ in 0..60 {
counter_addrs.push(rt.spawn(CounterActor::new()).unwrap());
}
// ── Fan-out hubs (traffic amplifiers) ──────────────────────────────
let mut fanout_addrs = Vec::new();
for _ in 0..4 {
fanout_addrs.push(
rt.spawn(FanoutActor {
targets: Vec::new(),
})
.unwrap(),
);
}
// ── Chain rings (message relay loops) ──────────────────────────────
let mut chain_addrs = Vec::new();
for _ in 0..24 {
chain_addrs.push(rt.spawn(ChainActor).unwrap());
}
let handle = rt.run().expect("failed to start runtime");
let runtime = Arc::clone(&handle.runtime);
// Wire up ping-pong chains
for i in 0..ping_addrs.len() {
let target = ping_addrs[(i + 1) % ping_addrs.len()];
let _ = runtime.send_to(ping_addrs[i], Ping(target));
}
// Wire up fan-out hubs: each hub fans to a slice of counter actors
let chunk_size = counter_addrs.len() / fanout_addrs.len().max(1);
for (i, &hub) in fanout_addrs.iter().enumerate() {
let start = i * chunk_size;
let end = (start + chunk_size).min(counter_addrs.len());
let targets: Vec<_> = counter_addrs[start..end].to_vec();
let _ = runtime.send_to(hub, Fanout(targets));
}
// Kick off chain rings: 3 rings of 8 actors each
for ring_start in (0..chain_addrs.len()).step_by(8) {
let ring: Vec<_> = chain_addrs[ring_start..ring_start + 8].to_vec();
let _ = runtime.send_to(
ring[0],
Hop {
remaining: 200,
chain: ring,
index: 0,
},
);
}
// ── Feeder threads ─────────────────────────────────────────────────
// Thread 1: tick all counters + periodically spawn more
let rt1 = Arc::clone(&runtime);
let fanout_addrs_clone = fanout_addrs.clone();
thread::spawn(move || {
let mut counter_addrs = counter_addrs;
let mut round: u64 = 0;
loop {
// Tick every counter
for addr in &counter_addrs {
let _ = rt1.send_to(*addr, Tick);
}
// Periodically spawn more counters (grow from 60 up to 400)
if round % 50 == 25 && counter_addrs.len() < 400 {
let mut new_addrs = Vec::new();
for _ in 0..12 {
match rt1.spawn(CounterActor::new()) {
Ok(addr) => new_addrs.push(addr),
Err(_) => break,
}
}
// Re-wire fan-out hubs with expanded target list
let chunk = new_addrs.len() / fanout_addrs_clone.len().max(1);
for (i, &hub) in fanout_addrs_clone.iter().enumerate() {
let start = i * chunk;
let end = (start + chunk).min(new_addrs.len());
if start < end {
let targets: Vec<_> = new_addrs[start..end].to_vec();
let _ = rt1.send_to(hub, Fanout(targets));
}
}
counter_addrs.extend(new_addrs);
}
round += 1;
thread::sleep(Duration::from_millis(100));
}
});
// Thread 2: re-kick ping chains + chain rings periodically
let rt2 = Arc::clone(&runtime);
let ping_clone = ping_addrs.clone();
let chain_clone = chain_addrs.clone();
thread::spawn(move || {
let mut round: u64 = 0;
loop {
// Re-kick ping-pong chains
if round % 40 == 0 {
for i in 0..ping_clone.len() {
let target = ping_clone[(i + 1) % ping_clone.len()];
let _ = rt2.send_to(ping_clone[i], Ping(target));
}
}
// Re-kick chain rings
if round % 30 == 0 {
for ring_start in (0..chain_clone.len()).step_by(8) {
let ring: Vec<_> = chain_clone[ring_start..ring_start + 8].to_vec();
let _ = rt2.send_to(
ring[0],
Hop {
remaining: 200,
chain: ring,
index: 0,
},
);
}
}
// Periodically spawn short-lived ping bursts
if round % 60 == 30 {
let mut burst = Vec::new();
for _ in 0..8 {
match rt2.spawn(PingActor::new(50)) {
Ok(addr) => burst.push(addr),
Err(_) => break,
}
}
for i in 0..burst.len() {
let target = burst[(i + 1) % burst.len()];
let _ = rt2.send_to(burst[i], Ping(target));
}
}
round += 1;
thread::sleep(Duration::from_millis(150));
}
});
// Thread 3: fan-out re-trigger (keeps hubs active)
let rt3 = Arc::clone(&runtime);
let fanout_clone = fanout_addrs.clone();
thread::spawn(move || loop {
for &hub in &fanout_clone {
// Re-send so hub forwards again to its targets
let _ = rt3.send_to(hub, Fanout(Vec::new()));
}
thread::sleep(Duration::from_millis(80));
});
// This blocks until the user presses 'q'
start_tui(runtime, collector, TuiConfig::default())?;
handle.shutdown();
handle.join();
Ok(())
}

View file

@ -0,0 +1,739 @@
pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime – Actors</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header-left { display: flex; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.replay-badge {
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
vertical-align: middle;
}
.replay-badge.visible { display: inline-block; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.progress-bar-wrap {
display: none; width: 100%; height: 3px; background: #2a2d3e;
}
.progress-bar-wrap.visible { display: block; }
.progress-fill {
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px; padding: 12px;
}
.panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; overflow: hidden;
}
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.full-width { grid-column: 1 / -1; }
.stats-cards {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.stat-card {
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
canvas { width: 100%; height: 200px; }
.search-wrap { margin-bottom: 10px; display: flex; align-items: center; gap: 12px; }
.search-input {
background: #1c1f2e; border: 1px solid #2a2d3e; color: #e0e0e0;
padding: 6px 10px; border-radius: 4px; font-family: inherit;
font-size: 12px; width: 300px; outline: none;
}
.search-input:focus { border-color: #6366f1; }
.search-info { color: #555; font-size: 11px; }
.actor-list-wrap { max-height: 500px; overflow-y: auto; }
.actor-list-wrap table { width: 100%; border-collapse: collapse; }
.actor-list-wrap th, .actor-list-wrap td {
padding: 4px 8px; text-align: left; border-bottom: 1px solid #2a2d3e; font-size: 12px;
}
.actor-list-wrap th {
color: #888; font-weight: 500; position: sticky; top: 0; background: #161822;
}
.sortable { cursor: pointer; user-select: none; }
.sortable:hover { color: #e0e0e0; }
.sort-arrow { font-size: 10px; margin-left: 4px; color: #6366f1; }
.depth-bar {
height: 8px; border-radius: 2px; max-width: 120px; min-width: 2px;
}
.msg-type { color: #4caf50; }
.msg-type.none { color: #555; font-style: italic; }
tr.focused { background: #1c1f2e; }
tr.clickable { cursor: pointer; }
tr.clickable:hover { background: #1a1d2c; }
.detail-panel { display: none; }
.detail-panel.visible { display: block; }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.detail-close {
background: none; border: 1px solid #2a2d3e; color: #888; border-radius: 3px;
padding: 2px 8px; cursor: pointer; font-family: inherit; font-size: 11px;
}
.detail-close:hover { color: #e0e0e0; border-color: #555; }
.detail-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px;
}
.detail-item { background: #1c1f2e; border-radius: 4px; padding: 8px 10px; }
.detail-item .d-label { font-size: 10px; color: #888; text-transform: uppercase; }
.detail-item .d-value { font-size: 14px; font-weight: 700; color: #fff; margin-top: 2px; word-break: break-all; }
.poisoned-badge {
background: #f44336; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 6px; border-radius: 3px; letter-spacing: 0.5px;
}
.healthy-badge { color: #4caf50; font-size: 12px; }
canvas.sparkline { width: 100%; height: 60px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>
Swactor Runtime Dashboard
<span id="statusDot" class="status-dot"></span>
<span id="replayBadge" class="replay-badge">REPLAY</span>
</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link active">Actors</a>
</nav>
</div>
<div class="header-right">
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
<span id="uptimeLabel" style="color:#888;font-size:12px;"></span>
</div>
</div>
<div id="progressBarWrap" class="progress-bar-wrap">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="grid">
<!-- Stat cards -->
<div class="panel full-width">
<h2>Actor Stats</h2>
<div class="stats-cards">
<div class="stat-card"><div class="value" id="statTotal">0</div><div class="label">Total Actors</div></div>
<div class="stat-card"><div class="value" id="statAvgMbox">0</div><div class="label">Avg Mailbox</div></div>
<div class="stat-card"><div class="value" id="statMaxMbox">0</div><div class="label">Max Mailbox</div></div>
<div class="stat-card"><div class="value" id="statActiveWorkers">0</div><div class="label">Active Workers</div></div>
</div>
</div>
<!-- Mailbox depth distribution -->
<div class="panel">
<h2>Mailbox Depth Distribution</h2>
<canvas id="depthChart"></canvas>
</div>
<!-- Actors per worker -->
<div class="panel">
<h2>Actors per Worker</h2>
<canvas id="workerChart"></canvas>
</div>
<!-- Actor table -->
<div class="panel full-width">
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
<div class="search-wrap">
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address or worker..." />
<span id="searchInfo" class="search-info"></span>
</div>
<div class="actor-list-wrap">
<table>
<thead>
<tr>
<th class="sortable" data-sort="address">Address <span id="sortArrowAddress" class="sort-arrow"></span></th>
<th class="sortable" data-sort="worker">Worker <span id="sortArrowWorker" class="sort-arrow"></span></th>
<th class="sortable" data-sort="mailbox">Mailbox <span id="sortArrowMailbox" class="sort-arrow"></span></th>
<th class="sortable" data-sort="msgs">Msgs <span id="sortArrowMsgs" class="sort-arrow"></span></th>
<th>Last Msg</th>
<th>Depth</th>
</tr>
</thead>
<tbody id="actorTableBody"></tbody>
</table>
</div>
</div>
<!-- Actor detail panel -->
<div id="detailPanel" class="panel full-width detail-panel">
<div class="detail-header">
<h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
<button class="detail-close" id="detailClose">Close</button>
</div>
<div class="detail-grid">
<div class="detail-item"><div class="d-label">Full Address</div><div class="d-value" id="detailFullAddr" style="font-size:11px;"></div></div>
<div class="detail-item"><div class="d-label">Worker</div><div class="d-value" id="detailWorker"></div></div>
<div class="detail-item"><div class="d-label">Mailbox Depth</div><div class="d-value" id="detailMailbox"></div></div>
<div class="detail-item"><div class="d-label">Messages Processed</div><div class="d-value" id="detailMsgCount"></div></div>
<div class="detail-item"><div class="d-label">Last Message Type</div><div class="d-value" id="detailLastMsg"></div></div>
<div class="detail-item"><div class="d-label">Status</div><div class="d-value" id="detailStatus"></div></div>
</div>
<h2>Mailbox Depth History</h2>
<canvas id="sparkline" class="sparkline"></canvas>
</div>
</div>
<script>
(function() {
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
var isReplay = (DASHBOARD_MODE === 'replay');
var lastUptimeMs = null;
var lastStatsTime = null;
var dot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel');
var replayBadge = document.getElementById('replayBadge');
var replaySpeed = document.getElementById('replaySpeed');
var replayPct = document.getElementById('replayPct');
var progressBarWrap = document.getElementById('progressBarWrap');
var progressFill = document.getElementById('progressFill');
if (isReplay) {
replayBadge.className = 'replay-badge visible';
progressBarWrap.className = 'progress-bar-wrap visible';
dot.className = 'status-dot replaying';
uptimeLabel.style.display = 'none';
}
function updateUptime() {
if (isReplay) return;
var up = lastUptimeMs;
if (up !== null && lastStatsTime !== null) {
up += (Date.now() - lastStatsTime);
}
if (up === null) { uptimeLabel.textContent = ''; return; }
var s = Math.floor(up / 1000);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
var parts = [];
if (d > 0) parts.push(d + 'd');
if (h > 0 || d > 0) parts.push(h + 'h');
parts.push(m + 'm');
parts.push(sec + 's');
uptimeLabel.textContent = parts.join(' ');
}
setInterval(updateUptime, 1000);
// ── State ──────────────────────────────────────────────
var currentActors = [];
var sortCol = 'worker';
var sortAsc = true;
var searchTimer = null;
var focusedAddrHex = null;
var depthHistory = {};
var HISTORY_MAX = 120;
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
// ── Helpers ────────────────────────────────────────────
function addrToHex(addr) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var j = 0; j < Math.min(8, bytes.length); j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
return hex + '\u2026';
}
function addrToFullHex(addr) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var j = 0; j < bytes.length; j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
return hex;
}
function shortTypeName(full) {
if (!full) return '';
var parts = full.split('::');
return parts[parts.length - 1];
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function depthColor(d) {
if (d === 0) return '#4caf50';
if (d <= 2) return '#8bc34a';
if (d <= 5) return '#cddc39';
if (d <= 10) return '#ff9800';
if (d <= 20) return '#ff5722';
return '#f44336';
}
// ── Stat cards ─────────────────────────────────────────
function updateStatCards(actors) {
var total = actors.length;
var sum = 0, max = 0;
var workerSet = {};
for (var i = 0; i < actors.length; i++) {
sum += actors[i].mailbox_depth;
if (actors[i].mailbox_depth > max) max = actors[i].mailbox_depth;
workerSet[actors[i].worker_id] = true;
}
var avg = total > 0 ? (sum / total).toFixed(1) : '0';
document.getElementById('statTotal').textContent = total;
document.getElementById('statAvgMbox').textContent = avg;
document.getElementById('statMaxMbox').textContent = max;
document.getElementById('statActiveWorkers').textContent = Object.keys(workerSet).length;
}
// ── Mailbox depth distribution chart ───────────────────
var depthCanvas = document.getElementById('depthChart');
var depthCtx = depthCanvas.getContext('2d');
var buckets = [
{label: '0', min: 0, max: 0},
{label: '1-2', min: 1, max: 2},
{label: '3-5', min: 3, max: 5},
{label: '6-10', min: 6, max: 10},
{label: '11-20', min: 11, max: 20},
{label: '21-50', min: 21, max: 50},
{label: '50+', min: 51, max: Infinity}
];
var bucketColors = ['#4caf50','#8bc34a','#cddc39','#ff9800','#ff5722','#f44336','#d32f2f'];
function drawDepthChart(actors) {
var dpr = window.devicePixelRatio || 1;
var rect = depthCanvas.getBoundingClientRect();
depthCanvas.width = rect.width * dpr;
depthCanvas.height = rect.height * dpr;
depthCtx.scale(dpr, dpr);
var W = rect.width, H = rect.height;
depthCtx.clearRect(0, 0, W, H);
var counts = buckets.map(function() { return 0; });
for (var i = 0; i < actors.length; i++) {
var d = actors[i].mailbox_depth;
for (var b = 0; b < buckets.length; b++) {
if (d >= buckets[b].min && d <= buckets[b].max) {
counts[b]++;
break;
}
}
}
var maxCount = Math.max(1, Math.max.apply(null, counts));
var barW = Math.max(12, Math.floor((W - 40) / buckets.length) - 8);
var chartH = H - 35;
for (var i = 0; i < buckets.length; i++) {
var x = 20 + i * (barW + 8);
var h = (counts[i] / maxCount) * (chartH - 10);
depthCtx.fillStyle = bucketColors[i];
depthCtx.globalAlpha = 0.85;
depthCtx.fillRect(x, chartH - h, barW, h);
if (counts[i] > 0) {
depthCtx.globalAlpha = 1;
depthCtx.fillStyle = '#e0e0e0';
depthCtx.font = '10px monospace';
depthCtx.textAlign = 'center';
depthCtx.fillText(counts[i], x + barW / 2, chartH - h - 4);
}
depthCtx.globalAlpha = 1;
depthCtx.fillStyle = '#888';
depthCtx.font = '10px monospace';
depthCtx.textAlign = 'center';
depthCtx.fillText(buckets[i].label, x + barW / 2, H - 4);
}
}
// ── Actors per worker chart ────────────────────────────
var workerCanvas = document.getElementById('workerChart');
var workerCtx = workerCanvas.getContext('2d');
function drawWorkerChart(actors) {
var dpr = window.devicePixelRatio || 1;
var rect = workerCanvas.getBoundingClientRect();
workerCanvas.width = rect.width * dpr;
workerCanvas.height = rect.height * dpr;
workerCtx.scale(dpr, dpr);
var W = rect.width, H = rect.height;
workerCtx.clearRect(0, 0, W, H);
var workerCounts = {};
for (var i = 0; i < actors.length; i++) {
var wid = actors[i].worker_id;
workerCounts[wid] = (workerCounts[wid] || 0) + 1;
}
var entries = Object.keys(workerCounts).sort(function(a, b) { return +a - +b; })
.map(function(wid) { return {id: +wid, count: workerCounts[wid]}; });
if (entries.length === 0) return;
var maxCount = Math.max(1, Math.max.apply(null, entries.map(function(e) { return e.count; })));
var barW = Math.max(12, Math.floor((W - 40) / entries.length) - 8);
var chartH = H - 35;
for (var i = 0; i < entries.length; i++) {
var x = 20 + i * (barW + 8);
var h = (entries[i].count / maxCount) * (chartH - 10);
workerCtx.fillStyle = colors[entries[i].id % colors.length];
workerCtx.globalAlpha = 0.85;
workerCtx.fillRect(x, chartH - h, barW, h);
workerCtx.globalAlpha = 1;
workerCtx.fillStyle = '#e0e0e0';
workerCtx.font = '10px monospace';
workerCtx.textAlign = 'center';
workerCtx.fillText(entries[i].count, x + barW / 2, chartH - h - 4);
workerCtx.fillStyle = '#888';
workerCtx.fillText('W' + entries[i].id, x + barW / 2, H - 4);
}
}
// ── Actor table ────────────────────────────────────────
var MAX_TABLE_ROWS = 2000;
function renderActorTable() {
var filter = document.getElementById('actorSearch').value.toLowerCase();
var filtered = currentActors;
if (filter) {
filtered = currentActors.filter(function(a) {
var hex = addrToHex(a.address).toLowerCase();
return hex.indexOf(filter) >= 0 || ('w' + a.worker_id).indexOf(filter) >= 0;
});
}
// Sort
filtered.sort(function(a, b) {
var va, vb;
if (sortCol === 'address') {
va = addrToHex(a.address);
vb = addrToHex(b.address);
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
} else if (sortCol === 'worker') {
va = a.worker_id; vb = b.worker_id;
} else if (sortCol === 'msgs') {
va = a.messages_processed || 0; vb = b.messages_processed || 0;
} else {
va = a.mailbox_depth; vb = b.mailbox_depth;
}
return sortAsc ? va - vb : vb - va;
});
// Update sort arrows
['address', 'worker', 'mailbox', 'msgs'].forEach(function(col) {
var key = col.charAt(0).toUpperCase() + col.slice(1);
var el = document.getElementById('sortArrow' + key);
if (col === sortCol) {
el.textContent = sortAsc ? '\u25B2' : '\u25BC';
} else {
el.textContent = '';
}
});
var maxDepth = 1;
for (var i = 0; i < currentActors.length; i++) {
if (currentActors[i].mailbox_depth > maxDepth) maxDepth = currentActors[i].mailbox_depth;
}
var tbody = document.getElementById('actorTableBody');
tbody.innerHTML = '';
var count = Math.min(filtered.length, MAX_TABLE_ROWS);
for (var i = 0; i < count; i++) {
var a = filtered[i];
var hex = addrToHex(a.address);
var fullHex = addrToFullHex(a.address);
var pct = Math.round((a.mailbox_depth / maxDepth) * 120);
var bc = depthColor(a.mailbox_depth);
var hasMsg = !!a.last_msg_type;
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : '\u2014';
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
var msgTitle = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
var tr = document.createElement('tr');
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
if (a.poisoned) tr.style.opacity = '0.6';
tr.setAttribute('data-addr', fullHex);
tr.innerHTML =
'<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>' +
'<td>W' + a.worker_id + '</td>' +
'<td>' + a.mailbox_depth + '</td>' +
'<td>' + (a.messages_processed || 0).toLocaleString() + '</td>' +
'<td class="' + msgClass + '"' + msgTitle + '>' + escapeHtml(msgShort) + '</td>' +
'<td><div class="depth-bar" style="width:' + pct + 'px;background:' + bc + ';"></div></td>';
tr.addEventListener('click', (function(fh) { return function() { focusActor(fh); }; })(fullHex));
tbody.appendChild(tr);
}
if (filtered.length > MAX_TABLE_ROWS) {
var tr2 = document.createElement('tr');
tr2.innerHTML = '<td colspan="6" style="color:#555;">... and ' + (filtered.length - MAX_TABLE_ROWS) + ' more</td>';
tbody.appendChild(tr2);
}
var info = '(' + filtered.length + (filtered.length !== currentActors.length ? ' of ' + currentActors.length : '') + ')';
document.getElementById('actorCount').textContent = info;
}
// ── Focus / detail panel ───────────────────────────────
function focusActor(fullHex) {
focusedAddrHex = fullHex;
document.getElementById('detailPanel').className = 'panel full-width detail-panel visible';
updateDetailPanel();
renderActorTable();
}
function clearFocus() {
focusedAddrHex = null;
document.getElementById('detailPanel').className = 'panel full-width detail-panel';
renderActorTable();
}
document.getElementById('detailClose').addEventListener('click', clearFocus);
function updateDetailPanel() {
if (!focusedAddrHex) return;
var actor = null;
for (var i = 0; i < currentActors.length; i++) {
if (addrToFullHex(currentActors[i].address) === focusedAddrHex) {
actor = currentActors[i];
break;
}
}
if (!actor) {
document.getElementById('detailAddr').textContent = focusedAddrHex.substring(0, 16) + '\u2026 (gone)';
return;
}
document.getElementById('detailAddr').textContent = addrToHex(actor.address);
document.getElementById('detailFullAddr').textContent = focusedAddrHex;
document.getElementById('detailWorker').textContent = 'W' + actor.worker_id;
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();
var hasMsg = !!actor.last_msg_type;
document.getElementById('detailLastMsg').innerHTML = hasMsg
? '<span class="msg-type" title="' + escapeHtml(actor.last_msg_type) + '">' + escapeHtml(shortTypeName(actor.last_msg_type)) + '</span>'
: '<span class="msg-type none">\u2014</span>';
var isPoisoned = !!actor.poisoned;
document.getElementById('detailStatus').innerHTML = isPoisoned
? '<span class="poisoned-badge">POISONED</span>'
: '<span class="healthy-badge">Healthy</span>';
drawSparkline();
}
// ── Sparkline ──────────────────────────────────────────
function drawSparkline() {
var canvas = document.getElementById('sparkline');
var ctx = canvas.getContext('2d');
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
var W = rect.width, H = rect.height;
ctx.clearRect(0, 0, W, H);
var hist = depthHistory[focusedAddrHex];
if (!hist || hist.length < 2) {
ctx.fillStyle = '#555';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText('Collecting data\u2026', W / 2, H / 2);
return;
}
var max = Math.max(1, Math.max.apply(null, hist));
var padY = 6, padX = 4;
var drawW = W - padX * 2;
var drawH = H - padY * 2;
// Fill area
ctx.beginPath();
ctx.moveTo(padX, H - padY);
for (var i = 0; i < hist.length; i++) {
var x = padX + (i / (hist.length - 1)) * drawW;
var y = (H - padY) - (hist[i] / max) * drawH;
ctx.lineTo(x, y);
}
ctx.lineTo(padX + drawW, H - padY);
ctx.closePath();
ctx.fillStyle = 'rgba(99, 102, 241, 0.15)';
ctx.fill();
// Line
ctx.beginPath();
for (var i = 0; i < hist.length; i++) {
var x = padX + (i / (hist.length - 1)) * drawW;
var y = (H - padY) - (hist[i] / max) * drawH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 1.5;
ctx.stroke();
// Label
var last = hist[hist.length - 1];
ctx.fillStyle = '#e0e0e0';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText('depth: ' + last + ' max: ' + max, W - padX, padY + 8);
}
// ── Sort click handlers ────────────────────────────────
var sortHeaders = document.querySelectorAll('.sortable');
for (var i = 0; i < sortHeaders.length; i++) {
sortHeaders[i].addEventListener('click', function() {
var col = this.getAttribute('data-sort');
if (sortCol === col) { sortAsc = !sortAsc; }
else { sortCol = col; sortAsc = true; }
renderActorTable();
});
}
// ── Search handler ─────────────────────────────────────
document.getElementById('actorSearch').addEventListener('keyup', function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(renderActorTable, 150);
});
// ── Status helpers ─────────────────────────────────────
function setStatus(s) {
if (s === 'done') {
dot.className = 'status-dot done';
if (isReplay) {
replayPct.textContent = '100%';
progressFill.style.width = '100%';
}
} else if (s === 'disconnected') {
dot.className = 'status-dot disconnected';
} else {
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
}
}
// ── SSE connection ─────────────────────────────────────
var es = new EventSource('/events');
es.addEventListener('stats', function(e) {
try {
var data = JSON.parse(e.data);
if (typeof data.uptime_ms === 'number') {
lastUptimeMs = data.uptime_ms;
lastStatsTime = Date.now();
}
currentActors = data.actor_details || [];
updateStatCards(currentActors);
drawDepthChart(currentActors);
drawWorkerChart(currentActors);
// Accumulate per-actor depth history
var liveAddrs = {};
for (var i = 0; i < currentActors.length; i++) {
var fh = addrToFullHex(currentActors[i].address);
liveAddrs[fh] = true;
if (!depthHistory[fh]) depthHistory[fh] = [];
depthHistory[fh].push(currentActors[i].mailbox_depth);
if (depthHistory[fh].length > HISTORY_MAX) depthHistory[fh].shift();
}
// Clean up stale entries for removed actors
for (var key in depthHistory) {
if (!liveAddrs[key]) delete depthHistory[key];
}
renderActorTable();
if (focusedAddrHex) updateDetailPanel();
} catch(err) { console.error('stats parse error', err); }
});
es.addEventListener('replay_meta', function(e) {
try {
var meta = JSON.parse(e.data);
replaySpeed.textContent = meta.speed + 'x';
replaySpeed.style.display = 'inline';
replayPct.style.display = 'inline';
replayPct.textContent = '0%';
} catch(err) { console.error('replay_meta parse error', err); }
});
es.addEventListener('replay_progress', function(e) {
try {
var data = JSON.parse(e.data);
var pct = Math.round(data.progress * 100);
replayPct.textContent = pct + '%';
progressFill.style.width = pct + '%';
} catch(err) { console.error('replay_progress parse error', err); }
});
es.addEventListener('done', function() {
setStatus('done');
es.close();
});
es.onerror = function() {
setStatus('disconnected');
};
es.onopen = function() {
setStatus('connected');
};
})();
</script>
</body>
</html>
"##;

View file

@ -0,0 +1,60 @@
//! Lock-free-ish stats collector that implements [`StatsHook`].
//!
//! Workers push per-actor snapshots here; dashboard code reads them back
//! via [`actor_details()`](StatsCollector::actor_details) or
//! [`enrich()`](StatsCollector::enrich).
use std::sync::{Arc, RwLock};
use swactor::stats::{ActorInfo, ActorSnapshot, RuntimeStats, StatsHook};
/// Collects per-actor snapshots pushed by worker threads.
///
/// Create with [`new()`](Self::new), pass to both
/// [`Runtime::set_stats_hook()`](swactor::runtime::Runtime::set_stats_hook)
/// and the dashboard.
pub struct StatsCollector {
/// One slot per worker — writers never contend with each other.
slots: Vec<RwLock<Vec<ActorInfo>>>,
}
impl StatsCollector {
pub fn new(num_workers: usize) -> Arc<Self> {
let slots = (0..num_workers)
.map(|_| RwLock::new(Vec::new()))
.collect();
Arc::new(Self { slots })
}
/// Collect all per-actor details across workers.
pub fn actor_details(&self) -> Vec<ActorInfo> {
let mut out = Vec::new();
for slot in &self.slots {
let guard = slot.read().unwrap();
out.extend(guard.iter().cloned());
}
out
}
/// Patch `actor_details` into an existing [`RuntimeStats`].
pub fn enrich(&self, stats: &mut RuntimeStats) {
stats.actor_details = self.actor_details();
}
}
impl StatsHook for StatsCollector {
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]) {
if let Some(slot) = self.slots.get(worker_id) {
let mut guard = slot.write().unwrap();
guard.clear();
guard.extend(snapshots.iter().map(|s| ActorInfo {
address: s.address,
worker_id,
mailbox_depth: s.mailbox_depth,
last_msg_type: s.last_msg_type.map(|t| t.to_string()),
messages_processed: s.messages_processed,
poisoned: s.poisoned,
}));
}
}
}

View file

@ -33,6 +33,15 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
}
.replay-badge.visible { display: inline-block; }
.header-left { display: flex; align-items: center; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.progress-bar-wrap {
@ -93,6 +102,27 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
.level-DEBUG { color: #2196f3; }
.level-TRACE { color: #666; }
.worker-detail-panel { grid-column: 1 / -1; }
.worker-detail-scroll { max-height: 360px; overflow-y: auto; }
.worker-group { margin-bottom: 8px; border: 1px solid #2a2d3e; border-radius: 4px; overflow: hidden; }
.worker-group-header {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 12px; background: #1c1f2e; cursor: pointer; user-select: none;
}
.worker-group-header:hover { background: #22253a; }
.worker-group-header .wid { font-weight: 700; }
.worker-group-header .summary { color: #888; font-size: 11px; }
.worker-group-header .toggle { color: #555; font-size: 14px; }
.worker-group-body { display: none; }
.worker-group.open .worker-group-body { display: block; }
.worker-group-body table { width: 100%; border-collapse: collapse; }
.worker-group-body th, .worker-group-body td {
padding: 3px 10px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
}
.worker-group-body th { color: #888; font-weight: 500; background: #161822; }
.msg-type { color: #4caf50; }
.msg-type.none { color: #555; font-style: italic; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
@ -100,11 +130,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
</head>
<body>
<div class="header">
<h1>
Swactor Runtime Dashboard
<span id="statusDot" class="status-dot"></span>
<span id="replayBadge" class="replay-badge">REPLAY</span>
</h1>
<div class="header-left">
<h1>
Swactor Runtime Dashboard
<span id="statusDot" class="status-dot"></span>
<span id="replayBadge" class="replay-badge">REPLAY</span>
</h1>
<nav class="nav-links">
<a href="/" class="nav-link active">Overview</a>
<a href="/actors" class="nav-link">Actors</a>
</nav>
</div>
<div class="header-right">
<span id="replaySpeed" style="color:#2196f3;font-size:12px;display:none;"></span>
<span id="replayPct" style="color:#888;font-size:12px;display:none;"></span>
@ -142,6 +178,11 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
</div>
</div>
<div class="panel worker-detail-panel">
<h2>Worker Details</h2>
<div class="worker-detail-scroll" id="workerDetailContainer"></div>
</div>
<div class="panel log-panel">
<h2>Activity Log <span id="logCount" style="color:#555;font-weight:400;"></span></h2>
<div class="log-wrap" id="logWrap">
@ -156,10 +197,11 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
<script>
(function() {
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
var startTime = Date.now();
var logRowCount = 0;
var MAX_LOG_ROWS = 2000;
var isReplay = (DASHBOARD_MODE === 'replay');
var lastUptimeMs = null;
var lastStatsTime = null;
var dot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel');
@ -178,10 +220,22 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
function updateUptime() {
if (isReplay) return;
var s = Math.floor((Date.now() - startTime) / 1000);
var m = Math.floor(s / 60);
var h = Math.floor(m / 60);
uptimeLabel.textContent = (h > 0 ? h + 'h ' : '') + (m % 60) + 'm ' + (s % 60) + 's';
var up = lastUptimeMs;
if (up !== null && lastStatsTime !== null) {
up += (Date.now() - lastStatsTime);
}
if (up === null) { uptimeLabel.textContent = ''; return; }
var s = Math.floor(up / 1000);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
var parts = [];
if (d > 0) parts.push(d + 'd');
if (h > 0 || d > 0) parts.push(h + 'h');
parts.push(m + 'm');
parts.push(sec + 's');
uptimeLabel.textContent = parts.join(' ');
}
setInterval(updateUptime, 1000);
@ -230,6 +284,10 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
}
function updateStats(data) {
if (typeof data.uptime_ms === 'number') {
lastUptimeMs = data.uptime_ms;
lastStatsTime = Date.now();
}
var totalActors = data.actors ? data.actors.length : 0;
var totalMsgs = data.workers ? data.workers.reduce(function(s, w) { return s + w.messages_processed; }, 0) : 0;
var totalMailbox = data.workers ? data.workers.reduce(function(s, w) { return s + w.mailbox_depth; }, 0) : 0;
@ -266,6 +324,90 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
tbody.appendChild(tr2);
}
}
updateWorkerDetails(data);
}
function formatAddr(addr) {
var hex = '';
if (addr && addr.length > 0) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
for (var j = 0; j < Math.min(8, bytes.length); j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
hex += '\u2026';
}
return hex;
}
function shortTypeName(full) {
if (!full) return '';
var parts = full.split('::');
return parts[parts.length - 1];
}
function updateWorkerDetails(data) {
var container = document.getElementById('workerDetailContainer');
if (!data.workers) return;
var groups = {};
data.workers.forEach(function(w) { groups[w.id] = { info: w, actors: [] }; });
if (data.actor_details) {
data.actor_details.forEach(function(a) {
if (groups[a.worker_id]) groups[a.worker_id].actors.push(a);
});
}
var openState = {};
container.querySelectorAll('.worker-group').forEach(function(g) {
openState[g.dataset.wid] = g.classList.contains('open');
});
container.innerHTML = '';
var wids = Object.keys(groups).sort(function(a, b) { return a - b; });
wids.forEach(function(wid) {
var g = groups[wid];
var div = document.createElement('div');
var isOpen = openState[wid] || false;
div.className = 'worker-group' + (isOpen ? ' open' : '');
div.dataset.wid = wid;
var hdr = document.createElement('div');
hdr.className = 'worker-group-header';
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
hdr.innerHTML =
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
'<span class="summary">' + g.actors.length + ' actors, ' +
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' +
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
hdr.onclick = function() {
div.classList.toggle('open');
hdr.querySelector('.toggle').textContent = div.classList.contains('open') ? '\u25BC' : '\u25B6';
};
div.appendChild(hdr);
var body = document.createElement('div');
body.className = 'worker-group-body';
if (g.actors.length === 0) {
body.innerHTML = '<div style="padding:6px 12px;color:#555;">No actors</div>';
} else {
var rows = '';
g.actors.forEach(function(a) {
var hex = formatAddr(a.address);
var hasMsg = !!a.last_msg_type;
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
rows += '<tr><td style="color:#aaa;">' + hex + '</td>' +
'<td>' + a.mailbox_depth + '</td>' +
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
});
body.innerHTML = '<table><thead><tr><th>Address</th><th>Mailbox</th><th>Last Message</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
div.appendChild(body);
container.appendChild(div);
});
}
function addLogEvents(events) {

View file

@ -0,0 +1,498 @@
//! Line-oriented diagnostic protocol for LLM-driven runtime investigation.
//!
//! Send text commands on stdin, receive JSON responses on stdout (one per line).
//! All human-readable diagnostics go to stderr.
//!
//! # Protocol
//!
//! ```text
//! → overview
//! ← {"ok":true,"command":"overview","data":{"workers":4,"actors":120,...}}
//!
//! → hot 5
//! ← {"ok":true,"command":"hot","data":[{"address":"a1b2...","worker_id":2,"mailbox_depth":47},..]}
//!
//! → diff 2
//! ← {"ok":true,"command":"diff","data":{"elapsed_s":2.0,"delta_messages":8432,"msg_per_sec":4216.0,...}}
//! ```
use std::io::{self, BufRead, Write};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::Serialize;
use swactor::runtime::Runtime;
use swactor::stats::RuntimeStats;
use crate::collector::StatsCollector;
/// Run the investigate REPL. Blocks until stdin is closed or `quit` is received.
pub fn run_investigate(runtime: Arc<Runtime>, collector: Arc<StatsCollector>) -> io::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
eprintln!("swactor investigate protocol ready — send `help` for commands");
for line in stdin.lock().lines() {
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
let cmd = parts[0];
let args = &parts[1..];
let response = match cmd {
"help" => cmd_help(),
"overview" => cmd_overview(&runtime, &collector),
"workers" => cmd_workers(&runtime),
"worker" => cmd_worker(&runtime, &collector, args),
"actors" => cmd_actors(&runtime, &collector, args),
"actor" => cmd_actor(&runtime, &collector, args),
"hot" => cmd_hot(&runtime, &collector, args),
"phases" => cmd_phases(&runtime, args),
"diff" => cmd_diff(&runtime, &collector, args),
"quit" | "exit" => break,
_ => err_response(cmd, &format!("unknown command `{cmd}` — try `help`")),
};
stdout.write_all(response.as_bytes())?;
stdout.write_all(b"\n")?;
stdout.flush()?;
}
Ok(())
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn ok_response(cmd: &str, data: impl Serialize) -> String {
serde_json::to_string(&serde_json::json!({
"ok": true,
"command": cmd,
"data": data,
}))
.unwrap_or_else(|e| err_response(cmd, &format!("serialization error: {e}")))
}
fn err_response(cmd: &str, msg: &str) -> String {
serde_json::to_string(&serde_json::json!({
"ok": false,
"command": cmd,
"error": msg,
}))
.unwrap()
}
fn format_addr(addr: &swactor::actor::ActorAddress) -> String {
format!("{addr}")
}
fn full_hex(addr: &swactor::actor::ActorAddress) -> String {
addr.0.iter().map(|b| format!("{b:02x}")).collect()
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn enriched_stats(rt: &Runtime, col: &StatsCollector) -> RuntimeStats {
let mut s = rt.stats();
col.enrich(&mut s);
s
}
// ── Commands ────────────────────────────────────────────────────────────
fn cmd_help() -> String {
ok_response(
"help",
serde_json::json!({
"commands": [
{"name": "overview", "usage": "overview", "description": "Summary: worker count, actor count, total messages, mailbox depth, panics"},
{"name": "workers", "usage": "workers", "description": "Per-worker stats: actors, mailbox depth, messages, sends (local/cross/inbox), panics"},
{"name": "worker", "usage": "worker <id>", "description": "Single worker detail with tick-phase timing breakdown"},
{"name": "actors", "usage": "actors [--sort mailbox|worker|address] [--limit N] [--worker W]", "description": "List actors with optional sorting, limit, and worker filter"},
{"name": "actor", "usage": "actor <hex_prefix>", "description": "Find actor(s) whose address starts with the given hex prefix"},
{"name": "hot", "usage": "hot [N]", "description": "Top N actors by mailbox depth (default 10)"},
{"name": "phases", "usage": "phases [worker_id]", "description": "Tick-phase time breakdown (all workers or one)"},
{"name": "diff", "usage": "diff <seconds>", "description": "Collect two snapshots N seconds apart, report deltas and rates"},
{"name": "quit", "usage": "quit", "description": "Exit the investigate session"},
]
}),
)
}
fn cmd_overview(rt: &Runtime, col: &StatsCollector) -> String {
let stats = enriched_stats(rt, col);
let total_msgs: u64 = stats.workers.iter().map(|w| w.messages_processed).sum();
let total_mailbox: usize = stats.workers.iter().map(|w| w.mailbox_depth).sum();
let total_panics: u64 = stats.workers.iter().map(|w| w.panics).sum();
let total_type_mismatches: u64 = stats.workers.iter().map(|w| w.type_mismatches).sum();
let total_local: u64 = stats.workers.iter().map(|w| w.local_sends).sum();
let total_cross: u64 = stats.workers.iter().map(|w| w.cross_sends).sum();
let total_inbox: u64 = stats.workers.iter().map(|w| w.inbox_sends).sum();
ok_response(
"overview",
serde_json::json!({
"workers": stats.num_workers,
"actors": stats.actor_details.len(),
"total_messages_processed": total_msgs,
"total_mailbox_depth": total_mailbox,
"total_panics": total_panics,
"total_type_mismatches": total_type_mismatches,
"sends": {
"local": total_local,
"cross_worker": total_cross,
"inbox": total_inbox,
},
}),
)
}
fn cmd_workers(rt: &Runtime) -> String {
let stats = rt.stats();
let workers: Vec<_> = stats
.workers
.iter()
.map(|w| {
serde_json::json!({
"id": w.id,
"actors": w.num_actors,
"mailbox_depth": w.mailbox_depth,
"messages_processed": w.messages_processed,
"local_sends": w.local_sends,
"cross_sends": w.cross_sends,
"inbox_sends": w.inbox_sends,
"type_mismatches": w.type_mismatches,
"panics": w.panics,
})
})
.collect();
ok_response("workers", workers)
}
fn cmd_worker(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let id: usize = match args.first().and_then(|s| s.parse().ok()) {
Some(id) => id,
None => return err_response("worker", "usage: worker <id>"),
};
let stats = enriched_stats(rt, col);
let w = match stats.workers.iter().find(|w| w.id == id) {
Some(w) => w,
None => {
return err_response(
"worker",
&format!("worker {id} not found (have 0..{})", stats.num_workers),
)
}
};
// Tick phase breakdown for this worker
let timings = stats.tick_timings.get(id).cloned().unwrap_or_default();
let phase_breakdown = compute_phase_breakdown(&timings);
let actors_on_worker: Vec<_> = stats
.actor_details
.iter()
.filter(|a| a.worker_id == id)
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response(
"worker",
serde_json::json!({
"id": w.id,
"actors": w.num_actors,
"mailbox_depth": w.mailbox_depth,
"messages_processed": w.messages_processed,
"local_sends": w.local_sends,
"cross_sends": w.cross_sends,
"inbox_sends": w.inbox_sends,
"type_mismatches": w.type_mismatches,
"panics": w.panics,
"tick_phases": phase_breakdown,
"actor_details": actors_on_worker,
}),
)
}
fn cmd_actors(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let stats = enriched_stats(rt, col);
let mut actors = stats.actor_details.clone();
// Parse flags
let mut sort_by = "mailbox";
let mut limit: usize = usize::MAX;
let mut worker_filter: Option<usize> = None;
let mut i = 0;
while i < args.len() {
match args[i] {
"--sort" if i + 1 < args.len() => {
sort_by = args[i + 1];
i += 2;
}
"--limit" if i + 1 < args.len() => {
limit = args[i + 1].parse().unwrap_or(usize::MAX);
i += 2;
}
"--worker" if i + 1 < args.len() => {
worker_filter = args[i + 1].parse().ok();
i += 2;
}
_ => {
i += 1;
}
}
}
if let Some(wid) = worker_filter {
actors.retain(|a| a.worker_id == wid);
}
match sort_by {
"mailbox" => actors.sort_by(|a, b| b.mailbox_depth.cmp(&a.mailbox_depth)),
"worker" => actors.sort_by_key(|a| a.worker_id),
"address" => actors.sort_by(|a, b| a.address.0.cmp(&b.address.0)),
other => return err_response("actors", &format!("unknown sort field `{other}` — use mailbox|worker|address")),
}
actors.truncate(limit);
let rows: Vec<_> = actors
.iter()
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"address_full": full_hex(&a.address),
"worker_id": a.worker_id,
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response(
"actors",
serde_json::json!({
"total": stats.actor_details.len(),
"returned": rows.len(),
"sort": sort_by,
"actors": rows,
}),
)
}
fn cmd_actor(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let prefix = match args.first() {
Some(p) => *p,
None => return err_response("actor", "usage: actor <hex_prefix>"),
};
let stats = enriched_stats(rt, col);
let matches: Vec<_> = stats
.actor_details
.iter()
.filter(|a| full_hex(&a.address).starts_with(prefix))
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"address_full": full_hex(&a.address),
"worker_id": a.worker_id,
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response(
"actor",
serde_json::json!({
"prefix": prefix,
"matches": matches.len(),
"actors": matches,
}),
)
}
fn cmd_hot(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let n: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(10);
let stats = enriched_stats(rt, col);
let mut actors = stats.actor_details.clone();
actors.sort_by(|a, b| b.mailbox_depth.cmp(&a.mailbox_depth));
actors.truncate(n);
let rows: Vec<_> = actors
.iter()
.map(|a| {
serde_json::json!({
"address": format_addr(&a.address),
"address_full": full_hex(&a.address),
"worker_id": a.worker_id,
"mailbox_depth": a.mailbox_depth,
"last_msg_type": a.last_msg_type,
"messages_processed": a.messages_processed,
"poisoned": a.poisoned,
})
})
.collect();
ok_response("hot", rows)
}
fn cmd_phases(rt: &Runtime, args: &[&str]) -> String {
let stats = rt.stats();
let worker_filter: Option<usize> = args.first().and_then(|s| s.parse().ok());
let phase_names = [
"spawn_drain",
"transfer_drain",
"tick_all",
"spawn_drain_2",
"pending_local",
"stats_publish",
];
let mut results = Vec::new();
for (i, timings) in stats.tick_timings.iter().enumerate() {
if let Some(wid) = worker_filter {
if i != wid {
continue;
}
}
let breakdown = compute_phase_breakdown(timings);
results.push(serde_json::json!({
"worker_id": i,
"ticks_sampled": timings.len(),
"phases": breakdown,
"phase_names": phase_names,
}));
}
ok_response("phases", results)
}
fn cmd_diff(rt: &Runtime, col: &StatsCollector, args: &[&str]) -> String {
let secs: f64 = match args.first().and_then(|s| s.parse().ok()) {
Some(s) if s > 0.0 && s <= 30.0 => s,
Some(_) => return err_response("diff", "seconds must be between 0 and 30"),
None => return err_response("diff", "usage: diff <seconds>"),
};
let before = enriched_stats(rt, col);
let t0 = Instant::now();
std::thread::sleep(Duration::from_secs_f64(secs));
let after = enriched_stats(rt, col);
let elapsed = t0.elapsed().as_secs_f64();
let msgs_before: u64 = before.workers.iter().map(|w| w.messages_processed).sum();
let msgs_after: u64 = after.workers.iter().map(|w| w.messages_processed).sum();
let delta_msgs = msgs_after.saturating_sub(msgs_before);
let local_before: u64 = before.workers.iter().map(|w| w.local_sends).sum();
let local_after: u64 = after.workers.iter().map(|w| w.local_sends).sum();
let cross_before: u64 = before.workers.iter().map(|w| w.cross_sends).sum();
let cross_after: u64 = after.workers.iter().map(|w| w.cross_sends).sum();
let mailbox_before: usize = before.workers.iter().map(|w| w.mailbox_depth).sum();
let mailbox_after: usize = after.workers.iter().map(|w| w.mailbox_depth).sum();
let per_worker: Vec<_> = after
.workers
.iter()
.enumerate()
.map(|(i, w)| {
let prev = before.workers.get(i);
let d = prev
.map(|p| w.messages_processed.saturating_sub(p.messages_processed))
.unwrap_or(0);
serde_json::json!({
"worker_id": i,
"delta_messages": d,
"msg_per_sec": d as f64 / elapsed,
"actors_before": prev.map(|p| p.num_actors).unwrap_or(0),
"actors_after": w.num_actors,
"mailbox_before": prev.map(|p| p.mailbox_depth).unwrap_or(0),
"mailbox_after": w.mailbox_depth,
})
})
.collect();
ok_response(
"diff",
serde_json::json!({
"elapsed_s": elapsed,
"actors_before": before.actor_details.len(),
"actors_after": after.actor_details.len(),
"delta_messages": delta_msgs,
"msg_per_sec": delta_msgs as f64 / elapsed,
"delta_local_sends": local_after.saturating_sub(local_before),
"delta_cross_sends": cross_after.saturating_sub(cross_before),
"mailbox_before": mailbox_before,
"mailbox_after": mailbox_after,
"per_worker": per_worker,
}),
)
}
// ── Phase breakdown helper ──────────────────────────────────────────────
fn compute_phase_breakdown(
timings: &[swactor::stats::TickTiming],
) -> serde_json::Value {
if timings.is_empty() {
return serde_json::json!({
"ticks": 0,
"active_pct": 0.0,
"avg_tick_us": 0.0,
"phases_us": [0, 0, 0, 0, 0, 0],
"phases_pct": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
});
}
let n = timings.len();
let active = timings.iter().filter(|t| t.did_work).count();
let active_pct = (active as f64 / n as f64) * 100.0;
let mut phase_sums = [0u64; 6];
for t in timings {
for (i, &us) in t.phase_us.iter().enumerate() {
phase_sums[i] += us;
}
}
let total_us: u64 = phase_sums.iter().sum();
let avg_tick_us = total_us as f64 / n as f64;
let phases_pct: Vec<f64> = if total_us == 0 {
vec![0.0; 6]
} else {
phase_sums
.iter()
.map(|&s| (s as f64 / total_us as f64) * 100.0)
.collect()
};
serde_json::json!({
"ticks": n,
"active_pct": active_pct,
"avg_tick_us": avg_tick_us,
"phases_us": phase_sums,
"phases_pct": phases_pct,
})
}

View file

@ -3,6 +3,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use crossbeam_queue::ArrayQueue;
use serde::{Deserialize, Serialize};
use tracing::field::{Field, Visit};
use tracing::span;
@ -27,18 +28,18 @@ pub struct EventStore {
events: Mutex<VecDeque<DashboardEvent>>,
capacity: usize,
next_seq: AtomicU64,
/// When recording is enabled, all events are also appended here (unbounded).
full_log: Option<Mutex<Vec<DashboardEvent>>>,
/// When recording is enabled, events are kept in a lock-free bounded ring buffer.
full_log: Option<ArrayQueue<DashboardEvent>>,
}
impl EventStore {
pub fn new(capacity: usize, record: bool) -> Self {
pub fn new(capacity: usize, record: bool, record_event_capacity: usize) -> Self {
Self {
events: Mutex::new(VecDeque::with_capacity(capacity)),
capacity,
next_seq: AtomicU64::new(0),
full_log: if record {
Some(Mutex::new(Vec::new()))
Some(ArrayQueue::new(record_event_capacity.max(1)))
} else {
None
},
@ -48,7 +49,7 @@ impl EventStore {
pub fn push(&self, mut event: DashboardEvent) {
event.seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
if let Some(ref log) = self.full_log {
log.lock().unwrap().push(event.clone());
let _ = log.force_push(event.clone());
}
let mut events = self.events.lock().unwrap();
if events.len() >= self.capacity {
@ -82,11 +83,16 @@ impl EventStore {
(batch, new_cursor)
}
/// Returns a clone of the full event log. Only available when recording is enabled.
/// Drains the full recording log. Only available when recording is enabled.
/// This is destructive — events are consumed. Intended for `save_trace()`.
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
self.full_log
.as_ref()
.map(|log| log.lock().unwrap().clone())
self.full_log.as_ref().map(|log| {
let mut out = Vec::new();
while let Some(ev) = log.pop() {
out.push(ev);
}
out
})
}
}

View file

@ -1,19 +1,28 @@
pub mod collector;
pub mod layer;
pub mod trace;
mod actors_html;
mod dashboard_html;
mod server;
#[cfg(feature = "tui")]
pub mod tui;
pub mod investigate;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use crossbeam_queue::ArrayQueue;
use swactor::config::RuntimeConfig;
use swactor::runtime::{Runtime, RuntimeHandle};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use crate::collector::StatsCollector;
use crate::layer::{now_ms, DashboardLayer, EventStore};
use crate::trace::{RuntimeTrace, TimestampedStats};
@ -22,9 +31,13 @@ use crate::trace::{RuntimeTrace, TimestampedStats};
pub struct DashboardConfig {
pub port: u16,
pub event_capacity: usize,
/// Enable trace recording for `save_trace()`. When true, all events
/// are kept in an unbounded log and stats are periodically sampled.
/// Enable trace recording for `save_trace()`. When true, events and stats
/// are kept in lock-free bounded ring buffers and stats are periodically sampled.
pub record: bool,
/// Maximum events retained in the recording log. Only used when `record = true`.
pub record_event_capacity: usize,
/// Maximum stats snapshots retained in the timeline. Only used when `record = true`.
pub record_stats_capacity: usize,
}
impl Default for DashboardConfig {
@ -33,6 +46,8 @@ impl Default for DashboardConfig {
port: 9090,
event_capacity: 10_000,
record: false,
record_event_capacity: 100_000,
record_stats_capacity: 18_000,
}
}
}
@ -58,8 +73,9 @@ impl Default for ReplayConfig {
pub struct DashboardHandle {
store: Arc<EventStore>,
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
stats_timeline: Arc<Mutex<Vec<TimestampedStats>>>,
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
recording: bool,
}
@ -76,9 +92,10 @@ impl DashboardHandle {
DashboardLayer::new(Arc::clone(&self.store))
}
/// Attach a runtime to the dashboard, enabling stats polling.
pub fn set_runtime(&self, runtime: Arc<Runtime>) {
/// Attach a runtime and its stats collector, enabling stats polling.
pub fn set_runtime(&self, runtime: Arc<Runtime>, collector: Arc<StatsCollector>) {
*self.runtime.lock().unwrap() = Some(runtime);
*self.collector.lock().unwrap() = Some(collector);
}
/// Whether trace recording is enabled.
@ -94,6 +111,7 @@ impl DashboardHandle {
/// Save the recorded trace to a JSON file.
///
/// Only works when `DashboardConfig::record` was set to `true`.
/// This drains the recording buffers — each call consumes the buffered data.
pub fn save_trace(&self, path: &str) -> io::Result<()> {
let events = self.store.all_events().ok_or_else(|| {
io::Error::new(
@ -101,7 +119,10 @@ impl DashboardHandle {
"recording not enabled (set DashboardConfig::record = true)",
)
})?;
let stats_timeline = self.stats_timeline.lock().unwrap().clone();
let mut stats_timeline = Vec::new();
while let Some(ts) = self.stats_timeline.pop() {
stats_timeline.push(ts);
}
let trace = RuntimeTrace {
events,
stats_timeline,
@ -117,14 +138,20 @@ impl DashboardHandle {
/// The dashboard starts serving immediately. Call `install_tracing()` to set up
/// the global subscriber, and `set_runtime()` to enable stats polling.
pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let store = Arc::new(EventStore::new(config.event_capacity, config.record));
let store = Arc::new(EventStore::new(
config.event_capacity,
config.record,
config.record_event_capacity,
));
let runtime: Arc<Mutex<Option<Arc<Runtime>>>> = Arc::new(Mutex::new(None));
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
let shutdown = Arc::new(AtomicBool::new(false));
let stats_timeline = Arc::new(Mutex::new(Vec::new()));
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
server::spawn_http_server(
Arc::clone(&store),
Arc::clone(&runtime),
Arc::clone(&collector),
Arc::clone(&shutdown),
config.port,
);
@ -132,6 +159,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
// Start stats recorder thread when recording is enabled
if config.record {
let rt_ref = Arc::clone(&runtime);
let col_ref = Arc::clone(&collector);
let timeline = Arc::clone(&stats_timeline);
let stop = Arc::clone(&shutdown);
thread::spawn(move || {
@ -141,12 +169,15 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
}
let maybe_rt = rt_ref.lock().unwrap().clone();
if let Some(rt) = maybe_rt {
let stats = rt.stats();
let mut stats = rt.stats();
if let Some(col) = col_ref.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
let ts = TimestampedStats {
timestamp_ms: now_ms(),
stats,
};
timeline.lock().unwrap().push(ts);
let _ = timeline.force_push(ts);
}
thread::sleep(Duration::from_millis(200));
}
@ -158,6 +189,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
DashboardHandle {
store,
runtime,
collector,
shutdown,
stats_timeline,
recording: config.record,
@ -174,9 +206,13 @@ pub fn run_with_dashboard(
let dash = start_dashboard(dash_config);
dash.install_tracing();
let rt = Runtime::new(rt_config);
let num_workers = if rt_config.num_threads < 2 { 1 } else { rt_config.num_threads };
let collector = StatsCollector::new(num_workers);
let mut rt = Runtime::new(rt_config);
rt.set_stats_hook(collector.clone());
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(Arc::clone(&handle.runtime));
dash.set_runtime(Arc::clone(&handle.runtime), collector);
(handle, dash)
}

View file

@ -6,6 +6,8 @@ use std::time::{Duration, Instant};
use swactor::runtime::Runtime;
use crate::actors_html::ACTORS_HTML;
use crate::collector::StatsCollector;
use crate::dashboard_html::DASHBOARD_HTML;
use crate::layer::EventStore;
use crate::trace::RuntimeTrace;
@ -87,8 +89,8 @@ fn make_sse_response(
)
}
fn respond_html(request: tiny_http::Request, mode: &str) {
let html = DASHBOARD_HTML.replace("__DASHBOARD_MODE__", mode);
fn respond_html(request: tiny_http::Request, html_template: &str, mode: &str) {
let html = html_template.replace("__DASHBOARD_MODE__", mode);
let response = tiny_http::Response::from_string(html).with_header(
"Content-Type: text/html; charset=utf-8"
.parse::<tiny_http::Header>()
@ -108,6 +110,7 @@ fn respond_404(request: tiny_http::Request) {
pub(crate) fn spawn_http_server(
store: Arc<EventStore>,
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
port: u16,
) {
@ -119,6 +122,7 @@ pub(crate) fn spawn_http_server(
let server = Arc::clone(&server);
let store = Arc::clone(&store);
let runtime = Arc::clone(&runtime);
let collector = Arc::clone(&collector);
let shutdown = Arc::clone(&shutdown);
thread::spawn(move || {
loop {
@ -129,17 +133,23 @@ pub(crate) fn spawn_http_server(
let url = request.url().to_string();
match url.as_str() {
"/" => respond_html(request, "live"),
"/" => respond_html(request, DASHBOARD_HTML, "live"),
"/actors" => respond_html(request, ACTORS_HTML, "live"),
"/events" => {
handle_live_sse(
request,
Arc::clone(&store),
Arc::clone(&runtime),
Arc::clone(&collector),
Arc::clone(&shutdown),
);
}
"/api/stats" => {
handle_stats_api(request, Arc::clone(&runtime));
handle_stats_api(
request,
Arc::clone(&runtime),
Arc::clone(&collector),
);
}
_ => respond_404(request),
}
@ -152,6 +162,7 @@ fn handle_live_sse(
request: tiny_http::Request,
store: Arc<EventStore>,
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
) {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
@ -166,7 +177,10 @@ fn handle_live_sse(
{
let maybe_rt = runtime.lock().unwrap().clone();
if let Some(rt) = maybe_rt {
let stats = rt.stats();
let mut stats = rt.stats();
if let Some(col) = collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
let json = serde_json::to_string(&stats).unwrap();
if tx.send(format_sse("stats", &json)).is_err() {
return;
@ -198,10 +212,20 @@ fn handle_live_sse(
let _ = request.respond(response);
}
fn handle_stats_api(request: tiny_http::Request, runtime: Arc<Mutex<Option<Arc<Runtime>>>>) {
fn handle_stats_api(
request: tiny_http::Request,
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
) {
let maybe_rt = runtime.lock().unwrap().clone();
let json = match maybe_rt {
Some(rt) => serde_json::to_string(&rt.stats()).unwrap(),
Some(rt) => {
let mut stats = rt.stats();
if let Some(col) = collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
serde_json::to_string(&stats).unwrap()
}
None => "{}".to_string(),
};
let response = tiny_http::Response::from_string(json).with_header(
@ -232,7 +256,8 @@ pub(crate) fn spawn_replay_server(trace: Arc<RuntimeTrace>, port: u16, speed: f6
let url = request.url().to_string();
match url.as_str() {
"/" => respond_html(request, "replay"),
"/" => respond_html(request, DASHBOARD_HTML, "replay"),
"/actors" => respond_html(request, ACTORS_HTML, "replay"),
"/events" => {
handle_replay_sse(request, Arc::clone(&trace), speed);
}

View file

@ -0,0 +1,357 @@
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use swactor::actor::ActorAddress;
use swactor::stats::{RuntimeStats, TickTiming};
/// Per-worker data prepared for rendering.
pub struct WorkerView {
pub id: usize,
pub num_actors: usize,
pub mailbox_depth: usize,
pub messages_processed: u64,
pub msg_rate: f64,
pub load_pct: f64,
/// Fraction of bar for each phase group: [processing, delivery, spawns, overhead]
pub phase_fractions: [f64; 4],
pub panics: u64,
}
/// One row in the actor table.
pub struct ActorRow {
pub address: ActorAddress,
pub worker_id: usize,
pub mailbox_depth: usize,
pub last_msg_type: Option<String>,
pub messages_processed: u64,
pub poisoned: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
Address,
Worker,
Mailbox,
LastMsg,
MsgCount,
}
impl SortColumn {
pub fn next(self) -> Self {
match self {
SortColumn::Address => SortColumn::Worker,
SortColumn::Worker => SortColumn::Mailbox,
SortColumn::Mailbox => SortColumn::MsgCount,
SortColumn::MsgCount => SortColumn::LastMsg,
SortColumn::LastMsg => SortColumn::Address,
}
}
pub fn label(self) -> &'static str {
match self {
SortColumn::Address => "ADDRESS",
SortColumn::Worker => "WORKER",
SortColumn::Mailbox => "MAILBOX",
SortColumn::MsgCount => "MSGS",
SortColumn::LastMsg => "LAST MSG",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Overview,
WorkerDetail,
}
pub struct App {
pub workers: Vec<WorkerView>,
pub actor_rows: Vec<ActorRow>,
pub sort_column: SortColumn,
pub sort_desc: bool,
pub selected: usize,
pub should_quit: bool,
pub total_actors: usize,
pub total_messages: u64,
pub total_mailbox: usize,
pub total_panics: u64,
pub num_workers: usize,
pub view_mode: ViewMode,
pub focused_worker: usize,
prev_messages: Vec<u64>,
prev_time: Instant,
/// Rolling msg rates (smoothed)
msg_rates: Vec<f64>,
}
impl App {
pub fn new() -> Self {
Self {
workers: Vec::new(),
actor_rows: Vec::new(),
sort_column: SortColumn::Mailbox,
sort_desc: true,
selected: 0,
should_quit: false,
total_actors: 0,
total_messages: 0,
total_mailbox: 0,
total_panics: 0,
num_workers: 0,
view_mode: ViewMode::Overview,
focused_worker: 0,
prev_messages: Vec::new(),
prev_time: Instant::now(),
msg_rates: Vec::new(),
}
}
/// Actor rows filtered to the focused worker (for worker detail view).
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
self.actor_rows
.iter()
.filter(|r| r.worker_id == self.focused_worker)
.collect()
}
pub fn update(&mut self, stats: RuntimeStats) {
let now = Instant::now();
let dt = now.duration_since(self.prev_time).as_secs_f64();
self.prev_time = now;
self.num_workers = stats.num_workers;
// Ensure rate vectors are sized
if self.prev_messages.len() != stats.workers.len() {
self.prev_messages = stats.workers.iter().map(|w| w.messages_processed).collect();
self.msg_rates = vec![0.0; stats.workers.len()];
}
// Compute per-worker views
self.workers.clear();
for (i, w) in stats.workers.iter().enumerate() {
// Message rate
let rate = if dt > 0.0 {
let delta = w.messages_processed.saturating_sub(self.prev_messages[i]);
delta as f64 / dt
} else {
0.0
};
// Smooth the rate (exponential moving average)
let smoothed = if self.msg_rates[i] == 0.0 {
rate
} else {
self.msg_rates[i] * 0.6 + rate * 0.4
};
self.msg_rates[i] = smoothed;
self.prev_messages[i] = w.messages_processed;
// Load % and phase fractions from tick timings
let timings = stats.tick_timings.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
let (load_pct, phase_fractions) = compute_load_and_phases(timings);
self.workers.push(WorkerView {
id: w.id,
num_actors: w.num_actors,
mailbox_depth: w.mailbox_depth,
messages_processed: w.messages_processed,
msg_rate: smoothed,
load_pct,
phase_fractions,
panics: w.panics,
});
}
// Aggregate stats
self.total_actors = stats.actor_details.len();
self.total_messages = stats.workers.iter().map(|w| w.messages_processed).sum();
self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum();
self.total_panics = stats.workers.iter().map(|w| w.panics).sum();
// Build actor table
self.actor_rows.clear();
for a in &stats.actor_details {
self.actor_rows.push(ActorRow {
address: a.address,
worker_id: a.worker_id,
mailbox_depth: a.mailbox_depth,
last_msg_type: a.last_msg_type.clone(),
messages_processed: a.messages_processed,
poisoned: a.poisoned,
});
}
self.sort_actors();
// Clamp selection
if !self.actor_rows.is_empty() {
self.selected = self.selected.min(self.actor_rows.len() - 1);
} else {
self.selected = 0;
}
}
fn sort_actors(&mut self) {
let desc = self.sort_desc;
match self.sort_column {
SortColumn::Address => {
self.actor_rows.sort_by(|a, b| {
let cmp = a.address.0.cmp(&b.address.0);
if desc { cmp.reverse() } else { cmp }
});
}
SortColumn::Worker => {
self.actor_rows.sort_by(|a, b| {
let cmp = a.worker_id.cmp(&b.worker_id);
if desc { cmp.reverse() } else { cmp }
});
}
SortColumn::Mailbox => {
self.actor_rows.sort_by(|a, b| {
let cmp = a.mailbox_depth.cmp(&b.mailbox_depth);
if desc { cmp.reverse() } else { cmp }
});
}
SortColumn::MsgCount => {
self.actor_rows.sort_by(|a, b| {
let cmp = a.messages_processed.cmp(&b.messages_processed);
if desc { cmp.reverse() } else { cmp }
});
}
SortColumn::LastMsg => {
self.actor_rows.sort_by(|a, b| {
let cmp = a.last_msg_type.cmp(&b.last_msg_type);
if desc { cmp.reverse() } else { cmp }
});
}
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
// Global keys
match key.code {
KeyCode::Char('q') => { self.should_quit = true; return; }
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true; return;
}
KeyCode::Char('s') => {
self.sort_column = self.sort_column.next();
self.sort_actors();
return;
}
KeyCode::Char('r') => {
self.sort_desc = !self.sort_desc;
self.sort_actors();
return;
}
KeyCode::Tab => {
self.view_mode = match self.view_mode {
ViewMode::Overview => ViewMode::WorkerDetail,
ViewMode::WorkerDetail => ViewMode::Overview,
};
return;
}
_ => {}
}
match self.view_mode {
ViewMode::Overview => self.handle_key_overview(key),
ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
}
}
fn handle_key_overview(&mut self, key: KeyEvent) {
let max = if self.actor_rows.is_empty() { 0 } else { self.actor_rows.len() - 1 };
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected = (self.selected + 1).min(max);
}
KeyCode::PageUp => {
self.selected = self.selected.saturating_sub(20);
}
KeyCode::PageDown => {
self.selected = (self.selected + 20).min(max);
}
KeyCode::Home => { self.selected = 0; }
KeyCode::End => { self.selected = max; }
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
// Enter worker detail for the selected actor's worker
if let Some(row) = self.actor_rows.get(self.selected) {
self.focused_worker = row.worker_id;
}
self.view_mode = ViewMode::WorkerDetail;
}
_ => {}
}
}
fn handle_key_worker_detail(&mut self, key: KeyEvent) {
let max_w = if self.workers.is_empty() { 0 } else { self.workers.len() - 1 };
match key.code {
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
self.view_mode = ViewMode::Overview;
}
KeyCode::Up | KeyCode::Char('k') => {
self.focused_worker = self.focused_worker.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.focused_worker = (self.focused_worker + 1).min(max_w);
}
KeyCode::PageUp => {
self.focused_worker = self.focused_worker.saturating_sub(20);
}
KeyCode::PageDown => {
self.focused_worker = (self.focused_worker + 20).min(max_w);
}
KeyCode::Home => { self.focused_worker = 0; }
KeyCode::End => { self.focused_worker = max_w; }
_ => {}
}
}
pub fn visible_table_height(&self) -> usize {
// Will be set by the UI based on actual chunk size
20
}
}
/// Compute load percentage and phase fractions from tick timings.
///
/// Returns (load_pct, [processing, delivery, spawns, overhead]).
fn compute_load_and_phases(timings: &[TickTiming]) -> (f64, [f64; 4]) {
if timings.is_empty() {
return (0.0, [0.0; 4]);
}
let active = timings.iter().filter(|t| t.did_work).count();
let load_pct = (active as f64 / timings.len() as f64) * 100.0;
// Sum phase microseconds across all ticks
let mut phase_sums = [0u64; 6];
for t in timings {
for (i, &us) in t.phase_us.iter().enumerate() {
phase_sums[i] += us;
}
}
let total_us: u64 = phase_sums.iter().sum();
if total_us == 0 {
return (load_pct, [0.25, 0.25, 0.25, 0.25]);
}
let total = total_us as f64;
// Group phases:
// processing = phase 2 (tick_all)
// delivery = phase 1 (transfer drain) + phase 4 (pending local)
// spawns = phase 0 (spawn drain) + phase 3 (spawn drain again)
// overhead = phase 5 (stats)
let processing = phase_sums[2] as f64 / total;
let delivery = (phase_sums[1] + phase_sums[4]) as f64 / total;
let spawns = (phase_sums[0] + phase_sums[3]) as f64 / total;
let overhead = phase_sums[5] as f64 / total;
(load_pct, [processing, delivery, spawns, overhead])
}

View file

@ -0,0 +1,86 @@
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use crossterm::event::{self, Event, KeyEvent, KeyEventKind};
use swactor::stats::RuntimeStats;
use super::types::RuntimeEndpoint;
pub enum AppEvent {
Key(KeyEvent),
Tick,
StatsUpdate {
source: RuntimeEndpoint,
stats: Box<RuntimeStats>,
},
}
pub struct EventLoop {
rx: mpsc::Receiver<AppEvent>,
}
impl EventLoop {
pub fn new(tick_ms: u64) -> Self {
let (tx, rx) = mpsc::channel();
// Input thread
let tx_input = tx.clone();
thread::spawn(move || loop {
// Poll with a timeout so the thread can eventually notice if the
// channel is dropped (tx_input.send will fail).
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
// Only handle key press events, not release/repeat
if key.kind == KeyEventKind::Press {
if tx_input.send(AppEvent::Key(key)).is_err() {
return;
}
}
}
}
});
// Tick thread
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(tick_ms));
if tx.send(AppEvent::Tick).is_err() {
return;
}
});
Self { rx }
}
/// Create an event loop and return a sender for external producers (e.g. SSE client).
pub fn new_with_sender(tick_ms: u64) -> (Self, mpsc::Sender<AppEvent>) {
let (tx, rx) = mpsc::channel();
let tx_input = tx.clone();
thread::spawn(move || loop {
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
if key.kind == KeyEventKind::Press {
if tx_input.send(AppEvent::Key(key)).is_err() {
return;
}
}
}
}
});
let tx_tick = tx.clone();
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(tick_ms));
if tx_tick.send(AppEvent::Tick).is_err() {
return;
}
});
(Self { rx }, tx)
}
pub fn next(&self) -> Result<AppEvent, mpsc::RecvError> {
self.rx.recv()
}
}

View file

@ -0,0 +1,191 @@
pub mod app;
mod event;
pub mod sse_client;
pub mod types;
mod ui;
use std::io;
use std::sync::Arc;
use crossterm::execute;
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::widgets::TableState;
use swactor::runtime::Runtime;
use crate::collector::StatsCollector;
use self::app::App;
use self::event::{AppEvent, EventLoop};
use self::types::RuntimeEndpoint;
/// Configuration for the TUI dashboard.
#[derive(Debug, Clone)]
pub struct TuiConfig {
/// How often to poll `Runtime::stats()`, in milliseconds.
pub poll_interval_ms: u64,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
poll_interval_ms: 200,
}
}
}
/// Run the TUI dashboard. Blocks the calling thread until the user quits.
///
/// Takes ownership of the terminal (alternate screen + raw mode) and restores
/// it on exit, including on panic.
pub fn start_tui(
runtime: Arc<Runtime>,
collector: Arc<StatsCollector>,
config: TuiConfig,
) -> io::Result<()> {
// Set up terminal
crossterm::terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
// Panic hook to restore terminal
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(info);
}));
// Run main loop
let result = run_loop(&mut terminal, runtime, collector, config);
// Restore terminal
crossterm::terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
// Restore default panic hook
let _ = std::panic::take_hook();
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
runtime: Arc<Runtime>,
collector: Arc<StatsCollector>,
config: TuiConfig,
) -> io::Result<()> {
let mut app = App::new();
let mut table_state = TableState::default();
let events = EventLoop::new(config.poll_interval_ms);
// Initial stats poll
let mut stats = runtime.stats();
collector.enrich(&mut stats);
app.update(stats);
loop {
terminal.draw(|f| ui::draw(f, &app, &mut table_state))?;
match events.next() {
Ok(AppEvent::Tick) => {
let mut stats = runtime.stats();
collector.enrich(&mut stats);
app.update(stats);
}
Ok(AppEvent::Key(key)) => {
app.handle_key(key);
}
Ok(AppEvent::StatsUpdate { stats, .. }) => {
app.update(*stats);
}
Err(_) => {
// Channel closed, exit
break;
}
}
if app.should_quit {
break;
}
}
Ok(())
}
/// Run the TUI dashboard connected to a remote runtime's SSE endpoint.
///
/// Blocks the calling thread until the user quits ('q' or Esc).
pub fn start_tui_remote(endpoint: RuntimeEndpoint, config: TuiConfig) -> io::Result<()> {
// Set up terminal
crossterm::terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
// Panic hook to restore terminal
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(info);
}));
// Run main loop
let result = run_loop_remote(&mut terminal, endpoint, config);
// Restore terminal
crossterm::terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
let _ = std::panic::take_hook();
result
}
fn run_loop_remote(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
endpoint: RuntimeEndpoint,
config: TuiConfig,
) -> io::Result<()> {
let mut app = App::new();
let mut table_state = TableState::default();
let (events, tx) = EventLoop::new_with_sender(config.poll_interval_ms);
// Spawn the SSE reader thread — pushes StatsUpdate events into the same channel
let _sse_handle = sse_client::spawn_sse_reader(endpoint, tx)?;
loop {
terminal.draw(|f| ui::draw(f, &app, &mut table_state))?;
match events.next() {
Ok(AppEvent::StatsUpdate { stats, .. }) => {
app.update(*stats);
}
Ok(AppEvent::Tick) => {
// Just redraw — data comes from SSE, not local polling
}
Ok(AppEvent::Key(key)) => {
app.handle_key(key);
}
Err(_) => {
break;
}
}
if app.should_quit {
break;
}
}
Ok(())
}

View file

@ -0,0 +1,238 @@
use std::fs::OpenOptions;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::sync::mpsc;
use std::thread::{self, JoinHandle};
use swactor::stats::RuntimeStats;
use super::event::AppEvent;
use super::types::RuntimeEndpoint;
/// Debug log file — visible after TUI exits (stderr is swallowed by raw mode).
const DEBUG_LOG: &str = "/tmp/swactor_sse_debug.log";
fn debug_log(msg: &str) {
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(DEBUG_LOG) {
let _ = writeln!(f, "[sse] {}", msg);
}
}
/// Spawn a thread that connects to a runtime's `/events` SSE endpoint
/// and sends parsed `RuntimeStats` through the provided channel.
///
/// Only handles `http://` endpoints. Returns an error if the endpoint
/// cannot be parsed or the initial TCP connection fails.
pub fn spawn_sse_reader(
endpoint: RuntimeEndpoint,
tx: mpsc::Sender<AppEvent>,
) -> io::Result<JoinHandle<()>> {
let (host, port) = endpoint.parse_http().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unsupported endpoint (expected http://host:port): {}",
endpoint.endpoint
),
)
})?;
let addr = format!("{}:{}", host, port);
let stream = TcpStream::connect(&addr)?;
let host_header = addr.clone();
Ok(thread::spawn(move || {
debug_log(&format!("connecting to {}", endpoint));
if let Err(e) = sse_read_loop(stream, &host_header, &endpoint, &tx) {
debug_log(&format!("error for {}: {}", endpoint, e));
eprintln!("SSE reader error for {}: {}", endpoint, e);
}
}))
}
fn sse_read_loop(
mut stream: TcpStream,
host: &str,
endpoint: &RuntimeEndpoint,
tx: &mpsc::Sender<AppEvent>,
) -> io::Result<()> {
// HTTP/1.1 so tiny_http will use chunked transfer encoding for streaming.
// (HTTP/1.0 causes tiny_http to buffer the entire response.)
let request = format!(
"GET /events HTTP/1.1\r\nHost: {}\r\nAccept: text/event-stream\r\n\r\n",
host
);
stream.write_all(request.as_bytes())?;
let mut reader = BufReader::new(stream);
// Parse response headers — detect chunked encoding
let mut chunked = false;
let mut header_line = String::new();
loop {
header_line.clear();
let n = reader.read_line(&mut header_line)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"connection closed during headers",
));
}
let trimmed = header_line.trim();
if trimmed.is_empty() {
break;
}
debug_log(&format!("header: {}", trimmed));
if trimmed.to_ascii_lowercase().starts_with("transfer-encoding:")
&& trimmed.to_ascii_lowercase().contains("chunked")
{
chunked = true;
}
}
debug_log(&format!("headers done, chunked={}", chunked));
if chunked {
let dechunked = DechunkedReader::new(reader);
let line_reader = BufReader::new(dechunked);
parse_sse_events(line_reader, endpoint, tx)
} else {
parse_sse_events(reader, endpoint, tx)
}
}
fn parse_sse_events<R: BufRead>(
mut reader: R,
endpoint: &RuntimeEndpoint,
tx: &mpsc::Sender<AppEvent>,
) -> io::Result<()> {
let mut current_event = String::new();
let mut data_buf = String::new();
let mut line = String::new();
let mut event_count: u64 = 0;
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
debug_log("EOF");
break;
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
// Empty line = end of SSE event, dispatch
if current_event == "stats" && !data_buf.is_empty() {
match serde_json::from_str::<RuntimeStats>(&data_buf) {
Ok(stats) => {
event_count += 1;
if event_count <= 3 || event_count % 100 == 0 {
debug_log(&format!("stats event #{}", event_count));
}
let event = AppEvent::StatsUpdate {
source: endpoint.clone(),
stats: Box::new(stats),
};
if tx.send(event).is_err() {
debug_log("channel closed, exiting");
return Ok(());
}
}
Err(e) => {
debug_log(&format!(
"JSON parse error: {} data={}",
e,
&data_buf[..data_buf.len().min(200)]
));
}
}
} else if current_event == "done" {
debug_log("received done event");
return Ok(());
}
current_event.clear();
data_buf.clear();
} else if let Some(event_type) = trimmed.strip_prefix("event: ") {
current_event = event_type.to_string();
} else if let Some(data) = trimmed.strip_prefix("data: ") {
data_buf.push_str(data);
}
}
Ok(())
}
// ── Chunked transfer encoding decoder ────────────────────────────────
/// Transparently decodes HTTP chunked transfer encoding.
///
/// Wraps a `BufReader<R>` (which already consumed the response headers)
/// and exposes a plain `Read` that yields the dechunked body bytes.
struct DechunkedReader<R> {
inner: BufReader<R>,
remaining: usize,
finished: bool,
}
impl<R: Read> DechunkedReader<R> {
fn new(inner: BufReader<R>) -> Self {
Self {
inner,
remaining: 0,
finished: false,
}
}
}
impl<R: Read> Read for DechunkedReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.finished {
return Ok(0);
}
// Start of a new chunk — read the hex size line
if self.remaining == 0 {
let mut size_line = String::new();
let n = self.inner.read_line(&mut size_line)?;
if n == 0 {
self.finished = true;
return Ok(0);
}
let hex = size_line.trim();
if hex.is_empty() {
self.finished = true;
return Ok(0);
}
let size = usize::from_str_radix(hex, 16).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid chunk size: {:?}", hex),
)
})?;
if size == 0 {
// Terminal chunk
self.finished = true;
return Ok(0);
}
self.remaining = size;
}
// Read up to `remaining` bytes from the current chunk
let to_read = buf.len().min(self.remaining);
let n = self.inner.read(&mut buf[..to_read])?;
if n == 0 {
self.finished = true;
return Ok(0);
}
self.remaining -= n;
// After consuming all bytes in a chunk, skip the trailing \r\n
if self.remaining == 0 {
let mut crlf = [0u8; 2];
self.inner.read_exact(&mut crlf)?;
}
Ok(n)
}
}

View file

@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
use swactor::runtime::RuntimeAddress;
/// A runtime identity paired with a network endpoint for dashboard connections.
///
/// The endpoint string is protocol-agnostic: `"http://host:port"`, `"unix:///path"`,
/// `"custom://..."`, etc. The transport layer interprets the scheme.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RuntimeEndpoint {
pub id: RuntimeAddress,
pub endpoint: String,
}
impl RuntimeEndpoint {
/// Create from an HTTP URL. Generates a random `RuntimeAddress`.
///
/// In the future the runtime provides its own ID; for now the dashboard assigns one.
pub fn from_url(url: &str) -> Self {
Self {
id: RuntimeAddress::new_random(),
endpoint: url.to_string(),
}
}
/// Parse host and port from an `http://host:port` endpoint string.
pub fn parse_http(&self) -> Option<(&str, u16)> {
let s = self.endpoint.strip_prefix("http://")?;
let (host, port_str) = s.rsplit_once(':')?;
let port = port_str.parse().ok()?;
Some((host, port))
}
}
impl std::fmt::Display for RuntimeEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.id, self.endpoint)
}
}

View file

@ -0,0 +1,473 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
use super::app::{App, SortColumn, ViewMode};
/// Render the full dashboard into the given frame.
pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
match app.view_mode {
ViewMode::Overview => draw_overview(f, app, table_state),
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
}
}
// ─── Overview ────────────────────────────────────────────────────────────────
fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) {
let num_workers = app.workers.len().max(1);
let chunks = Layout::vertical([
Constraint::Length(num_workers as u16),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(f.area());
draw_worker_bars(f, app, chunks[0]);
draw_summary(f, app, chunks[1]);
draw_actor_table(f, app, table_state, chunks[2]);
}
/// Render htop-style worker bars.
fn draw_worker_bars(f: &mut Frame, app: &App, area: Rect) {
let lines: Vec<Line> = app
.workers
.iter()
.map(|w| build_worker_line(w, area.width as usize))
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> {
let id_str = format!("{:>3}", w.id);
let suffix = format!(
" {} actors {} m/s",
w.num_actors,
format_rate(w.msg_rate),
);
let pct_str = format!("{:>5.1}%", w.load_pct);
let overhead = id_str.len() + 1 + 1 + pct_str.len() + suffix.len();
let bar_width = if total_width > overhead {
total_width - overhead
} else {
10
};
let filled = ((w.load_pct / 100.0) * bar_width as f64).round() as usize;
let filled = filled.min(bar_width);
let phase_colors = [Color::Green, Color::Blue, Color::Cyan, Color::Red];
let mut phase_cells = [0usize; 4];
if filled > 0 {
let mut assigned = 0usize;
for i in 0..4 {
phase_cells[i] = (w.phase_fractions[i] * filled as f64).round() as usize;
assigned += phase_cells[i];
}
if assigned != filled {
let diff = filled as isize - assigned as isize;
let max_idx = phase_cells
.iter()
.enumerate()
.max_by_key(|(_, v)| *v)
.map(|(i, _)| i)
.unwrap_or(0);
phase_cells[max_idx] = (phase_cells[max_idx] as isize + diff).max(0) as usize;
}
}
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::raw(id_str));
spans.push(Span::raw("["));
for (i, &color) in phase_colors.iter().enumerate() {
let count = phase_cells[i];
if count > 0 {
spans.push(Span::styled(
"|".repeat(count),
Style::default().fg(color),
));
}
}
let filled_total: usize = phase_cells.iter().sum();
let empty = bar_width.saturating_sub(filled_total);
if empty >= pct_str.len() {
let padding = empty - pct_str.len();
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(
pct_str,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
pct_str,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::raw("]"));
spans.push(Span::styled(suffix, Style::default().fg(Color::DarkGray)));
Line::from(spans)
}
/// Render the summary stats line.
fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
let panics_style = if app.total_panics > 0 {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let line = Line::from(vec![
Span::styled(" Workers: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.num_workers),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(" Actors: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.total_actors),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
Span::styled(" Msgs: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(app.total_messages),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", app.total_mailbox),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", app.total_panics), panics_style),
]);
f.render_widget(Paragraph::new(line), area);
}
/// Render the actor table with scrolling and selection.
fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
let columns = [
SortColumn::Address,
SortColumn::Worker,
SortColumn::Mailbox,
SortColumn::MsgCount,
SortColumn::LastMsg,
];
let header_cells = columns.iter().map(|&col| {
let mut label = col.label().to_string();
if col == app.sort_column {
label.push_str(sort_arrow);
}
Cell::from(label).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = app
.actor_rows
.iter()
.map(|a| actor_row_cells(a))
.collect();
table_state.select(Some(app.selected));
let help_text =
" q: quit \u{2191}\u{2193}: scroll s: sort column r: reverse Tab: worker view Enter: drill in";
let table = Table::new(
rows,
[
Constraint::Min(20),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(24),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(
" Actors (sorted by {}{}) ",
app.sort_column.label(),
sort_arrow
))
.title_bottom(Line::from(help_text).centered()),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(table, area, table_state);
}
fn actor_row_cells(a: &super::app::ActorRow) -> Row<'static> {
let addr = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
} else {
addr
};
let addr_style = if a.poisoned {
Style::default().fg(Color::Red)
} else {
Style::default()
};
let msg_short = short_type_name(a.last_msg_type.as_deref());
let msg_style = if a.poisoned {
Style::default().fg(Color::Red)
} else if a.last_msg_type.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Row::new(vec![
Cell::from(addr_short).style(addr_style),
Cell::from(format!("{}", a.worker_id)),
Cell::from(format!("{}", a.mailbox_depth)),
Cell::from(format!("{}", a.messages_processed)),
Cell::from(msg_short).style(msg_style),
])
}
// ─── Worker Detail View ──────────────────────────────────────────────────────
fn draw_worker_detail(f: &mut Frame, app: &App, table_state: &mut TableState) {
let num_workers = app.workers.len().max(1);
let chunks = Layout::vertical([
Constraint::Length(num_workers as u16),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(f.area());
draw_worker_selector(f, app, chunks[0]);
draw_worker_summary(f, app, chunks[1]);
draw_focused_actor_table(f, app, table_state, chunks[2]);
}
/// Worker list with highlight on the focused worker.
fn draw_worker_selector(f: &mut Frame, app: &App, area: Rect) {
let lines: Vec<Line> = app
.workers
.iter()
.map(|w| {
let mut line = build_worker_line(w, area.width as usize);
if w.id == app.focused_worker {
// Highlight the focused worker row
line = line.style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
}
line
})
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
/// Summary line for the focused worker.
fn draw_worker_summary(f: &mut Frame, app: &App, area: Rect) {
let focused = app.workers.iter().find(|w| w.id == app.focused_worker);
let line = match focused {
Some(w) => {
let panics_style = if w.panics > 0 {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let actor_count = app
.actor_rows
.iter()
.filter(|r| r.worker_id == app.focused_worker)
.count();
Line::from(vec![
Span::styled(" W", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", w.id),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Actors: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor_count),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Msgs: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(w.messages_processed),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({} m/s)", format_rate(w.msg_rate)),
Style::default().fg(Color::DarkGray),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", w.mailbox_depth),
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Panics: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", w.panics), panics_style),
])
}
None => Line::from(Span::styled(
" No workers",
Style::default().fg(Color::DarkGray),
)),
};
f.render_widget(Paragraph::new(line), area);
}
/// Actor table filtered to the focused worker.
fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
let columns = [SortColumn::Address, SortColumn::Mailbox, SortColumn::MsgCount, SortColumn::LastMsg];
let header_cells = columns.iter().map(|&col| {
let mut label = col.label().to_string();
if col == app.sort_column {
label.push_str(sort_arrow);
}
Cell::from(label).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1);
let focused_rows: Vec<&super::app::ActorRow> = app.focused_actor_rows();
let rows: Vec<Row> = focused_rows
.iter()
.map(|a| {
let addr = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
} else {
addr
};
let addr_style = if a.poisoned {
Style::default().fg(Color::Red)
} else {
Style::default()
};
let msg_short = short_type_name(a.last_msg_type.as_deref());
let msg_style = if a.poisoned {
Style::default().fg(Color::Red)
} else if a.last_msg_type.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Row::new(vec![
Cell::from(addr_short).style(addr_style),
Cell::from(format!("{}", a.mailbox_depth)),
Cell::from(format!("{}", a.messages_processed)),
Cell::from(msg_short).style(msg_style),
])
})
.collect();
// Don't select any row in the focused table — it's read-only
table_state.select(None);
let help_text = " Esc/\u{2190}: overview \u{2191}\u{2193}: select worker s: sort r: reverse Tab: overview q: quit";
let table = Table::new(
rows,
[
Constraint::Min(20),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(28),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" W{} Actors ", app.focused_worker))
.title_bottom(Line::from(help_text).centered()),
);
f.render_stateful_widget(table, area, table_state);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn short_type_name(full: Option<&str>) -> String {
match full {
Some(s) => s.rsplit("::").next().unwrap_or(s).to_string(),
None => "\u{2014}".to_string(), // em dash
}
}
fn format_num(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
result.chars().rev().collect()
}
fn format_rate(rate: f64) -> String {
if rate >= 1_000_000.0 {
format!("{:.1}M", rate / 1_000_000.0)
} else if rate >= 1_000.0 {
format!("{:.1}k", rate / 1_000.0)
} else {
format!("{:.0}", rate)
}
}

View file

@ -500,6 +500,8 @@ pub struct PyRuntimeStats {
#[pyo3(get)]
num_workers: usize,
#[pyo3(get)]
uptime_ms: u64,
#[pyo3(get)]
actors: Vec<PyActorInfo>,
#[pyo3(get)]
workers: Vec<PyWorkerInfo>,
@ -556,6 +558,7 @@ fn build_stats(runtime: &Runtime) -> PyRuntimeStats {
PyRuntimeStats {
num_actors: actors.len(),
num_workers: stats.num_workers,
uptime_ms: stats.uptime_ms,
actors,
workers,
}

View file

@ -46,21 +46,21 @@ impl<A: ActorInterface> Actor<A> {
/// Trait for type-erased actors — single-message handler.
///
/// Returns `true` if the message was handled, `false` on type mismatch.
/// Returns `Some(type_name)` if handled, `None` on type mismatch.
pub trait AnyActor: Send {
fn handle_any(&mut self, ctx: &Ctx, msg: Box<dyn Any + Send>) -> bool;
fn handle_any(&mut self, ctx: &Ctx, msg: Box<dyn Any + Send>) -> Option<&'static str>;
}
impl<A> AnyActor for Actor<A>
where
A: ActorInterface,
{
fn handle_any(&mut self, ctx: &Ctx, msg: Box<dyn Any + Send>) -> bool {
fn handle_any(&mut self, ctx: &Ctx, msg: Box<dyn Any + Send>) -> Option<&'static str> {
if let Ok(typed) = msg.downcast::<A::Incoming>() {
self.0.handle(ctx, *typed);
true
Some(std::any::type_name::<A::Incoming>())
} else {
false
None
}
}
}

View file

@ -154,6 +154,7 @@ pub(crate) struct TickContext<'a> {
pub(crate) placement: &'a Placement,
pub(crate) inbox_registry: &'a InboxRegistry,
pub(crate) config: &'a RuntimeConfig,
pub(crate) stats_hook: Option<&'a dyn crate::stats::StatsHook>,
#[cfg(feature = "transport")]
pub(crate) codec_registry: Option<&'a crate::transport::CodecRegistry>,
#[cfg(feature = "transport")]

View file

@ -3,13 +3,14 @@ use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Instant;
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message};
use crate::channel::{Receiver, Sender};
// Re-export config types so existing code using `runtime::RuntimeConfig` still works
pub use crate::config::{BackoffPolicy, RuntimeConfig};
use crate::delivery::{AddressMap, Envelope, InboxRegistry, Placement, TickContext, WorkerId};
use crate::stats::{ActorInfo, WorkerStats};
use crate::stats::{StatsHook, WorkerStats};
// Re-export stats types so existing code using `runtime::*` still works
pub use crate::stats::{RuntimeStats, WorkerInfo};
use crate::worker::Worker;
@ -66,10 +67,10 @@ pub struct Runtime {
placement: Placement,
is_running: AtomicBool,
worker_stats: Vec<Arc<WorkerStats>>,
/// Per-worker mailbox snapshots, updated each tick by workers.
mailbox_snapshots: Vec<Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>>,
stats_hook: Option<Arc<dyn StatsHook>>,
/// Workers available for tick(). run() drains this and moves workers to threads.
tick_workers: RefCell<Vec<Worker>>,
created_at: Instant,
#[cfg(feature = "transport")]
codec_registry: Option<Arc<crate::transport::CodecRegistry>>,
#[cfg(feature = "transport")]
@ -80,6 +81,30 @@ pub struct Runtime {
// After run() the RefCell is empty and not accessed by worker threads.
unsafe impl Sync for Runtime {}
/// Globally unique identity of a swactor runtime instance.
/// Pure identity — no networking info. A runtime can exist on any device,
/// any protocol, or no network at all.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RuntimeAddress(pub [u8; 32]);
impl std::fmt::Display for RuntimeAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for b in &self.0[..8] {
write!(f, "{:02x}", b)?;
}
write!(f, "\u{2026}")
}
}
impl RuntimeAddress {
pub fn new_random() -> Self {
let mut bytes = [0u8; 32];
crate::get_random(&mut bytes);
Self(bytes)
}
}
impl Runtime {
/// Builds a new `Runtime` struct, but does not yet run anything. If multithreaded, call
/// `run()`, if single threaded, needs to be driven by calls to the `tick()` method.
@ -97,7 +122,6 @@ impl Runtime {
let mut transfer_txs = Vec::with_capacity(num_workers);
let mut spawn_txs = Vec::with_capacity(num_workers);
let mut worker_stats = Vec::with_capacity(num_workers);
let mut mailbox_snapshots = Vec::with_capacity(num_workers);
let mut workers = Vec::with_capacity(num_workers);
for i in 0..num_workers {
@ -111,10 +135,8 @@ impl Runtime {
spawn_txs.push(spawn_tx);
let stats = Arc::new(WorkerStats::new());
let mbox_snap = Arc::new(std::sync::Mutex::new(Vec::new()));
worker_stats.push(stats.clone());
mailbox_snapshots.push(mbox_snap.clone());
workers.push(Worker::new(WorkerId(i), transfer_rx, spawn_rx, stats, mbox_snap));
workers.push(Worker::new(WorkerId(i), transfer_rx, spawn_rx, stats));
}
let rt = Self {
@ -126,8 +148,9 @@ impl Runtime {
placement,
is_running: AtomicBool::new(false),
worker_stats,
mailbox_snapshots,
stats_hook: None,
tick_workers: RefCell::new(workers),
created_at: Instant::now(),
#[cfg(feature = "transport")]
codec_registry: None,
#[cfg(feature = "transport")]
@ -193,6 +216,7 @@ impl Runtime {
placement: &self.placement,
inbox_registry: &self.inbox_registry,
config: &self.config,
stats_hook: self.stats_hook.as_deref(),
#[cfg(feature = "transport")]
codec_registry: self.codec_registry.as_deref(),
#[cfg(feature = "transport")]
@ -261,18 +285,13 @@ impl Runtime {
.map(|(addr, wid)| (addr, wid.as_usize()))
.collect();
let mut actor_details = Vec::new();
for (wid, snap_lock) in self.mailbox_snapshots.iter().enumerate() {
for &(addr, depth) in snap_lock.lock().unwrap().iter() {
actor_details.push(ActorInfo { address: addr, worker_id: wid, mailbox_depth: depth });
}
}
let tick_timings = self.worker_stats.iter()
.map(|ws| ws.drain_tick_timings())
.collect();
RuntimeStats { num_workers, actors, workers, actor_details, tick_timings }
let uptime_ms = self.created_at.elapsed().as_millis() as u64;
RuntimeStats { num_workers, uptime_ms, actors, workers, actor_details: Vec::new(), tick_timings }
}
/// Signal all workers to stop
@ -283,6 +302,13 @@ impl Runtime {
self.is_running.store(false, Ordering::Release);
}
/// Set a stats hook to receive per-actor snapshots from workers.
///
/// Must be called before [`run()`](Self::run) or [`tick()`](Self::tick).
pub fn set_stats_hook(&mut self, hook: Arc<dyn StatsHook>) {
self.stats_hook = Some(hook);
}
/// Set the codec registry for remote transport.
#[cfg(feature = "transport")]
pub fn set_codec_registry(&mut self, registry: Arc<crate::transport::CodecRegistry>) {

View file

@ -98,6 +98,27 @@ pub struct WorkerInfo {
pub panics: u64,
}
/// Per-actor snapshot transferred from worker to runtime (not serialized).
pub struct ActorSnapshot {
pub address: ActorAddress,
pub mailbox_depth: usize,
pub last_msg_type: Option<&'static str>,
pub messages_processed: u64,
pub poisoned: bool,
}
/// Observer hook called by workers after productive ticks.
///
/// Implement this to collect per-actor snapshot data outside the runtime.
/// The runtime itself stores nothing — snapshots are ephemeral and passed by reference.
pub trait StatsHook: Send + Sync {
/// Called once per worker after a productive tick.
///
/// `worker_id` is the index of the worker (0..num_workers).
/// `snapshots` borrows the worker's scratch buffer — copy what you need.
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]);
}
/// Per-actor info for the dashboard.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@ -105,6 +126,15 @@ pub struct ActorInfo {
pub address: ActorAddress,
pub worker_id: usize,
pub mailbox_depth: usize,
/// Rust type name of the last message this actor successfully handled.
#[cfg_attr(feature = "serde", serde(default))]
pub last_msg_type: Option<String>,
/// Total messages successfully processed by this actor.
#[cfg_attr(feature = "serde", serde(default))]
pub messages_processed: u64,
/// Whether the actor has panicked and is no longer processing messages.
#[cfg_attr(feature = "serde", serde(default))]
pub poisoned: bool,
}
/// Snapshot of overall runtime state.
@ -112,6 +142,9 @@ pub struct ActorInfo {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RuntimeStats {
pub num_workers: usize,
/// Milliseconds since the runtime was created.
#[cfg_attr(feature = "serde", serde(default))]
pub uptime_ms: u64,
/// Each entry is (address, worker_id).
pub actors: Vec<(ActorAddress, usize)>,
pub workers: Vec<WorkerInfo>,

View file

@ -9,7 +9,7 @@ use std::time::Instant;
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx};
use crate::channel::Receiver;
use crate::delivery::{Envelope, TickContext, WorkerId};
use crate::stats::{TickTiming, WorkerStats};
use crate::stats::{ActorSnapshot, TickTiming, WorkerStats};
use crate::Error;
/// A worker owns a set of actors and runs them in a loop.
@ -19,8 +19,8 @@ pub(crate) struct Worker {
transfer_rx: Receiver<Envelope>,
spawn_rx: Receiver<(ActorAddress, Box<dyn AnyActor>)>,
stats: Arc<WorkerStats>,
/// Shared snapshot of per-actor mailbox depths, readable by Runtime::stats().
mailbox_snapshot: Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>,
/// Reusable scratch buffer for building per-actor snapshots.
snapshot_buf: Vec<ActorSnapshot>,
}
impl Worker {
@ -29,7 +29,6 @@ impl Worker {
transfer_rx: Receiver<Envelope>,
spawn_rx: Receiver<(ActorAddress, Box<dyn AnyActor>)>,
stats: Arc<WorkerStats>,
mailbox_snapshot: Arc<std::sync::Mutex<Vec<(ActorAddress, usize)>>>,
) -> Self {
Self {
id,
@ -37,7 +36,7 @@ impl Worker {
transfer_rx,
spawn_rx,
stats,
mailbox_snapshot,
snapshot_buf: Vec::new(),
}
}
@ -125,8 +124,10 @@ impl Worker {
self.stats.total_mailbox_depth.store(self.pool.total_mailbox_depth(), Ordering::Relaxed);
self.stats.messages_processed.fetch_add(processed as u64, Ordering::Relaxed);
let mut snap = self.mailbox_snapshot.lock().unwrap();
self.pool.mailbox_depths_into(&mut snap);
if let Some(hook) = tc.stats_hook {
self.pool.mailbox_depths_into(&mut self.snapshot_buf);
hook.on_tick(self.id.0, &self.snapshot_buf);
}
}
let t6 = Instant::now();
@ -231,6 +232,8 @@ struct ActorSlot {
mailbox: VecDeque<Box<dyn Any + Send>>,
actor: Box<dyn AnyActor>,
poisoned: bool,
last_msg_type: Option<&'static str>,
messages_processed: u64,
}
/// Per-worker actor storage. Owns per-actor mailboxes.
@ -250,6 +253,8 @@ impl ActorPool {
mailbox: VecDeque::with_capacity(16),
actor,
poisoned: false,
last_msg_type: None,
messages_processed: 0,
});
}
@ -279,7 +284,7 @@ impl ActorPool {
slot.actor.handle_any(&ctx, msg)
}));
match result {
Ok(false) => {
Ok(None) => {
stats.type_mismatches.fetch_add(1, Ordering::Relaxed);
}
Err(_) => {
@ -291,7 +296,10 @@ impl ActorPool {
slot.mailbox.clear();
break;
}
Ok(true) => {}
Ok(Some(type_name)) => {
slot.last_msg_type = Some(type_name);
slot.messages_processed += 1;
}
}
count += 1;
}
@ -307,9 +315,17 @@ impl ActorPool {
self.actors.values().map(|slot| slot.mailbox.len()).sum()
}
/// Fill `out` with per-actor mailbox depths, reusing the existing allocation.
pub fn mailbox_depths_into(&self, out: &mut Vec<(ActorAddress, usize)>) {
/// Fill `out` with per-actor snapshots, reusing the existing allocation.
pub fn mailbox_depths_into(&self, out: &mut Vec<ActorSnapshot>) {
out.clear();
out.extend(self.actors.iter().map(|(&addr, slot)| (addr, slot.mailbox.len())));
out.extend(self.actors.iter().map(|(&addr, slot)| {
ActorSnapshot {
address: addr,
mailbox_depth: slot.mailbox.len(),
last_msg_type: slot.last_msg_type,
messages_processed: slot.messages_processed,
poisoned: slot.poisoned,
}
}));
}
}