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:
parent
2da9198ee9
commit
076f1194bf
30 changed files with 4257 additions and 107 deletions
545
Cargo.lock
generated
545
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -40,3 +40,7 @@ harness = false
|
|||
[[bench]]
|
||||
name = "mt_benchmarks"
|
||||
harness = false
|
||||
|
||||
[[example]]
|
||||
name = "tcp_ping_pong"
|
||||
required-features = ["transport"]
|
||||
|
|
|
|||
175
crates/runtime-dashboard/AGENTS.md
Normal file
175
crates/runtime-dashboard/AGENTS.md
Normal 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 |
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
122
crates/runtime-dashboard/examples/investigate_demo.rs
Normal file
122
crates/runtime-dashboard/examples/investigate_demo.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
13
crates/runtime-dashboard/examples/remote_tui.rs
Normal file
13
crates/runtime-dashboard/examples/remote_tui.rs
Normal 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())
|
||||
}
|
||||
295
crates/runtime-dashboard/examples/tui_demo.rs
Normal file
295
crates/runtime-dashboard/examples/tui_demo.rs
Normal 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(())
|
||||
}
|
||||
739
crates/runtime-dashboard/src/actors_html.rs
Normal file
739
crates/runtime-dashboard/src/actors_html.rs
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
"##;
|
||||
60
crates/runtime-dashboard/src/collector.rs
Normal file
60
crates/runtime-dashboard/src/collector.rs
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
498
crates/runtime-dashboard/src/investigate.rs
Normal file
498
crates/runtime-dashboard/src/investigate.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
357
crates/runtime-dashboard/src/tui/app.rs
Normal file
357
crates/runtime-dashboard/src/tui/app.rs
Normal 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])
|
||||
}
|
||||
86
crates/runtime-dashboard/src/tui/event.rs
Normal file
86
crates/runtime-dashboard/src/tui/event.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
191
crates/runtime-dashboard/src/tui/mod.rs
Normal file
191
crates/runtime-dashboard/src/tui/mod.rs
Normal 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(())
|
||||
}
|
||||
238
crates/runtime-dashboard/src/tui/sse_client.rs
Normal file
238
crates/runtime-dashboard/src/tui/sse_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
38
crates/runtime-dashboard/src/tui/types.rs
Normal file
38
crates/runtime-dashboard/src/tui/types.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
473
crates/runtime-dashboard/src/tui/ui.rs
Normal file
473
crates/runtime-dashboard/src/tui/ui.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
10
src/actor.rs
10
src/actor.rs
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
33
src/stats.rs
33
src/stats.rs
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue