diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31336991..a66ec20e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,46 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings + coverage: + name: Coverage (rocm-dash crates, ratcheted) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install native build deps + run: sudo apt-get update && sudo apt-get install -y pkg-config libcap-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Cache cargo registry and build + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-cov-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-cov- + + # Ratcheted, fail-on-regression floor scoped to the transplanted rocm-dash + # crates (the clean anchor) — deliberately NOT the whole workspace, so the + # gate does not over-claim coverage of the large untyped rocm-cli core + # (EAI-6871 / D8). Baseline measured 2026-06-11: 73.7% lines across the four + # crates. Floor set just below measured; ratchet upward as the larger TUI + # tab files (bench/overview/modal) gain tests. + - name: Coverage gate (rocm-dash crates, >= 70% lines) + run: | + cargo llvm-cov --no-cfg-coverage \ + -p rocm-dash-core -p rocm-dash-collectors \ + -p rocm-dash-daemon -p rocm-dash-tui \ + --fail-under-lines 70 + gpu-smoke: name: GPU Smoke (gfx1151) runs-on: [self-hosted, windows-gfx1151-gpu-rocm] diff --git a/Cargo.lock b/Cargo.lock index f405a645..2b4a7c3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -34,6 +43,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -101,6 +119,21 @@ dependencies = [ "security-framework 3.7.0", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -194,7 +227,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -234,7 +267,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -251,7 +284,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -266,6 +308,28 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -318,17 +382,53 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" -version = "2.11.0" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -361,12 +461,77 @@ dependencies = [ "piper", ] +[[package]] +name = "bollard" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d41711ad46fda47cd701f6908e59d1bd6b9a2b7464c0d0aeab95c6d37096ff8a" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.45.0-rc.26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7c5415e3a6bc6d3e99eff6268e488fd4ee25e7b28c10f08fa6760bd9de16e4" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -410,6 +575,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -419,6 +586,26 @@ 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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -460,7 +647,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -469,12 +656,58 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -489,6 +722,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -551,6 +798,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -563,9 +816,10 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.13.0", "crossterm_winapi", "filedescriptor", + "futures-core", "libc", "mio", "parking_lot", @@ -581,7 +835,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -614,6 +868,37 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.23.0" @@ -634,7 +919,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -645,7 +930,29 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", ] [[package]] @@ -667,7 +974,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", ] [[package]] @@ -690,6 +997,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -710,7 +1026,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -722,12 +1038,33 @@ dependencies = [ "litrs", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -752,7 +1089,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -771,6 +1108,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -792,6 +1138,43 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -826,21 +1209,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "flate2" -version = "1.1.9" +name = "finl_unicode" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -850,6 +1257,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -857,6 +1285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -865,6 +1294,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -892,24 +1332,40 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -931,8 +1387,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -943,11 +1415,48 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -956,7 +1465,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -964,6 +1484,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1056,6 +1581,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1065,6 +1591,37 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -1073,15 +1630,64 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", "pin-project-lite", "tokio", "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1197,6 +1803,23 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1238,9 +1861,15 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1256,12 +1885,80 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1274,6 +1971,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keyring-core" version = "1.0.0" @@ -1283,6 +1991,18 @@ dependencies = [ "log", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1295,6 +2015,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.15" @@ -1304,6 +2030,15 @@ dependencies = [ "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1353,22 +2088,62 @@ dependencies = [ ] [[package]] -name = "matchit" -version = "0.8.4" +name = "lru" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "memoffset" -version = "0.9.1" +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] @@ -1379,6 +2154,22 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1391,9 +2182,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -1401,6 +2192,56 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -1434,6 +2275,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1474,6 +2332,33 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1492,12 +2377,36 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1508,6 +2417,36 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -1549,6 +2488,121 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1586,6 +2640,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1595,6 +2655,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1602,60 +2677,271 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.0", + "cassowary", + "compact_str 0.8.1", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate 1.1.0", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +dependencies = [ + "bitflags 2.13.0", + "compact_str 0.9.1", + "critical-section", + "hashbrown 0.17.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru 0.18.0", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate 2.0.1", + "unicode-width 0.2.0", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "ratatui-crossterm" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" dependencies = [ - "toml_edit", + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "ratatui-macros" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" dependencies = [ - "unicode-ident", + "ratatui-core", + "ratatui-widgets", ] [[package]] -name = "quote" -version = "1.0.45" +name = "ratatui-termwiz" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" dependencies = [ - "proc-macro2", + "ratatui-core", + "termwiz", ] [[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "ratatui" -version = "0.29.0" +name = "ratatui-widgets" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm 0.28.1", + "bitflags 2.13.0", + "hashbrown 0.17.1", "indoc", "instability", - "itertools", - "lru", - "paste", - "strum", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", "unicode-segmentation", - "unicode-truncate", "unicode-width 0.2.0", ] @@ -1665,7 +2951,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -1679,6 +2965,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -1708,6 +3014,115 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rig-core" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9bae0f2b910cbd4ca2d53513f18aba3b6f6cb6cb0bf295e390d5396f56359d" +dependencies = [ + "as-any", + "async-stream", + "base64", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http", + "mime", + "mime_guess", + "nanoid", + "ordered-float 5.3.0", + "pin-project-lite", + "reqwest 0.13.4", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-futures", + "url", +] + [[package]] name = "ring" version = "0.17.14" @@ -1733,8 +3148,10 @@ dependencies = [ "crossterm 0.29.0", "flate2", "keyring-core", - "ratatui", + "ratatui 0.29.0", "rocm-core", + "rocm-dash-daemon", + "rocm-dash-tui", "rocm-engine-atom", "rocm-engine-lemonade", "rocm-engine-llama-cpp", @@ -1746,6 +3163,7 @@ dependencies = [ "serde", "serde_json", "tar", + "tokio", "ureq", "windows-native-keyring-store", "zbus-secret-service-keyring-store", @@ -1760,10 +3178,87 @@ dependencies = [ "libc", "serde", "serde_json", + "toml", "ureq", "windows-sys 0.61.2", ] +[[package]] +name = "rocm-dash-collectors" +version = "0.3.0" +dependencies = [ + "bollard", + "chrono", + "csv", + "regex", + "reqwest 0.12.28", + "rocm-dash-core", + "serde", + "serde_json", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rocm-dash-core" +version = "0.3.0" +dependencies = [ + "chrono", + "dirs", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "tracing", +] + +[[package]] +name = "rocm-dash-daemon" +version = "0.3.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "color-eyre", + "futures", + "rocm-dash-collectors", + "rocm-dash-core", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "rocm-dash-tui" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "color-eyre", + "crossterm 0.28.1", + "futures", + "http", + "ratatui 0.30.1", + "reqwest 0.13.4", + "rig-core", + "rocm-dash-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", +] + [[package]] name = "rocm-engine-atom" version = "0.3.0" @@ -1886,6 +3381,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1901,7 +3408,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1914,7 +3421,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -1927,6 +3434,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -1942,13 +3450,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "rustls-pki-types", "schannel", "security-framework 2.11.1", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -1964,15 +3484,44 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.4", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1990,6 +3539,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1999,6 +3557,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2030,7 +3625,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2043,7 +3638,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2093,14 +3688,25 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2128,19 +3734,58 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "time", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -2154,6 +3799,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2197,6 +3851,28 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2243,7 +3919,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2256,7 +3941,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2265,6 +3962,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2281,6 +3989,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2290,7 +4001,41 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -2317,6 +4062,69 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2343,7 +4151,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2354,7 +4162,49 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -2367,15 +4217,31 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2386,13 +4252,73 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -2404,16 +4330,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", - "toml_datetime", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2422,9 +4362,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2441,6 +4387,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2457,32 +4421,122 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", - "pin-project-lite", - "tracing-attributes", + "once_cell", "tracing-core", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "tracing-serde" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "proc-macro2", - "quote", - "syn", + "serde", + "tracing-core", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", ] [[package]] @@ -2491,6 +4545,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -2502,6 +4562,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2520,11 +4586,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -2560,7 +4637,7 @@ dependencies = [ "log", "once_cell", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "rustls-pki-types", "serde", "serde_json", @@ -2580,6 +4657,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2598,17 +4681,53 @@ version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ + "atomic", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2646,6 +4765,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2665,7 +4794,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2695,23 +4824,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2730,6 +4901,78 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2746,12 +4989,100 @@ 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 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" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2771,6 +5102,44 @@ dependencies = [ "zeroize", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2862,6 +5231,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" @@ -2905,9 +5283,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2923,7 +5301,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2935,8 +5313,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2955,7 +5333,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3000,7 +5378,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3034,7 +5412,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -3060,7 +5438,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -3073,10 +5451,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow", + "winnow 1.0.3", "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3094,7 +5492,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3134,7 +5532,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3152,7 +5550,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] @@ -3166,7 +5564,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -3179,6 +5577,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", - "winnow", + "syn 2.0.117", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index ab6425aa..f54f06c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,11 @@ members = [ "apps/rocmd", "crates/rocm-core", "crates/rocm-engine-protocol", + # rocm-dash telemetry/dashboard libraries (EAI-6871 merge). + "crates/rocm-dash-core", + "crates/rocm-dash-collectors", + "crates/rocm-dash-daemon", + "crates/rocm-dash-tui", "engines/llama-cpp", "engines/atom", "engines/pytorch", diff --git a/apps/rocm/Cargo.toml b/apps/rocm/Cargo.toml index 3e37a946..5c076313 100644 --- a/apps/rocm/Cargo.toml +++ b/apps/rocm/Cargo.toml @@ -14,6 +14,13 @@ flate2 = "1.1" keyring-core.workspace = true ratatui.workspace = true rocm-core = { path = "../../crates/rocm-core" } +# rocm-dash unified dashboard launch (EAI-6871 Phase 3 / D5 bin-fold). The +# telemetry daemon + ratatui-0.30 TUI are launched from the `dash` verb; tokio +# drives the async daemon/TUI from the otherwise-sync `rocm` binary. +# (rocm-dash-core is pulled in transitively by the two below — not a direct dep.) +rocm-dash-daemon = { path = "../../crates/rocm-dash-daemon" } +rocm-dash-tui = { path = "../../crates/rocm-dash-tui" } +tokio = { workspace = true } rocm-engine-atom = { path = "../../engines/atom" } rocm-engine-lemonade = { path = "../../engines/lemonade" } rocm-engine-llama-cpp = { path = "../../engines/llama-cpp" } diff --git a/apps/rocm/src/automations.rs b/apps/rocm/src/automations.rs new file mode 100644 index 00000000..f6145151 --- /dev/null +++ b/apps/rocm/src/automations.rs @@ -0,0 +1,65 @@ +//! `rocm automations` command handler (EAI-6871 D5). +//! +//! Mechanically relocated from `main.rs` with no behavior change — the +//! `dispatch()` call site stays `automations(command)` (re-imported via +//! `use crate::automations::automations;`). Render/policy helpers remain in the +//! crate root and are reached through `crate::` (root items are visible to this +//! descendant module). + +use anyhow::{Result, bail}; +use rocm_core::{AppPaths, RocmCliConfig, builtin_watcher}; + +use crate::{AutomationsCommand, render_automations_text, watcher_policy_note}; + +pub(crate) fn automations(command: Option) -> Result<()> { + let paths = AppPaths::discover()?; + let mut config = RocmCliConfig::load(&paths)?; + match command.unwrap_or(AutomationsCommand::List) { + AutomationsCommand::List => { + print!("{}", render_automations_text(&paths, &config)?); + } + AutomationsCommand::Enable { watcher, mode } => { + let Some(spec) = builtin_watcher(&watcher) else { + bail!("unknown watcher: {watcher}"); + }; + let entry = config.watcher_config_mut(spec.id); + entry.enabled = true; + if let Some(mode) = mode { + entry.mode = Some(mode.into()); + } + config.automations.daemon_enabled = true; + config.save(&paths)?; + println!("automation watcher enabled"); + println!(" watcher: {}", spec.id); + println!(" mode: {}", config.effective_watcher_mode(spec).as_str()); + println!(" trigger: {}", spec.trigger); + if let Some(note) = watcher_policy_note(spec.id) { + println!(" policy: {note}"); + } + println!(" config: {}", paths.config_path().display()); + println!( + " next step: run `rocmd run --automations-enabled` to start the persistent watcher loop" + ); + } + AutomationsCommand::Disable { watcher } => { + let Some(spec) = builtin_watcher(&watcher) else { + bail!("unknown watcher: {watcher}"); + }; + let entry = config.watcher_config_mut(spec.id); + entry.enabled = false; + if !config + .automations + .watchers + .values() + .any(|watcher| watcher.enabled) + { + config.automations.daemon_enabled = false; + } + config.save(&paths)?; + println!("automation watcher disabled"); + println!(" watcher: {}", spec.id); + println!(" config: {}", paths.config_path().display()); + } + } + Ok(()) +} diff --git a/apps/rocm/src/dash.rs b/apps/rocm/src/dash.rs new file mode 100644 index 00000000..815fd60a --- /dev/null +++ b/apps/rocm/src/dash.rs @@ -0,0 +1,290 @@ +//! `rocm dash` — launch the unified telemetry dashboard (EAI-6871 Phase 3 / D5). +//! +//! Folds the rocm-dash launch verb into the `rocm` binary. It builds the +//! telemetry daemon's [`RunnerOptions`] — wiring `services_dir = +//! AppPaths::services_dir()` so the managed services that `rocm serve --managed` +//! writes there surface live in the dashboard (the D7 registry→scrape→`gen_tps` +//! seam) — auto-starts an embedded daemon when none is already listening, and +//! runs the ratatui dashboard TUI. +//! +//! The rest of `rocm` is synchronous; the async daemon/TUI run on a tokio +//! runtime built here. The two ratatui majors (0.29 in `tui.rs`, 0.30 in +//! `rocm-dash-tui`) coexist, each confined to its crate. + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use rocm_core::{AppPaths, RocmCliConfig, builtin_model_recipes, builtin_watchers}; +use rocm_dash_daemon::runner::RunnerOptions; +use rocm_dash_tui::app::{ActiveTab, ResolvedArgs}; +use rocm_dash_tui::ui::automations_manager::AutomationSummary; +use rocm_dash_tui::ui::model_picker::ModelRecipeSummary; +use rocm_dash_tui::ui::runtime_manager::RuntimeSummary; + +use crate::therock; + +/// Build the telemetry-daemon options from the unified dashboard config. +/// +/// `services_dir` is the load-bearing wire: pointing it at +/// [`AppPaths::services_dir`] makes the daemon discover managed services written +/// by `rocm serve --managed` and surface their `gen_tps` in the dashboard. +pub fn runner_options( + config: &RocmCliConfig, + paths: &AppPaths, + enable_docker: bool, +) -> RunnerOptions { + let d = &config.dashboard.daemon; + RunnerOptions { + bench_csv: d.bench_results_dir.clone(), + enable_docker, + image_patterns: None, + gpu_tick: Duration::from_secs_f64(d.gpu_tick_secs), + discovery_tick: Duration::from_secs_f64(d.discovery_tick_secs), + instance_tick: Duration::from_secs_f64(d.instance_tick_secs), + disable_vllm_metrics: false, + vllm_metrics_host: "127.0.0.1".into(), + // Lemonade discovery stays opt-in (mirrors a no-flag embedded daemon). + enable_lemonade: false, + lemonade_host: "127.0.0.1".into(), + lemonade_port: 13305, + persist_dir: Some(paths.telemetry_state_dir()), + // D7 seam consumer: managed services from `rocm serve --managed`. + services_dir: Some(paths.services_dir()), + } +} + +/// API key precedence — sourced from the environment ONLY (never TOML/CLI/source/ +/// logs); see the chat invariant. +fn chat_api_key_from_env() -> Option { + ["ROCMDASH_CHAT_API_KEY", "AMD_LLM_API_KEY", "OPENAI_API_KEY"] + .into_iter() + .find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty())) +} + +/// Adapt the built-in `rocm-core` model recipes into the TUI-local summaries the +/// serve-wizard picker consumes (the bin owns the `rocm-core` dependency so the +/// dash crates stay free of it). +fn model_recipe_summaries() -> Vec { + builtin_model_recipes() + .into_iter() + .map(|r| ModelRecipeSummary { + id: r.canonical_model_id, + aliases: r.aliases, + task: r.task, + preferred_engine: r.preferred_engines.into_iter().next(), + }) + .collect() +} + +/// Adapt the registered ROCm runtimes into the TUI-local summaries the runtime +/// manager consumes (the bin owns `rocm-core` / `therock`, so the dash crates +/// stay free of them). Tolerant: a load failure yields an empty list rather +/// than blocking the dashboard launch — the in-TUI refresh re-reads live. +fn runtime_summaries(paths: &AppPaths, config: &RocmCliConfig) -> Vec { + let Ok(manifests) = therock::load_runtime_manifests(paths) else { + return Vec::new(); + }; + let active_key = config.active_runtime_key.as_deref(); + let prev_key = config.previous_runtime_key.as_deref(); + let default_id = config.default_runtime_id.as_deref(); + // Mirror `render_runtimes_text`: a runtime is active by an explicit + // active_runtime_key, or — absent one — by being the single manifest whose + // runtime_id matches the configured default_runtime_id. + let default_matches: Vec<&str> = manifests + .iter() + .filter(|m| Some(m.runtime_id.as_str()) == default_id) + .map(|m| m.runtime_key.as_str()) + .collect(); + let single_default_key = if active_key.is_none() && default_matches.len() == 1 { + Some(default_matches[0].to_string()) + } else { + None + }; + manifests + .iter() + .map(|m| { + let active = active_key == Some(m.runtime_key.as_str()) + || single_default_key.as_deref() == Some(m.runtime_key.as_str()); + let rollback = prev_key == Some(m.runtime_key.as_str()); + RuntimeSummary { + key: m.runtime_key.clone(), + id: m.runtime_id.clone(), + channel: m.channel.clone(), + version: m.version.clone(), + root: m.install_root.display().to_string(), + active, + rollback, + } + }) + .collect() +} + +/// Adapt the built-in background checks into the TUI-local summaries the +/// automations manager consumes (enabled-state + effective mode come from the +/// unified config; the bin owns `rocm-core`). +fn automation_summaries(config: &RocmCliConfig) -> Vec { + builtin_watchers() + .iter() + .map(|w| AutomationSummary { + id: w.id.to_string(), + summary: w.summary.to_string(), + enabled: config.watcher_enabled(w), + mode: config.effective_watcher_mode(w).as_str().to_string(), + }) + .collect() +} + +/// Resolve the TUI args from the unified config + environment. +pub fn resolved_args( + config: &RocmCliConfig, + paths: &AppPaths, + initial_tab: ActiveTab, +) -> ResolvedArgs { + let t = &config.dashboard.tui; + ResolvedArgs { + connect: t.connect.clone(), + token: config.dashboard.daemon.token.clone(), + theme: t.theme.clone(), + replay: None, + initial_tab, + chat_url: t.chat_url.clone(), + chat_model: t.chat_model.clone(), + chat_auth_header: t.chat_auth_header.clone(), + chat_env_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.is_empty()), + chat_api_key: chat_api_key_from_env(), + chat_auto_consent: false, + chat_mock: false, + model_recipes: model_recipe_summaries(), + runtimes: runtime_summaries(paths, config), + automations: automation_summaries(config), + } +} + +/// Entry point for `rocm dash`. Builds a tokio runtime and runs the dashboard. +pub fn run() -> Result<()> { + let paths = AppPaths::discover()?; + let config = RocmCliConfig::load(&paths)?; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("building tokio runtime for the dashboard")?; + rt.block_on(run_async(config, paths)) +} + +async fn run_async(config: RocmCliConfig, paths: AppPaths) -> Result<()> { + let args = resolved_args(&config, &paths, ActiveTab::Overview); + let embedded = maybe_spawn_embedded_daemon(&args.connect, &config, &paths).await; + + let result = rocm_dash_tui::app::run(args) + .await + .map_err(|e| anyhow::anyhow!(e.to_string())); + + // Tidy up the embedded daemon on exit (best-effort). + if let Some((handle, socket)) = embedded { + handle.abort(); + if let Some(path) = socket { + let _ = std::fs::remove_file(path); + } + } + result +} + +/// Auto-start an embedded telemetry daemon when no local one is already +/// listening, so `rocm dash` works without a separate `rocm daemon` terminal. +/// Returns the task handle + socket to clean up on exit, or `None` when an +/// existing daemon was found (we connect to it instead). +async fn maybe_spawn_embedded_daemon( + connect: &str, + config: &RocmCliConfig, + paths: &AppPaths, +) -> Option<(tokio::task::JoinHandle<()>, Option)> { + // Only auto-manage a LOCAL unix-socket daemon. + let target = connect.strip_prefix("unix:")?; + if tokio::net::UnixStream::connect(target).await.is_ok() { + return None; // a daemon already answers here + } + + let opts = runner_options(config, paths, false); + let listen = connect.to_string(); + let socket = Some(PathBuf::from(target)); + let token = config.dashboard.daemon.token.clone(); + + let handle = tokio::spawn(async move { + if let Err(e) = rocm_dash_daemon::server::run(&listen, token.as_deref(), opts).await { + eprintln!("rocm: embedded telemetry daemon exited: {e:#}"); + } + }); + // Give it a moment to bind before the TUI client dials in. + tokio::time::sleep(Duration::from_millis(200)).await; + Some((handle, socket)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> RocmCliConfig { + RocmCliConfig::default() + } + + fn paths() -> AppPaths { + AppPaths { + config_dir: PathBuf::from("/tmp/rocm-cfg"), + data_dir: PathBuf::from("/tmp/rocm-data"), + cache_dir: PathBuf::from("/tmp/rocm-cache"), + } + } + + #[test] + fn runner_options_wires_services_dir_to_registry() { + let p = paths(); + let opts = runner_options(&cfg(), &p, false); + // The serve→dashboard wire: daemon reads the managed-service registry. + assert_eq!(opts.services_dir, Some(p.services_dir())); + assert_eq!(opts.persist_dir, Some(p.telemetry_state_dir())); + assert!(!opts.enable_docker); + } + + #[test] + fn resolved_args_take_connect_and_theme_from_config() { + let c = cfg(); + let args = resolved_args(&c, &paths(), ActiveTab::Overview); + assert_eq!(args.connect, c.dashboard.tui.connect); + assert_eq!(args.theme, c.dashboard.tui.theme); + assert!(!args.chat_mock); + assert!(args.replay.is_none()); + // The serve-wizard recipe picker is fed from the built-in recipes. + assert!( + !args.model_recipes.is_empty(), + "built-in model recipes flow through to the wizard" + ); + } + + #[test] + fn model_recipe_summaries_carry_id_and_engine() { + let records = builtin_model_recipes(); + let summaries = model_recipe_summaries(); + assert!(!summaries.is_empty()); + assert_eq!(summaries.len(), records.len(), "no recipes dropped"); + // Every summary has a non-empty canonical id (the serve argv target). + assert!(summaries.iter().all(|s| !s.id.is_empty())); + // The preferred engine is actually plumbed (not zeroed) — at least one + // recipe declares one, and the first summary mirrors its record. + assert!( + summaries.iter().any(|s| s.preferred_engine.is_some()), + "preferred_engine forwarded" + ); + let first = &records[0]; + let first_summary = &summaries[0]; + assert_eq!(first_summary.id, first.canonical_model_id); + assert_eq!(first_summary.aliases, first.aliases); + assert_eq!(first_summary.task, first.task); + assert_eq!( + first_summary.preferred_engine.as_ref(), + first.preferred_engines.first() + ); + } +} diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index 1bcb955a..d60ceb25 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -1,9 +1,18 @@ +mod automations; mod bootstrap; mod comfyui; +mod dash; mod provider_keys; mod providers; mod therock; mod tui; +mod uninstall; + +// EAI-6871 D5: per-command handler fns mechanically relocated into modules. +// Dispatch call sites stay byte-identical via these re-imports (upstream-sync +// mergeability); only the fn definitions moved out of main.rs. +use crate::automations::automations; +use crate::uninstall::uninstall; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand, ValueEnum}; @@ -240,6 +249,8 @@ enum Command { }, /// Start the background helper in the foreground. Daemon, + /// Launch the unified telemetry dashboard (TUI) with an embedded daemon. + Dash, /// Remove ROCm CLI-managed files from this computer. Uninstall { /// Do not ask for interactive confirmation. @@ -610,6 +621,8 @@ impl TelemetryModeArg { } fn main() -> Result<()> { + maybe_migrate_legacy_dashboard_config(); + let raw_args: Vec = std::env::args().skip(1).collect(); if raw_args.is_empty() { return launch_default(); @@ -631,6 +644,28 @@ fn main() -> Result<()> { dispatch(Cli::parse()) } +/// One-shot, best-effort migration of a legacy rocm-dash `config.toml` into the +/// unified `config.json` (EAI-6871 D6). Prints a notice when a migration runs; +/// never fails startup if the legacy file is malformed. +fn maybe_migrate_legacy_dashboard_config() { + let Ok(paths) = AppPaths::discover() else { + return; + }; + match RocmCliConfig::migrate_legacy_dashboard_toml(&paths) { + Ok(Some(legacy)) => { + eprintln!( + "rocm: migrated legacy dashboard config from {} into {} (the original TOML was left untouched)", + legacy.display(), + paths.config_path().display() + ); + } + Ok(None) => {} + Err(err) => { + eprintln!("rocm: skipped legacy dashboard config migration: {err:#}"); + } + } +} + fn launch_default() -> Result<()> { refresh_startup_update_check_quietly(); if interactive_terminal() { @@ -1260,6 +1295,7 @@ fn dispatch(cli: Cli) -> Result<()> { print!("{}", render_daemon_text(&paths, &config)); Ok(()) } + Some(Command::Dash) => dash::run(), Some(Command::Uninstall { yes, dry_run, @@ -5170,59 +5206,6 @@ fn write_active_runtime_marker(paths: &AppPaths, marker: ActiveRuntimeMarker) -> Ok(()) } -fn automations(command: Option) -> Result<()> { - let paths = AppPaths::discover()?; - let mut config = RocmCliConfig::load(&paths)?; - match command.unwrap_or(AutomationsCommand::List) { - AutomationsCommand::List => { - print!("{}", render_automations_text(&paths, &config)?); - } - AutomationsCommand::Enable { watcher, mode } => { - let Some(spec) = builtin_watcher(&watcher) else { - bail!("unknown watcher: {watcher}"); - }; - let entry = config.watcher_config_mut(spec.id); - entry.enabled = true; - if let Some(mode) = mode { - entry.mode = Some(mode.into()); - } - config.automations.daemon_enabled = true; - config.save(&paths)?; - println!("automation watcher enabled"); - println!(" watcher: {}", spec.id); - println!(" mode: {}", config.effective_watcher_mode(spec).as_str()); - println!(" trigger: {}", spec.trigger); - if let Some(note) = watcher_policy_note(spec.id) { - println!(" policy: {note}"); - } - println!(" config: {}", paths.config_path().display()); - println!( - " next step: run `rocmd run --automations-enabled` to start the persistent watcher loop" - ); - } - AutomationsCommand::Disable { watcher } => { - let Some(spec) = builtin_watcher(&watcher) else { - bail!("unknown watcher: {watcher}"); - }; - let entry = config.watcher_config_mut(spec.id); - entry.enabled = false; - if !config - .automations - .watchers - .values() - .any(|watcher| watcher.enabled) - { - config.automations.daemon_enabled = false; - } - config.save(&paths)?; - println!("automation watcher disabled"); - println!(" watcher: {}", spec.id); - println!(" config: {}", paths.config_path().display()); - } - } - Ok(()) -} - fn config(command: ConfigCommand) -> Result<()> { let paths = AppPaths::discover()?; let mut config = RocmCliConfig::load(&paths)?; @@ -6675,7 +6658,7 @@ fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option { .position(|window| window.eq_ignore_ascii_case(needle)) } -const ROCM_CHAT_TOOL_SYSTEM_PROMPT: &str = "You are ROCm CLI's local assistant. Speak in simple English for non-technical Windows users. Use the provided ROCm tools when you need to inspect this machine, preview setup, read service logs, check updates, inspect automations, install or start ROCm-managed apps, or request ROCm/TheRock, config, engine, app, and local model server changes. For simple greetings or thanks like hello, hi, hey, ok, or thank you, reply normally; do not inspect ROCm, do not call tools, and do not launch or propose a model server. Tool-use rules: inspect first with read-only tools; call rocm_command only with argv-style args and no shell text; use natural_language_plan for ROCm requests that do not fit another read-only tool; ask for a mutating tool call only after explaining why it is needed; summarize tool results after they are returned. Read-only tools may run immediately. Tools that install, launch, stop, delete, or change state require user approval; request rocm_command and explain why. For 'is X running?', 'what is running?', status, or port questions, inspect before answering and do not start, stop, install, or serve anything. For ComfyUI or port 8188 use [\"comfyui\",\"status\"] or port_status. For vLLM, SGLang, Lemonade, PyTorch, llama.cpp, qwen, or local model servers use [\"services\",\"list\",\"--all\"] for running state and [\"engines\",\"list\"] for installed/available engine state. Treat ready/running as running, starting/recovering as starting, failed/stopped as not running, and no matching record as unknown or not managed by ROCm CLI. Interpret Doctor carefully: active_runtime_status=ready means ROCm CLI has an active managed TheRock/ROCm runtime; legacy_rocm_status=not_detected only means no global system ROCm install was found. If active_runtime_status=ready, tell the user ROCm/TheRock is installed and active for ROCm CLI. For 'is TheRock installed', 'is ROCm installed', or 'which GPU is on this machine', use doctor or gpu_snapshot before answering. For 'how do I setup TheRock' or install/setup requests, guide the user to choose an install folder first; do not answer with only a status check. For 'which LLMs can this machine support', use rocm_command args [\"model\"] or natural_language_plan before answering. For TheRock installs, always let the user choose the install folder. If the user names a folder or prefix, preserve that exact folder with [\"--prefix\",\"PATH\"]; you may call path_exists first to check whether that user-provided folder or its parent exists. If the user asks you to install TheRock/ROCm but has not named a folder, ask for the folder or let the guided setup folder picker collect it; do not invent a hidden default folder and do not request an install command without --prefix. Use rocm_command args [\"install\",\"sdk\",\"--channel\",\"release\",\"--format\",\"pip\",\"--prefix\",\"PATH\"] only when the user asks you to install it and a folder is known; for a requested build date add [\"--build-date\",\"YYYY-MM-DD\"] and for a requested exact version add [\"--version\",\"VERSION\"]. For config changes, inspect with [\"config\",\"show\"] first when useful, then request config subcommands such as [\"config\",\"set-default-engine\",\"lemonade\"], [\"config\",\"set-default-runtime\",\"RUNTIME_KEY\"], or [\"config\",\"set-telemetry\",\"local\"] only after explaining why. For ComfyUI, use rocm_command with args like [\"comfyui\",\"status\"], [\"comfyui\",\"logs\"], [\"comfyui\",\"install\"], [\"comfyui\",\"start\"], or [\"comfyui\",\"stop\"]. First-time setup is the same thing as bootstrap in ROCm CLI; it is a deterministic ROCm setup flow, not a separate model chat. The built-in local assistant is fixed to qwen, which maps to Qwen3-4B-Instruct-2507-GGUF served by Lemonade with gpu_required. vLLM, SGLang, PyTorch, and Lemonade are general serving engines; inspect or manage them when the user asks about general model serving, but do not switch the built-in assistant away from Lemonade. Use qwen-smoke only for a quick server smoke test. For llama.cpp, use the llama.cpp engine backed by upstream llama-server: request rocm_command args like [\"engines\",\"install\",\"llama.cpp\"] or [\"serve\",\"MODEL.gguf\",\"--engine\",\"llama.cpp\",\"--device\",\"gpu_required\",\"--managed\"]. On native Windows, vLLM and SGLang are skipped; use WSL/Linux for those ROCm GPU engines. For vLLM management, inspect engines first and use [\"engines\",\"install\",\"vllm\"] or [\"serve\",\"MODEL\",\"--engine\",\"vllm\",\"--device\",\"gpu_required\",\"--managed\"] only where the host supports it. Do not invent shell commands and do not request CPU fallback."; +const ROCM_CHAT_TOOL_SYSTEM_PROMPT: &str = "You are ROCm CLI's local assistant. Speak in simple English for non-technical Windows users. Use the provided ROCm tools when you need to inspect this machine, preview setup, read service logs, check updates, inspect automations, install or start ROCm-managed apps, or request ROCm/TheRock, config, engine, app, and local model server changes. For simple greetings or thanks like hello, hi, hey, ok, or thank you, reply normally; do not inspect ROCm, do not call tools, and do not launch or propose a model server. Tool-use rules: inspect first with read-only tools; call rocm_command only with argv-style args and no shell text; use natural_language_plan for ROCm requests that do not fit another read-only tool; ask for a mutating tool call only after explaining why it is needed; summarize tool results after they are returned. Read-only tools may run immediately. Tools that install, launch, stop, delete, or change state require user approval; request rocm_command and explain why. For 'is X running?', 'what is running?', status, or port questions, inspect before answering and do not start, stop, install, or serve anything. For ComfyUI or port 8188 use [\"comfyui\",\"status\"] or port_status. For vLLM, SGLang, Lemonade, PyTorch, llama.cpp, qwen, or local model servers use [\"services\",\"list\",\"--all\"] for running state and [\"engines\",\"list\"] for installed/available engine state. Treat ready/running as running, starting/recovering as starting, failed/stopped as not running, and no matching record as unknown or not managed by ROCm CLI. Interpret Doctor carefully: active_runtime_status=ready means ROCm CLI has an active managed TheRock/ROCm runtime; legacy_rocm_status=not_detected only means no global system ROCm install was found. If active_runtime_status=ready, tell the user ROCm/TheRock is installed and active for ROCm CLI. For 'is TheRock installed', 'is ROCm installed', or 'which GPU is on this machine', use doctor or gpu_snapshot before answering. For 'how do I setup TheRock' or install/setup requests, guide the user to choose an install folder first; do not answer with only a status check. For 'which LLMs can this machine support', use rocm_command args [\"model\"] or natural_language_plan before answering. For TheRock installs, always let the user choose the install folder. If the user names a folder or prefix, preserve that exact folder with [\"--prefix\",\"PATH\"]; you may call path_exists first to check whether that user-provided folder or its parent exists. If the user asks you to install TheRock/ROCm but has not named a folder, ask for the folder or let the guided setup folder picker collect it; do not invent a hidden default folder and do not request an install command without --prefix. Use rocm_command args [\"install\",\"sdk\",\"--channel\",\"release\",\"--format\",\"wheel\",\"--prefix\",\"PATH\"] only when the user asks you to install it and a folder is known; for a requested build date add [\"--build-date\",\"YYYY-MM-DD\"] and for a requested exact version add [\"--version\",\"VERSION\"]. For config changes, inspect with [\"config\",\"show\"] first when useful, then request config subcommands such as [\"config\",\"set-default-engine\",\"lemonade\"], [\"config\",\"set-default-runtime\",\"RUNTIME_KEY\"], or [\"config\",\"set-telemetry\",\"local\"] only after explaining why. For ComfyUI, use rocm_command with args like [\"comfyui\",\"status\"], [\"comfyui\",\"logs\"], [\"comfyui\",\"install\"], [\"comfyui\",\"start\"], or [\"comfyui\",\"stop\"]. First-time setup is the same thing as bootstrap in ROCm CLI; it is a deterministic ROCm setup flow, not a separate model chat. The built-in local assistant is fixed to qwen, which maps to Qwen3-4B-Instruct-2507-GGUF served by Lemonade with gpu_required. vLLM, SGLang, PyTorch, and Lemonade are general serving engines; inspect or manage them when the user asks about general model serving, but do not switch the built-in assistant away from Lemonade. Use qwen-smoke only for a quick server smoke test. For llama.cpp, use the llama.cpp engine backed by upstream llama-server: request rocm_command args like [\"engines\",\"install\",\"llama.cpp\"] or [\"serve\",\"MODEL.gguf\",\"--engine\",\"llama.cpp\",\"--device\",\"gpu_required\",\"--managed\"]. On native Windows, vLLM and SGLang are skipped; use WSL/Linux for those ROCm GPU engines. For vLLM management, inspect engines first and use [\"engines\",\"install\",\"vllm\"] or [\"serve\",\"MODEL\",\"--engine\",\"vllm\",\"--device\",\"gpu_required\",\"--managed\"] only where the host supports it. Do not invent shell commands and do not request CPU fallback."; const ROCM_CHAT_TOOL_SKILL: &str = include_str!("../../../skills/rocm-cli-assistant/SKILL.md"); fn rocm_chat_tool_system_prompt() -> String { @@ -6999,7 +6982,7 @@ fn validate_chat_install_sdk_tool_call(call: &providers::ChatToolCall) -> Result bail!("local assistant requested unsupported TheRock install format `{format}`"); } if rocm_core::runtime_is_windows() && format != "wheel" { - bail!("local assistant cannot request `{format}` installs on Windows; use pip"); + bail!("local assistant cannot request `{format}` installs on Windows; use wheel"); } let version = json_string(object, "version"); let build_date = json_string(object, "build_date"); @@ -7007,7 +6990,9 @@ fn validate_chat_install_sdk_tool_call(call: &providers::ChatToolCall) -> Result bail!("local assistant cannot request both `version` and `build_date`"); } if format != "wheel" && (version.is_some() || build_date.is_some()) { - bail!("local assistant can only request specific TheRock wheel versions for pip installs"); + bail!( + "local assistant can only request specific TheRock wheel versions for wheel installs" + ); } if let Some(version) = version { therock::RuntimeVersionSelector::version(version)?; @@ -7408,7 +7393,7 @@ fn validate_chat_rocm_command_safety(args: &[String]) -> Result<()> { && chat_cli_arg_value(args, "--format") .is_some_and(|value| !value.eq_ignore_ascii_case("wheel")) { - bail!("local assistant cannot request non-pip ROCm installs on Windows"); + bail!("local assistant cannot request non-wheel ROCm installs on Windows"); } let version = chat_cli_arg_value_checked(args, "--version")?; let build_date = chat_cli_arg_value_checked(args, "--build-date")?; @@ -7419,7 +7404,7 @@ fn validate_chat_rocm_command_safety(args: &[String]) -> Result<()> { let format = chat_cli_arg_value(args, "--format").unwrap_or("wheel"); if !format.eq_ignore_ascii_case("wheel") { bail!( - "local assistant can only request specific TheRock wheel versions for pip installs" + "local assistant can only request specific TheRock wheel versions for wheel installs" ); } } @@ -13044,34 +13029,6 @@ struct UninstallPlan { warnings: Vec, } -fn uninstall(options: UninstallOptions) -> Result<()> { - let paths = AppPaths::discover()?; - let plan = build_uninstall_plan(&paths, &options)?; - print!("{}", render_uninstall_plan(&plan, &options)); - - if plan.actions.is_empty() || options.dry_run { - return Ok(()); - } - - if !options.yes { - if !interactive_terminal() { - bail!("uninstall requires --yes outside an interactive terminal"); - } - if !confirm_uninstall()? { - println!("uninstall cancelled"); - return Ok(()); - } - } - - for entry in &plan.actions { - remove_path(&entry.path) - .with_context(|| format!("failed to remove {}", entry.path.display()))?; - println!("removed {} {}", entry.kind, entry.path.display()); - } - println!("uninstall complete"); - Ok(()) -} - fn build_uninstall_plan(paths: &AppPaths, options: &UninstallOptions) -> Result { let mut plan = UninstallPlan::default(); diff --git a/apps/rocm/src/uninstall.rs b/apps/rocm/src/uninstall.rs new file mode 100644 index 00000000..17f8d6aa --- /dev/null +++ b/apps/rocm/src/uninstall.rs @@ -0,0 +1,42 @@ +//! `rocm uninstall` command handler (EAI-6871 D5). +//! +//! Mechanically relocated from `main.rs` with no behavior change — the +//! `dispatch()` call site stays `uninstall(UninstallOptions { .. })` (re-imported +//! via `use crate::uninstall::uninstall;`). The `UninstallOptions`/`UninstallPlan` +//! types and the plan/render/remove helpers remain in the crate root and are +//! reached through `crate::` (root items are visible to this descendant module). + +use anyhow::{Context, Result, bail}; +use rocm_core::{AppPaths, interactive_terminal}; + +use crate::{ + UninstallOptions, build_uninstall_plan, confirm_uninstall, remove_path, render_uninstall_plan, +}; + +pub(crate) fn uninstall(options: UninstallOptions) -> Result<()> { + let paths = AppPaths::discover()?; + let plan = build_uninstall_plan(&paths, &options)?; + print!("{}", render_uninstall_plan(&plan, &options)); + + if plan.actions.is_empty() || options.dry_run { + return Ok(()); + } + + if !options.yes { + if !interactive_terminal() { + bail!("uninstall requires --yes outside an interactive terminal"); + } + if !confirm_uninstall()? { + println!("uninstall cancelled"); + return Ok(()); + } + } + + for entry in &plan.actions { + remove_path(&entry.path) + .with_context(|| format!("failed to remove {}", entry.path.display()))?; + println!("removed {} {}", entry.kind, entry.path.display()); + } + println!("uninstall complete"); + Ok(()) +} diff --git a/crates/rocm-core/Cargo.toml b/crates/rocm-core/Cargo.toml index cae4794d..d7e9cea2 100644 --- a/crates/rocm-core/Cargo.toml +++ b/crates/rocm-core/Cargo.toml @@ -12,6 +12,7 @@ directories.workspace = true libc.workspace = true serde.workspace = true serde_json.workspace = true +toml = "0.8" ureq = { version = "2.12", features = ["native-certs"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index 304849ab..3ede6318 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -793,6 +793,7 @@ impl AppPaths { &self.data_dir.join("services"), &self.data_dir.join("models"), &self.data_dir.join("runtimes"), + &self.telemetry_state_dir(), ] { fs::create_dir_all(dir) .with_context(|| format!("failed to create {}", dir.display()))?; @@ -878,6 +879,23 @@ impl AppPaths { self.engine_state_dir(engine) .join(format!("{service_id}.json")) } + + /// Directory holding rocm-dash telemetry daemon state (EAI-6871 D6). + /// (G3 rocm-cli maintainer sign-off pending — engineering implementation only.) + pub fn telemetry_state_dir(&self) -> PathBuf { + self.data_dir.join("telemetry") + } + + /// Log file for the rocm-dash telemetry daemon, under the shared logs dir + /// (EAI-6871 D6). (G3 sign-off pending.) + /// + /// Deliberately under the canonical `AppPaths` data root + /// (`~/.rocm/logs/rocmdashd.log`), NOT the legacy standalone rocm-dash XDG + /// state path (`~/.local/state/rocm-dash/`). D6 unifies the dual-dir split + /// onto `~/.rocm`; do not "restore" the old XDG location. + pub fn daemon_log_path(&self) -> PathBuf { + self.data_dir.join("logs").join("rocmdashd.log") + } } fn configured_managed_root_from_config(paths: &AppPaths) -> Option { @@ -3797,6 +3815,203 @@ pub struct RocmCliConfig { pub engines: BTreeMap, #[serde(default)] pub automations: AutomationsConfig, + /// rocm-dash telemetry/dashboard knobs (EAI-6871 D6). Nested as a sub-config + /// so it never collides with the rocm-cli `telemetry` analytics policy on + /// rebase. Every field defaults, so the section is fully optional. + #[serde(default)] + pub dashboard: DashboardConfig, +} + +// ===== rocm-dash dashboard sub-config (EAI-6871 D6) ===== +// +// Additive nesting under the canonical `RocmCliConfig`. The rocm-cli +// `TelemetryConfig { mode }` is an analytics opt-in *policy*; this +// `DashboardConfig` is the operational *spec* (listen address + tick cadence + +// chat endpoint). They are deliberately separate axes and never share a field. +// Pure `with_*()` transforms are scoped to this sub-config only — rocm-cli's own +// config keeps its in-place `&mut` mutation convention untouched. + +fn default_dashboard_listen() -> String { + "unix:/tmp/rocmdashd.sock".to_owned() +} + +fn default_dashboard_connect() -> String { + "unix:/tmp/rocmdashd.sock".to_owned() +} + +fn default_dashboard_theme() -> String { + "default-dark".to_owned() +} + +fn default_gpu_tick_secs() -> f64 { + 1.0 +} + +fn default_discovery_tick_secs() -> f64 { + 5.0 +} + +fn default_instance_tick_secs() -> f64 { + 2.0 +} + +/// Telemetry daemon operational spec. Tick cadences are stored as f64 seconds in +/// the unified JSON config; use the `*_tick()` accessors for `Duration`s. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DashboardDaemonConfig { + /// `unix:/path/to.sock` or `tcp:host:port`. + #[serde(default = "default_dashboard_listen")] + pub listen: String, + /// Optional shared secret. Required for TCP, ignored for Unix sockets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default = "default_gpu_tick_secs")] + pub gpu_tick_secs: f64, + #[serde(default = "default_discovery_tick_secs")] + pub discovery_tick_secs: f64, + #[serde(default = "default_instance_tick_secs")] + pub instance_tick_secs: f64, + /// Watch this directory for new normalized benchmark CSVs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bench_results_dir: Option, +} + +impl Default for DashboardDaemonConfig { + fn default() -> Self { + Self { + listen: default_dashboard_listen(), + token: None, + gpu_tick_secs: default_gpu_tick_secs(), + discovery_tick_secs: default_discovery_tick_secs(), + instance_tick_secs: default_instance_tick_secs(), + bench_results_dir: None, + } + } +} + +impl DashboardDaemonConfig { + pub fn gpu_tick(&self) -> Duration { + Duration::from_secs_f64(self.gpu_tick_secs) + } + + pub fn discovery_tick(&self) -> Duration { + Duration::from_secs_f64(self.discovery_tick_secs) + } + + pub fn instance_tick(&self) -> Duration { + Duration::from_secs_f64(self.instance_tick_secs) + } +} + +/// Dashboard TUI spec. The chat endpoint URL / model / auth-header *name* are +/// plain data; the auth-header *value* (API key) is always env-only and never +/// stored here (AMD gateway invariant). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DashboardTuiConfig { + #[serde(default = "default_dashboard_connect")] + pub connect: String, + #[serde(default = "default_dashboard_theme")] + pub theme: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chat_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chat_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chat_auth_header: Option, +} + +impl Default for DashboardTuiConfig { + fn default() -> Self { + Self { + connect: default_dashboard_connect(), + theme: default_dashboard_theme(), + chat_url: None, + chat_model: None, + chat_auth_header: None, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct DashboardConfig { + #[serde(default)] + pub daemon: DashboardDaemonConfig, + #[serde(default)] + pub tui: DashboardTuiConfig, +} + +impl DashboardConfig { + /// Return a copy with the chat endpoint base URL + model set and the custom + /// auth header cleared (mirrors the rocm-dash `config_with_chat` behavior). + /// Immutable transform — scoped to the dashboard sub-config only. + pub fn with_chat_endpoint( + mut self, + base_url: impl Into, + model: impl Into, + ) -> Self { + self.tui.chat_url = Some(base_url.into()); + self.tui.chat_model = Some(model.into()); + self.tui.chat_auth_header = None; + self + } + + /// Return a copy with the dashboard theme set. + pub fn with_theme(mut self, theme: impl Into) -> Self { + self.tui.theme = theme.into(); + self + } + + /// Return a copy with the telemetry daemon listen address set. + pub fn with_daemon_listen(mut self, listen: impl Into) -> Self { + self.daemon.listen = listen.into(); + self + } +} + +/// Legacy rocm-dash TOML config shape (`~/.config/rocm-dash/config.toml`), +/// parsed for one-shot migration into the unified JSON config. Every field is +/// optional so partial/legacy files parse cleanly; only the carried-forward +/// fields are mirrored. +#[derive(Debug, Default, Deserialize)] +struct LegacyDashToml { + #[serde(default)] + default_engine: Option, + #[serde(default)] + daemon: LegacyDashDaemon, + #[serde(default)] + tui: LegacyDashTui, + #[serde(default)] + engines: BTreeMap, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyDashDaemon { + #[serde(default)] + listen: Option, + #[serde(default)] + token: Option, + #[serde(default)] + gpu_tick: Option, + #[serde(default)] + discovery_tick: Option, + #[serde(default)] + instance_tick: Option, + #[serde(default)] + bench_results_dir: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyDashTui { + #[serde(default)] + connect: Option, + #[serde(default)] + theme: Option, + #[serde(default)] + chat_url: Option, + #[serde(default)] + chat_model: Option, + #[serde(default)] + chat_auth_header: Option, } impl RocmCliConfig { @@ -3874,6 +4089,82 @@ impl RocmCliConfig { .and_then(|cfg| cfg.mode) .unwrap_or(watcher.default_mode) } + + /// Location of the legacy rocm-dash TOML config, honoring `XDG_CONFIG_HOME` + /// (`~/.config/rocm-dash/config.toml` on Linux). + fn legacy_dashboard_toml_path() -> Option { + directories::BaseDirs::new() + .map(|dirs| dirs.config_dir().join("rocm-dash").join("config.toml")) + } + + /// One-shot migration of a legacy rocm-dash `config.toml` into the unified + /// JSON config. If no `config.json` exists yet **and** a legacy TOML is + /// present, its knobs are mapped into `dashboard` (and the canonical + /// `default_engine`/`engines`), `config.json` is written once, and the + /// migrated legacy path is returned so the caller can print a notice. The + /// TOML is left untouched. Returns `Ok(None)` when there is nothing to do + /// (already on the unified config, or no legacy file) — never clobbers an + /// existing `config.json`. + pub fn migrate_legacy_dashboard_toml(paths: &AppPaths) -> Result> { + let Some(legacy) = Self::legacy_dashboard_toml_path() else { + return Ok(None); + }; + Self::migrate_legacy_dashboard_toml_from(paths, &legacy) + } + + /// Testable core of [`migrate_legacy_dashboard_toml`] with an explicit legacy + /// path. Same one-shot, non-clobbering semantics. + pub fn migrate_legacy_dashboard_toml_from( + paths: &AppPaths, + legacy: &Path, + ) -> Result> { + if paths.config_path().is_file() || !legacy.is_file() { + return Ok(None); + } + + let raw = fs::read_to_string(legacy) + .with_context(|| format!("failed to read {}", legacy.display()))?; + let parsed: LegacyDashToml = toml::from_str(&raw) + .with_context(|| format!("failed to parse legacy config {}", legacy.display()))?; + + let mut config = Self::default(); + + // Dashboard-specific knobs map into the new sub-config. + let d = &parsed.daemon; + if let Some(v) = &d.listen { + config.dashboard.daemon.listen = v.clone(); + } + config.dashboard.daemon.token = d.token.clone(); + if let Some(v) = d.gpu_tick { + config.dashboard.daemon.gpu_tick_secs = v; + } + if let Some(v) = d.discovery_tick { + config.dashboard.daemon.discovery_tick_secs = v; + } + if let Some(v) = d.instance_tick { + config.dashboard.daemon.instance_tick_secs = v; + } + config.dashboard.daemon.bench_results_dir = d.bench_results_dir.clone(); + + let t = &parsed.tui; + if let Some(v) = &t.connect { + config.dashboard.tui.connect = v.clone(); + } + if let Some(v) = &t.theme { + config.dashboard.tui.theme = v.clone(); + } + config.dashboard.tui.chat_url = t.chat_url.clone(); + config.dashboard.tui.chat_model = t.chat_model.clone(); + config.dashboard.tui.chat_auth_header = t.chat_auth_header.clone(); + + // `default_engine` / `engines` map onto the canonical rocm-cli fields + // (identical shape) — not a second source of truth inside `dashboard`. + config.default_engine = parsed.default_engine.clone(); + config.engines = parsed.engines.clone(); + + config.save(paths)?; + Ok(Some(legacy.to_path_buf())) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -7023,4 +7314,162 @@ Class Name: Display } Ok(()) } + + // ===== D6 dashboard sub-config + migration (EAI-6871) ===== + + #[test] + fn dashboard_config_defaults_and_json_round_trip() { + let cfg = DashboardConfig::default(); + assert_eq!(cfg.daemon.listen, "unix:/tmp/rocmdashd.sock"); + assert_eq!(cfg.daemon.gpu_tick_secs, 1.0); + assert_eq!(cfg.daemon.discovery_tick_secs, 5.0); + assert_eq!(cfg.daemon.instance_tick_secs, 2.0); + assert_eq!(cfg.tui.theme, "default-dark"); + assert_eq!(cfg.tui.chat_url, None); + + let json = serde_json::to_string(&cfg).unwrap(); + let back: DashboardConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn rocm_cli_config_dashboard_section_is_optional() { + // A config.json with no `dashboard` key parses to the default sub-config. + let json = r#"{"default_engine":"vllm"}"#; + let cfg: RocmCliConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.default_engine.as_deref(), Some("vllm")); + assert_eq!(cfg.dashboard, DashboardConfig::default()); + } + + #[test] + fn dashboard_with_transforms_are_immutable_and_scoped() { + let base = DashboardConfig::default(); + let chat = base + .clone() + .with_chat_endpoint("http://127.0.0.1:8000", "llama-3.1-8b"); + // Original is untouched (immutable transform). + assert_eq!(base.tui.chat_url, None); + assert_eq!(chat.tui.chat_url.as_deref(), Some("http://127.0.0.1:8000")); + assert_eq!(chat.tui.chat_model.as_deref(), Some("llama-3.1-8b")); + assert_eq!(chat.tui.chat_auth_header, None); + + let themed = base.clone().with_theme("nord"); + assert_eq!(base.tui.theme, "default-dark"); + assert_eq!(themed.tui.theme, "nord"); + + let relisten = base.clone().with_daemon_listen("tcp:127.0.0.1:9000"); + assert_eq!(base.daemon.listen, "unix:/tmp/rocmdashd.sock"); + assert_eq!(relisten.daemon.listen, "tcp:127.0.0.1:9000"); + } + + #[test] + fn dashboard_daemon_tick_accessors_map_secs_to_duration() { + let d = DashboardDaemonConfig { + gpu_tick_secs: 0.5, + discovery_tick_secs: 10.0, + instance_tick_secs: 3.0, + ..Default::default() + }; + assert_eq!(d.gpu_tick(), Duration::from_secs_f64(0.5)); + assert_eq!(d.discovery_tick(), Duration::from_secs(10)); + assert_eq!(d.instance_tick(), Duration::from_secs(3)); + } + + #[test] + fn app_paths_expose_telemetry_and_daemon_log_paths() -> Result<()> { + let (root, paths) = temp_app_paths("telemetry-paths"); + assert_eq!( + paths.telemetry_state_dir(), + paths.data_dir.join("telemetry") + ); + assert_eq!( + paths.daemon_log_path(), + paths.data_dir.join("logs").join("rocmdashd.log") + ); + // ensure() creates the telemetry state dir alongside the others. + paths.ensure()?; + assert!(paths.telemetry_state_dir().is_dir()); + let _ = fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn migrate_legacy_dashboard_toml_maps_knobs_and_is_one_shot() -> Result<()> { + let (root, paths) = temp_app_paths("migrate-dash"); + paths.ensure()?; + let legacy = root.join("legacy-config.toml"); + fs::write( + &legacy, + r#" +default_engine = "vllm" + +[daemon] +listen = "unix:/tmp/custom.sock" +token = "secret" +gpu_tick = 0.5 +discovery_tick = 10 +instance_tick = 3 + +[tui] +connect = "unix:/tmp/custom.sock" +theme = "nord" +chat_url = "http://127.0.0.1:8000" +chat_model = "llama-3.1-8b" + +[engines.vllm] +preferred_env_id = "env-1" +last_installed_runtime_id = "therock-release" +"#, + )?; + + // First migration writes config.json once and reports the legacy path. + let migrated = RocmCliConfig::migrate_legacy_dashboard_toml_from(&paths, &legacy)?; + assert_eq!(migrated.as_deref(), Some(legacy.as_path())); + assert!(paths.config_path().is_file()); + // The legacy TOML is left untouched. + assert!(legacy.is_file()); + + // The written config maps every knob into the dashboard sub-config and + // the canonical engine fields. + let loaded = RocmCliConfig::load(&paths)?; + assert_eq!(loaded.dashboard.daemon.listen, "unix:/tmp/custom.sock"); + assert_eq!(loaded.dashboard.daemon.token.as_deref(), Some("secret")); + assert_eq!(loaded.dashboard.daemon.gpu_tick_secs, 0.5); + assert_eq!(loaded.dashboard.daemon.discovery_tick_secs, 10.0); + assert_eq!(loaded.dashboard.daemon.instance_tick_secs, 3.0); + assert_eq!(loaded.dashboard.tui.connect, "unix:/tmp/custom.sock"); + assert_eq!(loaded.dashboard.tui.theme, "nord"); + assert_eq!( + loaded.dashboard.tui.chat_url.as_deref(), + Some("http://127.0.0.1:8000") + ); + assert_eq!( + loaded.dashboard.tui.chat_model.as_deref(), + Some("llama-3.1-8b") + ); + assert_eq!(loaded.default_engine.as_deref(), Some("vllm")); + assert_eq!( + loaded.engines["vllm"].preferred_env_id.as_deref(), + Some("env-1") + ); + + // Second call is a no-op (config.json already exists — never clobbers). + let again = RocmCliConfig::migrate_legacy_dashboard_toml_from(&paths, &legacy)?; + assert_eq!(again, None); + + let _ = fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn migrate_legacy_dashboard_toml_without_legacy_is_noop() -> Result<()> { + let (root, paths) = temp_app_paths("migrate-dash-absent"); + paths.ensure()?; + let legacy = root.join("does-not-exist.toml"); + let migrated = RocmCliConfig::migrate_legacy_dashboard_toml_from(&paths, &legacy)?; + assert_eq!(migrated, None); + assert!(!paths.config_path().is_file()); + let _ = fs::remove_dir_all(&root); + Ok(()) + } } diff --git a/crates/rocm-dash-collectors/Cargo.toml b/crates/rocm-dash-collectors/Cargo.toml new file mode 100644 index 00000000..8bcd9025 --- /dev/null +++ b/crates/rocm-dash-collectors/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "rocm-dash-collectors" +version.workspace = true +# Transplanted from rocm-dash (EAI-6871); migrated to the workspace edition (2024). +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +rocm-dash-core = { path = "../rocm-dash-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +csv = "1" +regex = "1" +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +sysinfo = "0.34" +bollard = "0.17" +tokio = { version = "1", features = ["full"] } +# HTTP partition: collectors keep reqwest 0.12 (vLLM Prometheus scrape, plain +# localhost HTTP). ureq stays the rocm-cli sync default elsewhere. +reqwest = { version = "0.12", default-features = false } + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/crates/rocm-dash-collectors/src/amd_smi.rs b/crates/rocm-dash-collectors/src/amd_smi.rs new file mode 100644 index 00000000..4ab77b28 --- /dev/null +++ b/crates/rocm-dash-collectors/src/amd_smi.rs @@ -0,0 +1,491 @@ +//! amd-smi subprocess + JSON parse. +//! +//! Field paths and the KFD pre-flight check are vendored from the TypeScript +//! `AmdSmiProvider` in instinct-dash. See `../wiki/entities/amd-smi.md`. + +use std::io; +use std::time::Duration; + +use rocm_dash_core::metrics::{GpuMetrics, GpuSystemInfo}; +use rocm_dash_core::partition::{ComputePartitionMode, MemoryPartitionMode}; +use rocm_dash_core::traits::GpuProcess; +use serde_json::Value; +use tokio::process::Command; +use tokio::time::timeout; +use tracing::warn; + +const KFD_DEVICE: &str = "/dev/kfd"; +const DETECT_TIMEOUT: Duration = Duration::from_secs(5); +const RUN_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug)] +pub struct AmdSmiCollector { + binary: String, +} + +impl AmdSmiCollector { + /// Returns `Some` only if `/dev/kfd` is readable AND `amd-smi version` succeeds. + /// + /// The KFD pre-flight is mandatory: without it, `amd-smi` blocks in + /// uninterruptible kernel sleep (D-state) that no signal can escape. + pub async fn detect() -> Option { + if !kfd_accessible() { + return None; + } + let me = Self { + binary: "amd-smi".into(), + }; + match timeout(DETECT_TIMEOUT, me.run(&["version"])).await { + Ok(Ok(_)) => Some(me), + Ok(Err(e)) => { + warn!(error = %e, "amd-smi present but `version` failed"); + None + } + Err(_) => { + warn!("amd-smi `version` timed out"); + None + } + } + } + + pub async fn metrics(&self) -> io::Result> { + let v = self.run_json(&["metric", "--json"]).await?; + Ok(parse_metrics(&v)) + } + + /// Per-process GPU VRAM usage from `amd-smi process --json`. + /// + /// Inherent async (mirrors [`metrics`]); the runner uses this directly + /// rather than the sync `GpuCollector::processes` trait method. Parsing is + /// delegated to the defensive pure [`parse_processes`]; see its docs for + /// the assumed schema and version-variance handling. + pub async fn processes(&self) -> io::Result> { + let v = self.run_json(&["process", "--json"]).await?; + Ok(parse_processes(&v)) + } + + /// Best-effort system info — each sub-call is tolerated independently + /// (matches the `Promise.allSettled` pattern in instinct-dash). + pub async fn system_info(&self) -> GpuSystemInfo { + let (ver, stat, topo) = tokio::join!( + self.run_json(&["version", "--json"]), + self.run_json(&["static", "--json"]), + self.run_json(&["topology", "--json"]), + ); + parse_system_info(ver.ok(), stat.ok(), topo.ok()) + } + + async fn run(&self, args: &[&str]) -> io::Result { + let out = Command::new(&self.binary).args(args).output().await?; + if !out.status.success() { + return Err(io::Error::other(format!( + "amd-smi {args:?} exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } + + async fn run_json(&self, args: &[&str]) -> io::Result { + let s = timeout(RUN_TIMEOUT, self.run(args)) + .await + .map_err(|_| io::Error::other(format!("amd-smi {args:?} timed out")))??; + serde_json::from_str(&s).map_err(|e| io::Error::other(format!("parse: {e}"))) + } +} + +fn kfd_accessible() -> bool { + std::fs::OpenOptions::new() + .read(true) + .open(KFD_DEVICE) + .is_ok() +} + +fn val_u64(v: Option<&Value>) -> Option { + v?.as_u64() + .or_else(|| v?.as_f64().map(|x| x.round() as u64)) +} + +fn val_f32(v: Option<&Value>) -> Option { + v?.as_f64().map(|x| x as f32) +} + +fn nested<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Value> { + path.iter().try_fold(v, |cur, k| cur.get(*k)) +} + +pub fn parse_metrics(v: &Value) -> Vec { + let Some(gpus) = v.get("gpu_data").and_then(|x| x.as_array()) else { + return Vec::new(); + }; + gpus.iter() + .map(|g| { + let id = g.get("gpu").and_then(|x| x.as_u64()).unwrap_or(0); + // Prefer hotspot — edge is N/A on MI300X SR-IOV. + let temperature_c = val_f32(nested(g, &["temperature", "hotspot", "value"])) + .or_else(|| val_f32(nested(g, &["temperature", "edge", "value"]))) + .unwrap_or(0.0); + GpuMetrics { + device_id: format!("gpu-{id}"), + vram_used_mb: val_u64(nested(g, &["mem_usage", "used_vram", "value"])).unwrap_or(0), + vram_total_mb: val_u64(nested(g, &["mem_usage", "total_vram", "value"])) + .unwrap_or(0), + gpu_utilization_pct: val_f32(nested(g, &["usage", "gfx_activity", "value"])) + .unwrap_or(0.0), + temperature_c, + power_w: val_f32(nested(g, &["power", "socket_power", "value"])).unwrap_or(0.0), + clock_mhz: val_f32(nested(g, &["clock", "gfx_0", "clk", "value"])), + } + }) + .collect() +} + +/// Candidate `(parent, child)` JSON paths for a process's VRAM figure, in +/// priority order. amd-smi naming varies by version, so we probe several. +const VRAM_PATHS: &[(&str, &str)] = &[ + ("memory_usage", "vram_mem"), + ("mem_usage", "vram_mem"), + ("memory_usage", "vram_usage"), + ("mem_usage", "vram_usage"), +]; + +/// Convert a memory figure to MB given its unit string. Unrecognized or absent +/// units are treated as MB (the unit amd-smi reports for the structured +/// `{value,unit}` form). Case-insensitive; binary multiples (KiB/GiB) alias the +/// decimal-looking unit names amd-smi emits. +fn mem_unit_to_mb(value: u64, unit: &str) -> u64 { + match unit.to_ascii_uppercase().as_str() { + "B" | "BYTES" => value / (1024 * 1024), + "KB" | "KIB" => value / 1024, + "GB" | "GIB" => value * 1024, + // "MB" / "MIB" / unknown → already MB. + _ => value, + } +} + +/// Extract a u64 from a value that is either a raw number or a `{value}` wrapper. +fn u64_or_wrapped(v: &Value) -> Option { + val_u64(Some(v)).or_else(|| val_u64(v.get("value"))) +} + +/// Resolve a process entry's VRAM in MB across schema variants. A raw numeric +/// VRAM (no `{value,unit}` wrapper) is assumed to be **bytes** — the form +/// amd-smi's `vram_mem` field takes when emitted without a unit annotation. +/// Absent → 0. +fn process_vram_mb(p: &Value) -> u64 { + let raw = VRAM_PATHS + .iter() + .find_map(|(parent, child)| p.get(parent).and_then(|m| m.get(child))); + let Some(v) = raw else { return 0 }; + if v.is_object() { + let value = val_u64(v.get("value")).unwrap_or(0); + let unit = v.get("unit").and_then(|x| x.as_str()).unwrap_or("MB"); + mem_unit_to_mb(value, unit) + } else { + // Raw number with no unit → bytes (documented assumption). + mem_unit_to_mb(val_u64(Some(v)).unwrap_or(0), "B") + } +} + +/// Parse `amd-smi process --json` into per-process VRAM records. +/// +/// **Defensive across amd-smi versions.** Assumed schema (GPU-indexed): +/// - Top level: `{ "gpu_data": [ , ... ] }` OR a bare `[ , ... ]`. +/// - Each ``: `{ "gpu": , "process_list" | "processes": [ , ... ] }`. +/// - Each ``: flat, or wrapped in `{ "process_info": { ... } }`. +/// - PID: `pid` as a raw number or a `{ "value": N }` wrapper. +/// - VRAM: `memory_usage.vram_mem` (or `mem_usage.vram_mem` / +/// `*.vram_usage`); a `{value,unit}` wrapper (unit normalized to MB) or a +/// raw number (assumed bytes). See [`process_vram_mb`]. +/// +/// Entries with no resolvable PID are skipped; VRAM defaults to 0 when absent. +/// Never panics on shape mismatch — unknown shapes yield an empty `Vec`. +pub fn parse_processes(v: &Value) -> Vec { + let entries: Vec<&Value> = if let Some(arr) = v.get("gpu_data").and_then(|x| x.as_array()) { + arr.iter().collect() + } else if let Some(arr) = v.as_array() { + arr.iter().collect() + } else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for g in entries { + let id = g.get("gpu").and_then(|x| x.as_u64()).unwrap_or(0); + let device_id = format!("gpu-{id}"); + let plist = g + .get("process_list") + .and_then(|x| x.as_array()) + .or_else(|| g.get("processes").and_then(|x| x.as_array())); + let Some(plist) = plist else { continue }; + for item in plist { + let p = item.get("process_info").unwrap_or(item); + let Some(pid) = p.get("pid").and_then(u64_or_wrapped) else { + continue; + }; + out.push(GpuProcess { + pid: pid as u32, + device_id: device_id.clone(), + vram_used_mb: process_vram_mb(p), + }); + } + } + out +} + +pub fn parse_system_info( + ver: Option, + stat: Option, + topo: Option, +) -> GpuSystemInfo { + let mut info = GpuSystemInfo::default(); + + if let Some(v) = ver { + let item = match v { + Value::Array(mut a) if !a.is_empty() => a.swap_remove(0), + other => other, + }; + info.rocm_version = item + .get("rocm_version") + .or_else(|| item.get("ROCm_version")) + .and_then(|x| x.as_str()) + .map(str::to_owned); + info.driver_version = item + .get("amdgpu_version") + .or_else(|| item.get("driver")) + .and_then(|x| x.as_str()) + .map(str::to_owned); + } + + if let Some(s) = stat + && let Some(gpus) = s.get("gpu_data").and_then(|x| x.as_array()) + { + info.physical_gpu_count = gpus.len() as u32; + info.logical_gpu_count = info.physical_gpu_count; + if let Some(first) = gpus.first() { + info.gpu_model = nested(first, &["asic", "market_name"]) + .and_then(|x| x.as_str()) + .filter(|s| *s != "N/A") + .or_else(|| nested(first, &["board", "product_name"]).and_then(|x| x.as_str())) + .map(str::to_owned) + .unwrap_or_else(|| "Unknown".into()); + } + } + + if let Some(t) = topo { + info.partition_mode = parse_compute( + t.get("partition_mode") + .or_else(|| t.get("compute_partition_mode")), + ); + info.memory_partition_mode = parse_memory(t.get("memory_partition_mode")); + info.compute_partition_mode = parse_compute( + t.get("compute_partition_mode") + .or_else(|| t.get("partition_mode")), + ); + } + + info +} + +fn parse_compute(v: Option<&Value>) -> ComputePartitionMode { + match v + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_ascii_uppercase() + .as_str() + { + "SPX" => ComputePartitionMode::Spx, + "DPX" => ComputePartitionMode::Dpx, + "QPX" => ComputePartitionMode::Qpx, + "CPX" => ComputePartitionMode::Cpx, + _ => ComputePartitionMode::Unknown, + } +} + +fn parse_memory(v: Option<&Value>) -> MemoryPartitionMode { + match v + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_ascii_uppercase() + .as_str() + { + "NPS1" => MemoryPartitionMode::Nps1, + "NPS2" => MemoryPartitionMode::Nps2, + "NPS4" => MemoryPartitionMode::Nps4, + _ => MemoryPartitionMode::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_METRIC: &str = r#"{ + "gpu_data": [ + { + "gpu": 0, + "mem_usage": { "used_vram": { "value": 1234, "unit": "MB" }, + "total_vram": { "value": 196608, "unit": "MB" } }, + "usage": { "gfx_activity": { "value": 42.5, "unit": "%" } }, + "temperature": { "hotspot": { "value": 68.0, "unit": "C" }, + "edge": { "value": null } }, + "power": { "socket_power": { "value": 410.5, "unit": "W" } } + } + ] + }"#; + + #[test] + fn parses_a_single_gpu_metric_block() { + let v: Value = serde_json::from_str(SAMPLE_METRIC).unwrap(); + let m = parse_metrics(&v); + assert_eq!(m.len(), 1); + let g = &m[0]; + assert_eq!(g.device_id, "gpu-0"); + assert_eq!(g.vram_used_mb, 1234); + assert_eq!(g.vram_total_mb, 196608); + assert!((g.gpu_utilization_pct - 42.5).abs() < 0.01); + assert!((g.temperature_c - 68.0).abs() < 0.01); + assert!((g.power_w - 410.5).abs() < 0.01); + } + + #[test] + fn missing_fields_default_safely() { + let v: Value = serde_json::from_str(r#"{ "gpu_data": [ { "gpu": 7 } ] }"#).unwrap(); + let m = parse_metrics(&v); + assert_eq!(m[0].device_id, "gpu-7"); + assert_eq!(m[0].vram_used_mb, 0); + assert_eq!(m[0].temperature_c, 0.0); + } + + #[test] + fn temperature_falls_back_to_edge_when_no_hotspot() { + let v: Value = serde_json::from_str( + r#"{ "gpu_data": [ { "gpu": 0, "temperature": { "edge": { "value": 55.0 } } } ] }"#, + ) + .unwrap(); + let m = parse_metrics(&v); + assert!((m[0].temperature_c - 55.0).abs() < 0.01); + } + + #[test] + fn partition_modes_parse_case_insensitive() { + let topo = serde_json::json!({ + "partition_mode": "spx", + "memory_partition_mode": "NPS4", + "compute_partition_mode": "QPX" + }); + let info = parse_system_info(None, None, Some(topo)); + assert_eq!(info.partition_mode, ComputePartitionMode::Spx); + assert_eq!(info.memory_partition_mode, MemoryPartitionMode::Nps4); + assert_eq!(info.compute_partition_mode, ComputePartitionMode::Qpx); + } + + #[test] + fn system_info_uses_market_name_when_available() { + let stat = serde_json::json!({ + "gpu_data": [ + { "asic": { "market_name": "Instinct MI300X" } }, + { "asic": { "market_name": "Instinct MI300X" } } + ] + }); + let info = parse_system_info(None, Some(stat), None); + assert_eq!(info.physical_gpu_count, 2); + assert_eq!(info.gpu_model, "Instinct MI300X"); + } + + // --- process parsing ------------------------------------------------- + + // gpu_data wrapper + process_list + process_info wrapper + + // memory_usage.vram_mem {value, unit:"MB"}; second entry has no pid. + const SAMPLE_PROCESS_WRAPPED: &str = r#"{ + "gpu_data": [ + { + "gpu": 0, + "process_list": [ + { "process_info": { "pid": 12345, "name": "vllm", + "memory_usage": { "vram_mem": { "value": 4096, "unit": "MB" } } } }, + { "process_info": { "name": "no-pid", + "memory_usage": { "vram_mem": { "value": 100, "unit": "MB" } } } } + ] + } + ] + }"#; + + // bare top-level array + flat process (no process_info) + raw-bytes vram_mem. + const SAMPLE_PROCESS_BARE: &str = r#"[ + { "gpu": 3, "processes": [ { "pid": 999, "mem_usage": { "vram_mem": 2147483648 } } ] } + ]"#; + + #[test] + fn parses_wrapped_process_list_value_unit_mb() { + let v: Value = serde_json::from_str(SAMPLE_PROCESS_WRAPPED).unwrap(); + let procs = parse_processes(&v); + assert_eq!(procs.len(), 1, "the no-pid entry must be skipped"); + assert_eq!(procs[0].pid, 12345); + assert_eq!(procs[0].device_id, "gpu-0"); + assert_eq!(procs[0].vram_used_mb, 4096); + } + + #[test] + fn parses_bare_array_flat_process_raw_bytes() { + let v: Value = serde_json::from_str(SAMPLE_PROCESS_BARE).unwrap(); + let procs = parse_processes(&v); + assert_eq!(procs.len(), 1); + assert_eq!(procs[0].pid, 999); + assert_eq!(procs[0].device_id, "gpu-3"); + // 2 GiB raw bytes → 2048 MB. + assert_eq!(procs[0].vram_used_mb, 2048); + } + + #[test] + fn vram_unit_normalization_bytes_and_mb() { + // {value, unit:"B"}: 1 GiB → 1024 MB. + let v: Value = serde_json::from_str( + r#"{ "gpu_data": [ { "gpu": 0, "process_list": [ + { "pid": 1, "memory_usage": { "vram_mem": { "value": 1073741824, "unit": "B" } } } + ] } ] }"#, + ) + .unwrap(); + assert_eq!(parse_processes(&v)[0].vram_used_mb, 1024); + + // {value, unit:"MB"} stays as-is. + let v: Value = serde_json::from_str( + r#"{ "gpu_data": [ { "gpu": 0, "process_list": [ + { "pid": 2, "memory_usage": { "vram_mem": { "value": 512, "unit": "MB" } } } + ] } ] }"#, + ) + .unwrap(); + assert_eq!(parse_processes(&v)[0].vram_used_mb, 512); + } + + #[test] + fn empty_and_garbage_yield_no_processes() { + assert!(parse_processes(&serde_json::json!({})).is_empty()); + assert!(parse_processes(&serde_json::json!([])).is_empty()); + assert!(parse_processes(&serde_json::json!("garbage")).is_empty()); + assert!(parse_processes(&serde_json::json!(42)).is_empty()); + // gpu entry present but no process list. + assert!(parse_processes(&serde_json::json!({ "gpu_data": [ { "gpu": 0 } ] })).is_empty()); + } + + #[test] + fn process_without_pid_is_skipped() { + let v: Value = serde_json::from_str( + r#"{ "gpu_data": [ { "gpu": 0, "process_list": [ + { "memory_usage": { "vram_mem": { "value": 100, "unit": "MB" } } } + ] } ] }"#, + ) + .unwrap(); + assert!(parse_processes(&v).is_empty()); + } + + #[tokio::test] + #[ignore = "requires a real AMD GPU + amd-smi; run manually on hardware"] + async fn live_processes_no_panic() { + if let Some(c) = AmdSmiCollector::detect().await { + // Either Ok or Err is acceptable; the contract is "does not panic". + let _ = c.processes().await; + } + } +} diff --git a/crates/rocm-dash-collectors/src/bench_tail.rs b/crates/rocm-dash-collectors/src/bench_tail.rs new file mode 100644 index 00000000..d8135bc2 --- /dev/null +++ b/crates/rocm-dash-collectors/src/bench_tail.rs @@ -0,0 +1,155 @@ +//! Tail a normalized benchmark CSV from instinct-agent-bench. +//! +//! Strategy: every `drain()` re-reads the entire file and yields rows we haven't +//! returned yet. Simpler and correct under truncate-rewrite rotation (no need +//! to chase mtime / inode / offset heuristics). Benchmark CSVs are small — +//! tens to thousands of rows — so the cost is fine for our cadence. + +use std::fs::File; +use std::io::BufReader; +use std::path::PathBuf; + +use rocm_dash_core::bench_schema::BenchmarkRow; +use rocm_dash_core::traits::{BenchTailer, CollectorError, Result}; + +pub struct CsvBenchTailer { + path: PathBuf, + rows_seen: usize, +} + +impl CsvBenchTailer { + pub fn new(path: PathBuf) -> Self { + Self { path, rows_seen: 0 } + } +} + +impl BenchTailer for CsvBenchTailer { + fn name(&self) -> &'static str { + "csv-bench-tailer" + } + + fn drain(&mut self) -> Result> { + let file = File::open(&self.path)?; + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(BufReader::new(file)); + let header = rdr.headers().map_err(map_csv_err)?.clone(); + + let mut all: Vec = Vec::new(); + for rec in rdr.records() { + let rec = rec.map_err(map_csv_err)?; + let row: BenchmarkRow = rec.deserialize(Some(&header)).map_err(map_csv_err)?; + all.push(row); + } + + // Rotation (or row deletion) — file got shorter than what we last saw. + // Reset and re-emit everything. + if all.len() < self.rows_seen { + self.rows_seen = 0; + } + let new_rows: Vec<_> = all.into_iter().skip(self.rows_seen).collect(); + self.rows_seen += new_rows.len(); + Ok(new_rows) + } +} + +fn map_csv_err(e: csv::Error) -> CollectorError { + CollectorError::Parse(e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write(path: &std::path::Path, s: &str) { + std::fs::write(path, s).unwrap(); + } + + fn append(path: &std::path::Path, s: &str) { + let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + f.write_all(s.as_bytes()).unwrap(); + } + + const HEADER: &str = "cell,run,wall_s,n_requests,main_prompt_n,prompt_tokens,prompt_tps,\ + completion_tokens,gen_tps,max_running_reqs,max_waiting_reqs,out_chars,rc,\ + assertion_pass,assertion_fail_count,assertion_summary,quality_score,\ + judge_pass_fail,judge_model,model,endpoint,tp,pp,dtype,max_num_seqs,\ + attention_backend,concurrency,extra_args,safety_pass,safety_violations\n"; + const ROW1: &str = "O-arch,1,42.3,8,512,4096,1240.5,2048,68.2,8,2,8192,0,true,0,all-pass,\ + 4.5,pass,claude-sonnet-4-6,deepseek-r1,http://vllm:8000,8,1,fp8,32,triton,1,,true,0\n"; + const ROW2: &str = "B-code,1,55.8,4,1024,3200,980.2,1600,52.4,4,0,6400,0,false,2,assert.miss,\ + 2.1,fail,claude-sonnet-4-6,llama-3.1-70b,http://vllm:8000,4,1,fp16,16,flash,1,,true,0\n"; + + #[test] + fn drain_returns_only_appended_rows() { + let dir = tempdir(); + let path = dir.join("results.csv"); + write(&path, &format!("{HEADER}{ROW1}")); + let mut tailer = CsvBenchTailer::new(path.clone()); + + let first = tailer.drain().unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].cell, "O-arch"); + assert_eq!(first[0].prompt_tps, Some(1240.5)); + assert_eq!(first[0].gen_tps, Some(68.2)); + assert_eq!(first[0].assertion_pass, Some(true)); + + // Idempotent: no new rows → empty. + assert!(tailer.drain().unwrap().is_empty()); + + // Append one row → one row returned. + append(&path, ROW2); + let next = tailer.drain().unwrap(); + assert_eq!(next.len(), 1); + assert_eq!(next[0].cell, "B-code"); + assert_eq!(next[0].assertion_pass, Some(false)); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn rotation_truncate_rewrite_re_emits_rows() { + let dir = tempdir(); + let path = dir.join("results.csv"); + write(&path, &format!("{HEADER}{ROW1}{ROW2}")); + let mut tailer = CsvBenchTailer::new(path.clone()); + assert_eq!(tailer.drain().unwrap().len(), 2); + + // Rotation: rewrite with just ROW2 → rows_seen (2) > new total (1) → reset + re-emit. + write(&path, &format!("{HEADER}{ROW2}")); + let after = tailer.drain().unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].cell, "B-code"); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_30col_fields_default_safely() { + // A minimal header missing many optional columns shouldn't error. + let dir = tempdir(); + let path = dir.join("results.csv"); + write(&path, "cell,run\nO-arch,7\n"); + let mut t = CsvBenchTailer::new(path.clone()); + let rows = t.drain().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cell, "O-arch"); + assert_eq!(rows[0].run, 7); + assert_eq!(rows[0].prompt_tps, None); + + let _ = std::fs::remove_dir_all(dir); + } + + fn tempdir() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + let pid = std::process::id(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!("rocm-dash-test-{pid}-{ts}")); + std::fs::create_dir_all(&p).unwrap(); + p + } +} diff --git a/crates/rocm-dash-collectors/src/cgroup.rs b/crates/rocm-dash-collectors/src/cgroup.rs new file mode 100644 index 00000000..1021e809 --- /dev/null +++ b/crates/rocm-dash-collectors/src/cgroup.rs @@ -0,0 +1,123 @@ +//! PID → docker container id resolution via `/proc//cgroup`. +//! +//! amd-smi reports the **host** PIDs of GPU compute processes. For a +//! tensor-parallel vLLM server the workers are descendants of the container +//! init PID, so a naive `pid == container.pid` equality fails. The robust join +//! is: GPU-process host PID → read `/proc//cgroup` → extract the docker +//! 64-hex container id → match `Instance.container_id`. +//! +//! The parser is pure (string in, `Option` out) and fully testable on a +//! no-container box; only [`container_id_for_pid`] touches `/proc` and it is +//! `cfg`-gated for linux with a non-linux stub. std::fs only — no bollard, +//! no async. + +/// Whether `c` is a lowercase hex digit (`0-9a-f`). Docker/containerd ids are +/// lowercase hex; uppercase and other characters act as token delimiters. +fn is_lower_hex(c: char) -> bool { + c.is_ascii_digit() || matches!(c, 'a'..='f') +} + +/// Extract a docker/containerd 64-hex container id from cgroup file contents. +/// +/// Handles the common layouts by scanning for the first run of exactly 64 +/// lowercase-hex characters — the affixes (`docker-`, `cri-containerd-`, +/// `.scope`, path separators) are non-hex and delimit the token: +/// - cgroup v2: `0::/system.slice/docker-<64hex>.scope` +/// - cgroup v1: `.../docker/<64hex>` (any controller line) +/// - k8s/containerd: `cri-containerd-<64hex>.scope` or `kubepods/.../<64hex>` +/// +/// Returns `None` when no 64-hex token is present (e.g. a non-container +/// `0::/user.slice/...` line). The returned id is the full 64-char lowercase +/// hex, directly comparable to docker discovery's `Instance.container_id`. +pub fn parse_container_id_from_cgroup(contents: &str) -> Option { + contents + .split(|c: char| !is_lower_hex(c)) + .find(|tok| tok.len() == 64) + .map(str::to_string) +} + +/// Resolve the docker container id owning host process `pid` by reading +/// `/proc//cgroup`. Returns `None` on any read error (process gone, +/// permission denied) or when the cgroup names no container — never panics. +#[cfg(target_os = "linux")] +pub fn container_id_for_pid(pid: u32) -> Option { + let contents = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).ok()?; + parse_container_id_from_cgroup(&contents) +} + +/// Non-linux stub: there is no `/proc//cgroup`, so attribution is never +/// available off linux. Always `None`. +#[cfg(not(target_os = "linux"))] +pub fn container_id_for_pid(_pid: u32) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + // A clean 64-char lowercase-hex id (16-char block × 4). + const ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn assert_is_64_hex(s: &str) { + assert_eq!(s.len(), 64, "container id must be 64 chars"); + assert!( + s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "container id must be lowercase hex" + ); + } + + #[test] + fn extracts_from_cgroup_v2_docker_scope() { + let line = format!("0::/system.slice/docker-{ID}.scope\n"); + let got = parse_container_id_from_cgroup(&line).unwrap(); + assert_eq!(got, ID); + assert_is_64_hex(&got); + } + + #[test] + fn extracts_from_cgroup_v1_docker_path() { + let contents = format!("12:devices:/docker/{ID}\n11:cpuset:/docker/{ID}\n"); + let got = parse_container_id_from_cgroup(&contents).unwrap(); + assert_eq!(got, ID); + assert_is_64_hex(&got); + } + + #[test] + fn extracts_from_k8s_cri_containerd_scope() { + let line = format!( + "0::/kubepods.slice/kubepods-burstable.slice/\ + kubepods-burstable-podabc.slice/cri-containerd-{ID}.scope\n" + ); + let got = parse_container_id_from_cgroup(&line).unwrap(); + assert_eq!(got, ID); + assert_is_64_hex(&got); + } + + #[test] + fn extracts_from_k8s_kubepods_path() { + let line = format!("11:memory:/kubepods/besteffort/pod1234-5678/{ID}\n"); + let got = parse_container_id_from_cgroup(&line).unwrap(); + assert_eq!(got, ID); + } + + #[test] + fn non_container_cgroup_returns_none() { + let contents = "0::/user.slice/user-1000.slice/session-2.scope\n\ + 12:devices:/init.scope\n"; + assert_eq!(parse_container_id_from_cgroup(contents), None); + // A 32-hex pod uuid fragment must not false-match a 64-hex id. + assert_eq!( + parse_container_id_from_cgroup("0::/short/0123456789abcdef0123456789abcdef\n"), + None + ); + } + + #[test] + fn container_id_for_pid_unreadable_is_none_no_panic() { + // A PID that cannot exist → unreadable /proc path → None (no panic). + // On non-linux this exercises the stub; on linux, the read error path. + assert_eq!(container_id_for_pid(u32::MAX), None); + } +} diff --git a/crates/rocm-dash-collectors/src/docker.rs b/crates/rocm-dash-collectors/src/docker.rs new file mode 100644 index 00000000..089aab50 --- /dev/null +++ b/crates/rocm-dash-collectors/src/docker.rs @@ -0,0 +1,425 @@ +//! Docker service discovery via `bollard`. +//! +//! Field-extraction logic is vendored from instinct-dash `DockerService.ts` +//! (HIP_VISIBLE_DEVICES → gpu_ids, `--tensor-parallel-size`/`-tp`, etc.). +//! See `../../wiki/entities/dockerode.md`. +//! +//! The async inherent methods are the public API. The `ServiceDiscovery` trait +//! impl is sync-only and returns `Unsupported`: bollard requires a tokio +//! runtime, and we don't want to silently block-on inside a sync caller. + +use std::collections::BTreeMap; +use std::time::Duration; + +use bollard::Docker; +use bollard::container::{InspectContainerOptions, ListContainersOptions}; +use bollard::secret::ContainerInspectResponse; +use rocm_dash_core::traits::{CollectorError, DiscoveredService, Result, ServiceDiscovery}; +use tokio::time::timeout; +use tracing::{debug, warn}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const LIST_TIMEOUT: Duration = Duration::from_secs(5); +const INSPECT_TIMEOUT: Duration = Duration::from_secs(5); + +const DEFAULT_IMAGE_PATTERNS: &[&str] = &["vllm/*", "rocm/vllm*"]; +/// Fallback vLLM port for container discovery when Docker exposes no host-port +/// binding. The discovered binding is the authority; for managed services the +/// registry `ManagedServiceRecord.port` is authoritative (EAI-6871 D7). +const DEFAULT_VLLM_PORT: u16 = 8000; + +#[derive(Debug)] +pub struct DockerDiscovery { + docker: Option, + image_patterns: Vec, +} + +impl Default for DockerDiscovery { + fn default() -> Self { + Self { + docker: None, + image_patterns: DEFAULT_IMAGE_PATTERNS + .iter() + .map(|s| (*s).to_string()) + .collect(), + } + } +} + +impl DockerDiscovery { + /// Build with optional comma-separated image patterns (e.g. `"vllm/*,rocm/vllm*"`). + pub fn new(image_patterns: Option) -> Self { + let patterns: Vec = match image_patterns { + Some(s) if !s.trim().is_empty() => s + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(), + _ => DEFAULT_IMAGE_PATTERNS + .iter() + .map(|s| (*s).to_string()) + .collect(), + }; + Self { + docker: None, + image_patterns: patterns, + } + } + + /// Returns `Some` only if the local Docker daemon is reachable. + pub async fn detect(image_patterns: Option) -> Option { + let mut me = Self::new(image_patterns); + match Docker::connect_with_local_defaults() { + Ok(d) => match timeout(CONNECT_TIMEOUT, d.ping()).await { + Ok(Ok(_)) => { + me.docker = Some(d); + Some(me) + } + Ok(Err(e)) => { + warn!(error = %e, "docker ping failed"); + None + } + Err(_) => { + warn!("docker ping timed out"); + None + } + }, + Err(e) => { + warn!(error = %e, "docker connect failed"); + None + } + } + } + + pub async fn discover_async(&self) -> Result> { + let docker = self + .docker + .as_ref() + .ok_or_else(|| CollectorError::Unsupported("docker not connected".into()))?; + + let opts: ListContainersOptions = ListContainersOptions { + all: false, + ..Default::default() + }; + let summaries = timeout(LIST_TIMEOUT, docker.list_containers(Some(opts))) + .await + .map_err(|_| CollectorError::Transport("docker list_containers timed out".into()))? + .map_err(|e| CollectorError::Transport(format!("docker list_containers: {e}")))?; + + let mut out = Vec::new(); + for c in summaries { + let image = c.image.as_deref().unwrap_or(""); + if !matches_any_pattern(image, &self.image_patterns) { + continue; + } + let Some(id) = c.id.as_deref() else { continue }; + let inspect = timeout( + INSPECT_TIMEOUT, + docker.inspect_container(id, None::), + ) + .await + .map_err(|_| CollectorError::Transport(format!("inspect {id} timed out")))? + .map_err(|e| CollectorError::Transport(format!("inspect {id}: {e}")))?; + match parse_container(&inspect) { + Ok(svc) => out.push(svc), + Err(e) => debug!(container_id = id, error = %e, "skipping container"), + } + } + Ok(out) + } +} + +impl ServiceDiscovery for DockerDiscovery { + fn name(&self) -> &'static str { + "docker" + } + + fn discover(&self) -> Result> { + Err(CollectorError::Unsupported( + "DockerDiscovery is async — call discover_async() from a tokio runtime".into(), + )) + } +} + +// --- pure helpers, fully unit-testable --------------------------------------- + +fn matches_any_pattern(image: &str, patterns: &[String]) -> bool { + patterns.iter().any(|p| matches_pattern(image, p)) +} + +/// Glob-ish match: only `*` is special. Anchored at both ends, but a trailing +/// `:tag` on the image is allowed. +fn matches_pattern(image: &str, pattern: &str) -> bool { + let base = image.split(':').next().unwrap_or(image); + let pat_base = pattern.split(':').next().unwrap_or(pattern); + glob_match(base, pat_base) +} + +fn glob_match(s: &str, pat: &str) -> bool { + // Backtracking glob over `*`. + let (s, pat) = (s.as_bytes(), pat.as_bytes()); + let (mut i, mut j) = (0usize, 0usize); + let (mut star, mut s_at_star) = (None::, 0usize); + while i < s.len() { + if j < pat.len() && pat[j] == b'*' { + star = Some(j); + s_at_star = i; + j += 1; + } else if j < pat.len() && pat[j] == s[i] { + i += 1; + j += 1; + } else if let Some(sj) = star { + j = sj + 1; + s_at_star += 1; + i = s_at_star; + } else { + return false; + } + } + while j < pat.len() && pat[j] == b'*' { + j += 1; + } + j == pat.len() +} + +pub(crate) fn parse_env(env: &[String]) -> BTreeMap { + let mut out = BTreeMap::new(); + for entry in env { + if let Some(eq) = entry.find('=') + && eq > 0 + { + out.insert(entry[..eq].to_string(), entry[eq + 1..].to_string()); + } + } + out +} + +pub(crate) fn extract_gpu_ids(env: &BTreeMap) -> Vec { + if let Some(v) = env.get("HIP_VISIBLE_DEVICES") + && v != "all" + && !v.is_empty() + { + return split_csv(v); + } + for k in [ + "AMD_VISIBLE_DEVICES", + "ROCR_VISIBLE_DEVICES", + "CUDA_VISIBLE_DEVICES", + ] { + if let Some(v) = env.get(k) + && !v.is_empty() + { + return split_csv(v); + } + } + Vec::new() +} + +fn split_csv(s: &str) -> Vec { + s.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +pub(crate) fn extract_tensor_parallel(cmd: &[String]) -> u32 { + let pairs = [("--tensor-parallel-size", 1u32), ("-tp", 1)]; + for (flag, default) in pairs { + if let Some(i) = cmd.iter().position(|a| a == flag) + && let Some(v) = cmd.get(i + 1) + { + return v.parse().unwrap_or(default); + } + } + 1 +} + +pub(crate) fn extract_arg<'a>(cmd: &'a [String], flag: &str) -> Option<&'a str> { + cmd.iter() + .position(|a| a == flag) + .and_then(|i| cmd.get(i + 1)) + .map(String::as_str) +} + +/// Effective quantization for a vLLM launch command. Prefers the explicit +/// `--quantization` flag; falls back to `--dtype` when no `--quantization` was +/// passed (the closest signal vLLM exposes). Returns `None` when neither flag +/// is present. +pub(crate) fn extract_quantization(cmd: &[String]) -> Option { + extract_arg(cmd, "--quantization") + .or_else(|| extract_arg(cmd, "--dtype")) + .map(str::to_string) +} + +pub(crate) fn extract_model_name(cmd: &[String], env: &BTreeMap) -> String { + extract_arg(cmd, "--model") + .map(str::to_string) + .or_else(|| env.get("MODEL_NAME").cloned()) + .unwrap_or_else(|| "unknown".into()) +} + +fn parse_container(inspect: &ContainerInspectResponse) -> Result { + let id = inspect + .id + .clone() + .ok_or_else(|| CollectorError::Parse("container missing id".into()))?; + let name = inspect + .name + .clone() + .unwrap_or_default() + .trim_start_matches('/') + .to_string(); + let (env_list, cmd, pid) = inspect + .config + .as_ref() + .map(|cfg| { + ( + cfg.env.clone().unwrap_or_default(), + cfg.cmd.clone().unwrap_or_default(), + inspect.state.as_ref().and_then(|s| s.pid).unwrap_or(0) as u32, + ) + }) + .unwrap_or_default(); + + let env = parse_env(&env_list); + let gpu_ids = extract_gpu_ids(&env); + let tp = extract_tensor_parallel(&cmd); + let dtype = extract_arg(&cmd, "--dtype").map(str::to_string); + let quantization = extract_quantization(&cmd); + let model = extract_model_name(&cmd, &env); + let port = extract_first_host_port(inspect).unwrap_or(DEFAULT_VLLM_PORT); + + Ok(DiscoveredService { + container_id: id, + container_name: name, + model_name: model, + gpu_ids, + port: Some(port), + tensor_parallel_size: tp, + dtype, + quantization, + launch_args: cmd, + env_vars: env, + pid, + log_file: None, + }) +} + +fn extract_first_host_port(inspect: &ContainerInspectResponse) -> Option { + let ports = inspect.network_settings.as_ref()?.ports.as_ref()?; + for bindings in ports.values().flatten() { + for b in bindings { + if let Some(host_port) = &b.host_port + && let Ok(p) = host_port.parse() + { + return Some(p); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_matches_vllm_image() { + assert!(matches_pattern("vllm/vllm-openai:v0.5.0", "vllm/*")); + assert!(matches_pattern("rocm/vllm-rocm:latest", "rocm/vllm*")); + assert!(!matches_pattern("nginx:1.27", "vllm/*")); + assert!(!matches_pattern("ghcr.io/vllm/vllm:latest", "vllm/*")); + } + + #[test] + fn glob_handles_pure_wildcard() { + assert!(glob_match("anything", "*")); + assert!(glob_match("", "*")); + assert!(glob_match("foo-bar-baz", "foo*baz")); + assert!(!glob_match("foo-bar", "foo*baz")); + } + + #[test] + fn parse_env_splits_on_first_equals() { + let env = parse_env(&[ + "FOO=bar".into(), + "EQ=a=b=c".into(), + "NO_EQ".into(), + "=leading_eq".into(), + ]); + assert_eq!(env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!(env.get("EQ").map(String::as_str), Some("a=b=c")); + assert!(!env.contains_key("NO_EQ")); + assert!(!env.contains_key("")); + } + + #[test] + fn gpu_ids_prefer_hip_over_amd() { + let mut env = BTreeMap::new(); + env.insert("HIP_VISIBLE_DEVICES".into(), "0,2".into()); + env.insert("AMD_VISIBLE_DEVICES".into(), "all".into()); + assert_eq!(extract_gpu_ids(&env), vec!["0", "2"]); + } + + #[test] + fn gpu_ids_fall_back_when_hip_is_all() { + let mut env = BTreeMap::new(); + env.insert("HIP_VISIBLE_DEVICES".into(), "all".into()); + env.insert("ROCR_VISIBLE_DEVICES".into(), "1,3".into()); + assert_eq!(extract_gpu_ids(&env), vec!["1", "3"]); + } + + #[test] + fn gpu_ids_empty_when_no_env() { + assert!(extract_gpu_ids(&BTreeMap::new()).is_empty()); + } + + #[test] + fn tensor_parallel_parses_long_and_short_flags() { + let cmd = vec!["--tensor-parallel-size".into(), "8".into()]; + assert_eq!(extract_tensor_parallel(&cmd), 8); + let cmd = vec!["serve".into(), "-tp".into(), "4".into()]; + assert_eq!(extract_tensor_parallel(&cmd), 4); + let cmd: Vec = vec!["serve".into()]; + assert_eq!(extract_tensor_parallel(&cmd), 1); + } + + #[test] + fn model_name_falls_back_to_env_then_unknown() { + let env = parse_env(&["MODEL_NAME=llama3-70b".into()]); + assert_eq!(extract_model_name(&[], &env), "llama3-70b"); + assert_eq!(extract_model_name(&[], &BTreeMap::new()), "unknown"); + let cmd = vec!["--model".into(), "deepseek".into()]; + assert_eq!(extract_model_name(&cmd, &env), "deepseek"); + } + + #[test] + fn quantization_prefers_explicit_flag_then_dtype_then_none() { + // Both flags present → --quantization wins. + let cmd = vec![ + "--dtype".into(), + "bfloat16".into(), + "--quantization".into(), + "fp8".into(), + ]; + assert_eq!(extract_quantization(&cmd).as_deref(), Some("fp8")); + + // Only --dtype present → falls back to dtype value. + let cmd = vec!["--dtype".into(), "float16".into()]; + assert_eq!(extract_quantization(&cmd).as_deref(), Some("float16")); + + // Only --quantization present. + let cmd = vec!["--quantization".into(), "awq".into()]; + assert_eq!(extract_quantization(&cmd).as_deref(), Some("awq")); + + // Neither flag → None. + let cmd = vec!["serve".into(), "--model".into(), "x".into()]; + assert_eq!(extract_quantization(&cmd), None); + } + + #[test] + fn sync_trait_returns_unsupported() { + let d = DockerDiscovery::default(); + assert!(matches!(d.discover(), Err(CollectorError::Unsupported(_)))); + } +} diff --git a/crates/rocm-dash-collectors/src/engine_registry.rs b/crates/rocm-dash-collectors/src/engine_registry.rs new file mode 100644 index 00000000..9c0bb993 --- /dev/null +++ b/crates/rocm-dash-collectors/src/engine_registry.rs @@ -0,0 +1,100 @@ +//! Engine registry seam — one place that maps a discovered serving engine to its +//! per-engine sample parser, so the daemon can pick a backend per service instead +//! of hard-coding vLLM. The existing vLLM Prometheus parser +//! (`vllm_prom::parse`) is reachable through this seam **unchanged**; Lemonade +//! adds a second parser. This is a focused seam, not a rewrite. + +use rocm_dash_core::traits::InstanceSample; + +/// A known inference-serving engine kind, keyed by how its live stats are scraped +/// and parsed. New engines slot in here without touching the daemon loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EngineKind { + /// vLLM — Prometheus `/metrics` text exposition. + Vllm, + /// Lemonade Server — JSON `/api/v1/stats`. + Lemonade, + /// llama.cpp `llama-server` — `/slots` (parser not yet wired). + LlamaCpp, +} + +impl EngineKind { + /// Stable lowercase label (matches `rocm engines` / config engine names). + pub fn label(self) -> &'static str { + match self { + EngineKind::Vllm => "vllm", + EngineKind::Lemonade => "lemonade", + EngineKind::LlamaCpp => "llama.cpp", + } + } + + /// The engine's conventional local port. **Fallback only** — for managed + /// services the bound port comes from the registry + /// (`ManagedServiceRecord.port`, EAI-6871 D7); this default is used only for + /// unmanaged/external discovery where no registry record exists. + pub fn default_port(self) -> u16 { + match self { + EngineKind::Vllm => 8000, + EngineKind::Lemonade => crate::lemonade::LEMONADE_PORT, // 13305 + EngineKind::LlamaCpp => 8080, + } + } + + /// Map an engine label (config / discovery) to a kind. Case-insensitive; + /// accepts the `llama.cpp` / `llamacpp` / `llama_cpp` spellings. + pub fn from_label(label: &str) -> Option { + match label.trim().to_ascii_lowercase().as_str() { + "vllm" => Some(EngineKind::Vllm), + "lemonade" => Some(EngineKind::Lemonade), + "llama.cpp" | "llamacpp" | "llama_cpp" => Some(EngineKind::LlamaCpp), + _ => None, + } + } + + /// Parse this engine's raw scrape body into an [`InstanceSample`] using the + /// engine-appropriate parser. vLLM dispatches to the **unchanged** + /// `vllm_prom::parse`; Lemonade to `lemonade::parse_stats`. Unwired engines + /// return an empty sample (never panic). + pub fn parse_sample(self, body: &str) -> InstanceSample { + match self { + EngineKind::Vllm => crate::vllm_prom::parse(body), + EngineKind::Lemonade => crate::lemonade::parse_stats(body), + EngineKind::LlamaCpp => InstanceSample::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_kind_labels_and_ports_and_from_label_roundtrip() { + for kind in [EngineKind::Vllm, EngineKind::Lemonade, EngineKind::LlamaCpp] { + assert_eq!(EngineKind::from_label(kind.label()), Some(kind)); + } + assert_eq!(EngineKind::Lemonade.default_port(), 13305); + assert_eq!(EngineKind::Vllm.default_port(), 8000); + assert_eq!( + EngineKind::from_label("LLAMACPP"), + Some(EngineKind::LlamaCpp) + ); + assert_eq!(EngineKind::from_label("nope"), None); + } + + #[test] + fn parse_sample_dispatches_to_the_engine_parser() { + // vLLM Prometheus text → the unchanged vLLM parser (kv-cache 0..1 → 0..100). + let vllm_body = "vllm:gpu_cache_usage_perc 0.5\nvllm:num_requests_running 3\n"; + let vllm_sample = EngineKind::Vllm.parse_sample(vllm_body); + assert_eq!(vllm_sample.kv_cache_usage_pct, Some(50.0)); + assert_eq!(vllm_sample.running_reqs, Some(3)); + assert_eq!(vllm_sample.gen_tps, None); // counter path, no direct rate + + // Lemonade JSON → the Lemonade parser (rate → gen_tps). + let lemo_body = r#"{"tokens_per_second": 42.0}"#; + let lemo_sample = EngineKind::Lemonade.parse_sample(lemo_body); + assert_eq!(lemo_sample.gen_tps, Some(42.0)); + assert_eq!(lemo_sample.kv_cache_usage_pct, None); + } +} diff --git a/crates/rocm-dash-collectors/src/host.rs b/crates/rocm-dash-collectors/src/host.rs new file mode 100644 index 00000000..a1761325 --- /dev/null +++ b/crates/rocm-dash-collectors/src/host.rs @@ -0,0 +1,68 @@ +//! Host system metrics via sysinfo. Backs the daemon's per-tick `SystemMetrics`. + +use rocm_dash_core::metrics::SystemMetrics; +use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + +pub struct HostCollector { + sys: System, +} + +impl Default for HostCollector { + fn default() -> Self { + Self::new() + } +} + +impl HostCollector { + pub fn new() -> Self { + let refresh = RefreshKind::nothing() + .with_cpu(sysinfo::CpuRefreshKind::everything()) + .with_memory(MemoryRefreshKind::everything()); + let mut sys = System::new_with_specifics(refresh); + // First refresh primes CPU deltas; the very first read is meaningless. + sys.refresh_cpu_all(); + sys.refresh_memory(); + Self { sys } + } + + pub fn tick(&mut self) -> SystemMetrics { + self.sys.refresh_cpu_all(); + self.sys.refresh_memory(); + + let cpu_overall_pct = self.sys.global_cpu_usage(); + let cpu_per_core_pct: Vec = self.sys.cpus().iter().map(|c| c.cpu_usage()).collect(); + + // sysinfo reports memory in bytes. + let memory_used_mb = self.sys.used_memory() / 1024 / 1024; + let memory_total_mb = self.sys.total_memory() / 1024 / 1024; + let swap_used_mb = self.sys.used_swap() / 1024 / 1024; + let swap_total_mb = self.sys.total_swap() / 1024 / 1024; + + SystemMetrics { + cpu_overall_pct, + cpu_per_core_pct, + memory_used_mb, + memory_total_mb, + swap_used_mb, + swap_total_mb, + // TODO: wire `sysinfo::Disks` / `sysinfo::Networks` for I/O deltas. + disk_read_bps: 0, + disk_write_bps: 0, + net_rx_bps: 0, + net_tx_bps: 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tick_produces_metrics_with_some_cpus() { + let mut c = HostCollector::new(); + let m = c.tick(); + assert!(!m.cpu_per_core_pct.is_empty(), "expected at least one CPU"); + assert!(m.memory_total_mb > 0, "expected nonzero total memory"); + } +} diff --git a/crates/rocm-dash-collectors/src/lemonade.rs b/crates/rocm-dash-collectors/src/lemonade.rs new file mode 100644 index 00000000..a22c90cb --- /dev/null +++ b/crates/rocm-dash-collectors/src/lemonade.rs @@ -0,0 +1,319 @@ +//! Lemonade Server collector — pure parsers over the Lemonade REST API. +//! +//! Schema verified against the official docs (lemonade-server.ai/docs/api): +//! - `GET /api/v1/stats` → per-request performance metrics: +//! `time_to_first_token`, `tokens_per_second`, `input_tokens`, `output_tokens`, +//! `prompt_tokens`, `decode_token_times[]`. (No KV-cache / running / waiting — +//! Lemonade does not expose those; those `InstanceSample` fields stay `None`.) +//! - `GET /api/v1/health` → `status` + `model_loaded` (most-recent model name). +//! +//! The parsers (`parse_stats`, `parse_health_model`) are **pure** (serde only) +//! and the deterministic, fixture-tested anchor. The async scrape +//! ([`LemonadeCollector`]) fetches those bodies over HTTP and degrades to +//! "not reachable" (never panics) so a host with no Lemonade endpoint is a no-op. + +use std::time::Duration; + +use reqwest::Client; +use rocm_dash_core::metrics::Instance; +use rocm_dash_core::traits::{ + CollectorError, DiscoveredService, InstanceSample, Result, merge_instance, +}; +use serde::Deserialize; + +/// Lemonade's default OpenAI-compatible port. Mirrors `rocm_dash_tui::skills` +/// (defined locally to avoid a cross-crate dep from collectors → tui). +pub const LEMONADE_PORT: u16 = 13305; +/// The runtime-stats path on the canonical `/api/v1` base. +pub const LEMONADE_STATS_PATH: &str = "/api/v1/stats"; +/// The health/liveness path on the canonical `/api/v1` base. +pub const LEMONADE_HEALTH_PATH: &str = "/api/v1/health"; + +/// `/api/v1/stats` response — performance metrics from the most recent request. +/// Every field is optional: the endpoint only populates them after an inference. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct LemonadeStats { + #[serde(default)] + pub time_to_first_token: Option, + #[serde(default)] + pub tokens_per_second: Option, + #[serde(default)] + pub input_tokens: Option, + #[serde(default)] + pub output_tokens: Option, + #[serde(default)] + pub prompt_tokens: Option, + #[serde(default)] + pub decode_token_times: Option>, +} + +/// `/api/v1/health` response — liveness + the most-recently-loaded model name. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct LemonadeHealth { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub model_loaded: Option, +} + +/// PURE: parse a `/api/v1/stats` body into an [`InstanceSample`]. Lemonade reports +/// an instantaneous `tokens_per_second` rate (mapped to `gen_tps`) rather than a +/// cumulative counter; it exposes no KV-cache / running / waiting metrics, so +/// those stay `None`. Malformed/empty JSON → an all-`None` default, never panics. +pub fn parse_stats(body: &str) -> InstanceSample { + let stats = parse_stats_struct(body); + InstanceSample { + kv_cache_usage_pct: None, + running_reqs: None, + waiting_reqs: None, + // Lemonade has no cumulative token counter; surface the rate directly. + gen_tokens_total: None, + gen_tps: stats.tokens_per_second, + } +} + +/// PURE: parse the structured `/api/v1/stats` body. Returns the full Lemonade +/// metric set (for callers that want TTFT / token counts). Malformed → default. +pub fn parse_stats_struct(body: &str) -> LemonadeStats { + object_or_default(body) +} + +/// PURE: extract the loaded model name from a `/api/v1/health` body, if present +/// and non-empty. Malformed JSON → `None`, never panics. +pub fn parse_health_model(body: &str) -> Option { + let health: LemonadeHealth = object_or_default(body); + health.model_loaded.filter(|s| !s.is_empty()) +} + +/// Deserialize a JSON **object** body into `T`, returning `T::default()` for any +/// non-object/invalid input. serde's derived `Deserialize` decodes a struct from +/// a positional JSON *array* too — guarding on object shape prevents a wrong-shape +/// body (e.g. `[1,2,3]`) from being misread into fields. +fn object_or_default(body: &str) -> T { + match serde_json::from_str::(body) { + Ok(value @ serde_json::Value::Object(_)) => { + serde_json::from_value(value).unwrap_or_default() + } + _ => T::default(), + } +} + +/// A stable synthetic id for the local Lemonade endpoint (it is a server, not a +/// container, so it has no Docker id). Distinct from any vLLM/Docker container id. +pub fn lemonade_container_id(host: &str, port: u16) -> String { + format!("lemonade-{host}-{port}") +} + +/// PURE: build a `DiscoveredService` for a Lemonade endpoint. Lemonade is a +/// single local server (no tensor-parallel sharding metadata), so TP = 1. +pub fn lemonade_service(host: &str, port: u16, model_name: &str) -> DiscoveredService { + DiscoveredService { + container_id: lemonade_container_id(host, port), + container_name: "lemonade".to_string(), + model_name: if model_name.is_empty() { + "lemonade".to_string() + } else { + model_name.to_string() + }, + port: Some(port), + tensor_parallel_size: 1, + ..Default::default() + } +} + +/// PURE: build a finished `Instance` from a Lemonade `/health` model name + a +/// `/stats` body — the fixture→Instance anchor (no network). `gen_tps` flows from +/// `tokens_per_second`; KV/req fields stay `None` (Lemonade does not report them). +pub fn lemonade_instance(host: &str, port: u16, model_name: &str, stats_body: &str) -> Instance { + let svc = lemonade_service(host, port, model_name); + let sample = parse_stats(stats_body); + let mut inst = merge_instance(&svc, &sample, 0, 0); + inst.status = rocm_dash_core::metrics::InstanceStatus::Running; + inst +} + +/// Async scrape of a local Lemonade endpoint. Network/parse failure degrades to +/// `Err`/`None` ("not reachable") — never a panic. The pure parsers above do the +/// actual mapping; this only does I/O. +#[derive(Debug, Clone)] +pub struct LemonadeCollector { + host: String, + port: u16, + client: Client, +} + +impl LemonadeCollector { + pub fn new(host: impl Into, port: u16, timeout: Duration) -> Self { + let client = Client::builder() + .timeout(timeout) + .build() + .unwrap_or_else(|_| Client::new()); + Self { + host: host.into(), + port, + client, + } + } + + fn url(&self, path: &str) -> String { + format!("http://{}:{}{path}", self.host, self.port) + } + + async fn get_text(&self, path: &str) -> Result { + let url = self.url(path); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| CollectorError::Transport(format!("GET {url}: {e}")))?; + if !resp.status().is_success() { + return Err(CollectorError::Transport(format!( + "GET {url}: status {}", + resp.status() + ))); + } + resp.text() + .await + .map_err(|e| CollectorError::Transport(format!("body {url}: {e}"))) + } + + /// Scrape `/api/v1/stats` → an `InstanceSample` (rate → `gen_tps`). + pub async fn fetch_stats(&self) -> Result { + Ok(parse_stats(&self.get_text(LEMONADE_STATS_PATH).await?)) + } + + /// Scrape `/api/v1/health` → the loaded model name (if any). + pub async fn fetch_health_model(&self) -> Result> { + Ok(parse_health_model( + &self.get_text(LEMONADE_HEALTH_PATH).await?, + )) + } + + /// Probe the endpoint: if `/api/v1/health` answers, return a `DiscoveredService` + /// for it (model name from health, falling back to "lemonade"); otherwise + /// `None` (endpoint absent/unreachable) — a clean no-op, no panic. + pub async fn discover(&self) -> Option { + match self.fetch_health_model().await { + Ok(model) => Some(lemonade_service( + &self.host, + self.port, + model.as_deref().unwrap_or(""), + )), + Err(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Representative `/api/v1/stats` response (lemonade-server.ai/docs/api). + const STATS_FIXTURE: &str = r#"{ + "time_to_first_token": 2.14, + "tokens_per_second": 33.33, + "input_tokens": 128, + "output_tokens": 5, + "prompt_tokens": 133, + "decode_token_times": [0.03, 0.03, 0.03, 0.03, 0.03] + }"#; + + // Representative `/api/v1/health` response. + const HEALTH_FIXTURE: &str = r#"{ + "status": "ok", + "version": "9.3.3", + "model_loaded": "Llama-3.2-1B-Instruct-Hybrid", + "all_models_loaded": [] + }"#; + + #[test] + fn parse_stats_maps_tokens_per_second_to_gen_tps() { + let sample = parse_stats(STATS_FIXTURE); + assert_eq!(sample.gen_tps, Some(33.33)); + // Lemonade does not expose these — they must stay None (not zero). + assert_eq!(sample.kv_cache_usage_pct, None); + assert_eq!(sample.running_reqs, None); + assert_eq!(sample.waiting_reqs, None); + assert_eq!(sample.gen_tokens_total, None); + } + + #[test] + fn parse_stats_struct_captures_full_metric_set() { + let stats = parse_stats_struct(STATS_FIXTURE); + assert_eq!(stats.time_to_first_token, Some(2.14)); + assert_eq!(stats.tokens_per_second, Some(33.33)); + assert_eq!(stats.input_tokens, Some(128)); + assert_eq!(stats.output_tokens, Some(5)); + assert_eq!(stats.prompt_tokens, Some(133)); + assert_eq!(stats.decode_token_times.unwrap().len(), 5); + } + + #[test] + fn parse_health_model_extracts_loaded_model() { + assert_eq!( + parse_health_model(HEALTH_FIXTURE).as_deref(), + Some("Llama-3.2-1B-Instruct-Hybrid") + ); + } + + #[test] + fn malformed_or_empty_input_is_graceful_not_panic() { + // Garbage / empty / wrong-shape → all-None sample, no panic. + for body in [ + "", + "not json", + "{}", + "[1,2,3]", + r#"{"tokens_per_second": "x"}"#, + ] { + let sample = parse_stats(body); + assert_eq!(sample.gen_tps, None, "body {body:?}"); + assert!(sample.kv_cache_usage_pct.is_none()); + } + assert_eq!(parse_health_model("not json"), None); + assert_eq!(parse_health_model(r#"{"model_loaded": ""}"#), None); + } + + #[test] + fn fixture_stats_plus_health_becomes_an_instance() { + // The fixture→Instance anchor: no network. Health gives the model name, + // /stats gives the live rate → an Instances-tab row. + let model = parse_health_model(HEALTH_FIXTURE).expect("model"); + let inst = lemonade_instance("127.0.0.1", LEMONADE_PORT, &model, STATS_FIXTURE); + assert_eq!(inst.model_name, "Llama-3.2-1B-Instruct-Hybrid"); + assert_eq!(inst.container_name, "lemonade"); + assert_eq!(inst.container_id, "lemonade-127.0.0.1-13305"); + assert_eq!(inst.port, Some(13305)); + assert_eq!(inst.gen_tps, Some(33.33)); + assert_eq!( + inst.status, + rocm_dash_core::metrics::InstanceStatus::Running + ); + // Lemonade exposes no KV/req metrics → those stay None on the Instance. + assert_eq!(inst.kv_cache_usage_pct, None); + assert_eq!(inst.running_reqs, None); + } + + #[tokio::test] + async fn discover_is_clean_noop_when_endpoint_absent() { + // Probe a port with no server → connection refused → None, no panic. + // (Port 0 is never a live listener; the GET fails fast.) + let collector = LemonadeCollector::new("127.0.0.1", 1, Duration::from_millis(200)); + assert!(collector.discover().await.is_none()); + assert!(collector.fetch_stats().await.is_err()); + } + + /// Integration-gated: scrape a REAL local Lemonade server (start it first via + /// `rocm skill run install-lemonade --apply`). Not run in CI. + #[tokio::test] + #[ignore = "requires a running Lemonade server on :13305"] + async fn live_lemonade_scrape_produces_instance() { + let collector = + LemonadeCollector::new("127.0.0.1", LEMONADE_PORT, Duration::from_millis(1500)); + let svc = collector.discover().await.expect("lemonade reachable"); + assert_eq!(svc.port, Some(LEMONADE_PORT)); + // A stats scrape returns a sample (gen_tps may be None until an inference). + let _sample = collector.fetch_stats().await.expect("stats scrape"); + } +} diff --git a/crates/rocm-dash-collectors/src/lib.rs b/crates/rocm-dash-collectors/src/lib.rs new file mode 100644 index 00000000..e7a43cba --- /dev/null +++ b/crates/rocm-dash-collectors/src/lib.rs @@ -0,0 +1,20 @@ +//! IO-heavy collector implementations. Used by `rocm-dash-daemon`. +//! +//! Every collector implements one of the traits in `rocm_dash_core::traits`. +//! Stubs return `CollectorError::Unsupported` so the daemon can start with nothing wired. + +#![allow(dead_code)] + +pub mod amd_smi; +pub mod bench_tail; +pub mod cgroup; +pub mod docker; +pub mod engine_registry; +pub mod host; +pub mod lemonade; +pub mod llama_slots; +pub mod parallel; +pub mod proc_scan; +pub mod sysfs; +pub mod vllm_log; +pub mod vllm_prom; diff --git a/crates/rocm-dash-collectors/src/llama_slots.rs b/crates/rocm-dash-collectors/src/llama_slots.rs new file mode 100644 index 00000000..a202e62c --- /dev/null +++ b/crates/rocm-dash-collectors/src/llama_slots.rs @@ -0,0 +1,26 @@ +//! llama-server /slots collector. Stub. + +use rocm_dash_core::traits::{ + CollectorError, DiscoveredService, InstanceMetrics, InstanceSample, Result, +}; + +#[derive(Debug, Default)] +pub struct LlamaSlotsCollector; + +impl LlamaSlotsCollector { + pub fn new() -> Self { + Self + } +} + +impl InstanceMetrics for LlamaSlotsCollector { + fn name(&self) -> &'static str { + "llama-slots" + } + + fn fetch(&self, _svc: &DiscoveredService) -> Result { + // TODO: GET http://localhost:{port}/slots; count non-idle entries. + // llama.cpp has no waiting queue → set waiting_reqs = Some(0). + Err(CollectorError::Unsupported("llama-slots stub".into())) + } +} diff --git a/crates/rocm-dash-collectors/src/parallel.rs b/crates/rocm-dash-collectors/src/parallel.rs new file mode 100644 index 00000000..f9d0c33e --- /dev/null +++ b/crates/rocm-dash-collectors/src/parallel.rs @@ -0,0 +1,125 @@ +//! Shared primitives for async collectors that scrape N targets concurrently. +//! +//! Today: [[VllmPrometheusCollector]] uses this pattern hand-rolled in the +//! daemon's runner. As Strix-Halo's `LlamaSlotsCollector` and any future +//! per-instance scrapers come online, they'll reuse `parallel_scrape` and +//! the `WarningBus` instead of re-implementing the JoinSet glue. + +use std::future::Future; +use std::sync::Arc; + +use tokio::task::JoinSet; + +/// Run `f` against every `target` concurrently and collect `(target, result)` +/// pairs in completion order (NOT input order — order is not guaranteed). +/// +/// The closure is invoked with each `target`. The returned `Future` must be +/// `'static + Send`, which the caller typically achieves by cloning anything +/// captured into the future (cheap, since reqwest's `Client` is `Arc`-shared). +/// +/// Empty input returns an empty vec without spawning anything. +pub async fn parallel_scrape(targets: Vec, f: F) -> Vec<(T, R)> +where + T: Clone + Send + 'static, + R: Send + 'static, + F: Fn(T) -> Fut + Send + Sync, + Fut: Future + Send + 'static, +{ + if targets.is_empty() { + return Vec::new(); + } + let mut joins = JoinSet::new(); + for t in targets { + let tag = t.clone(); + let fut = f(t); + joins.spawn(async move { (tag, fut.await) }); + } + let mut out = Vec::with_capacity(joins.len()); + while let Some(res) = joins.join_next().await { + if let Ok(pair) = res { + out.push(pair); + } + } + out +} + +/// Collect warnings across a single runner tick from concurrent collector +/// failures, then drain into the snapshot. Cheap clone (Arc-backed) so it +/// can be passed into spawned tasks. +#[derive(Debug, Default, Clone)] +pub struct WarningBus { + inner: Arc>>, +} + +impl WarningBus { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&self, msg: impl Into) { + if let Ok(mut v) = self.inner.lock() { + v.push(msg.into()); + } + } + + /// Drain all accumulated warnings, replacing the bus with an empty vec. + pub fn drain(&self) -> Vec { + match self.inner.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(_) => Vec::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.inner.lock().map(|v| v.is_empty()).unwrap_or(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn parallel_scrape_runs_all_targets_concurrently() { + let targets = vec![1u32, 2, 3, 4, 5]; + let out = parallel_scrape(targets.clone(), |n| async move { n * 10 }).await; + assert_eq!(out.len(), targets.len()); + let mut values: Vec = out.iter().map(|(t, _)| *t).collect(); + values.sort_unstable(); + assert_eq!(values, targets); + for (n, mapped) in &out { + assert_eq!(*mapped, n * 10); + } + } + + #[tokio::test] + async fn parallel_scrape_empty_input_is_empty_output() { + let out: Vec<(u32, u32)> = parallel_scrape(Vec::new(), |n| async move { n }).await; + assert!(out.is_empty()); + } + + #[test] + fn warning_bus_collects_then_drains() { + let bus = WarningBus::new(); + bus.push("docker: unreachable"); + bus.push("vllm: 502 on :8000"); + let v = bus.drain(); + assert_eq!(v.len(), 2); + assert!(bus.is_empty()); + // Second drain returns empty. + assert!(bus.drain().is_empty()); + } + + #[tokio::test] + async fn warning_bus_is_clone_safe_across_tasks() { + let bus = WarningBus::new(); + let mut joins = JoinSet::new(); + for i in 0..10 { + let b = bus.clone(); + joins.spawn(async move { b.push(format!("warn {i}")) }); + } + while joins.join_next().await.is_some() {} + let v = bus.drain(); + assert_eq!(v.len(), 10); + } +} diff --git a/crates/rocm-dash-collectors/src/proc_scan.rs b/crates/rocm-dash-collectors/src/proc_scan.rs new file mode 100644 index 00000000..7b6c5336 --- /dev/null +++ b/crates/rocm-dash-collectors/src/proc_scan.rs @@ -0,0 +1,23 @@ +//! /proc scan for non-container deployments (Strix Halo). Stub. + +use rocm_dash_core::traits::{CollectorError, DiscoveredService, Result, ServiceDiscovery}; + +#[derive(Debug, Default)] +pub struct ProcDiscovery; + +impl ProcDiscovery { + pub fn new() -> Self { + Self + } +} + +impl ServiceDiscovery for ProcDiscovery { + fn name(&self) -> &'static str { + "proc" + } + + fn discover(&self) -> Result> { + // TODO: walk /proc, match comm == "llama-server" | "lemonade-server". + Err(CollectorError::Unsupported("proc discovery stub".into())) + } +} diff --git a/crates/rocm-dash-collectors/src/sysfs.rs b/crates/rocm-dash-collectors/src/sysfs.rs new file mode 100644 index 00000000..2ec72a3b --- /dev/null +++ b/crates/rocm-dash-collectors/src/sysfs.rs @@ -0,0 +1,35 @@ +//! sysfs/hwmon collector for Strix Halo (gfx1151). Stub. + +use rocm_dash_core::metrics::{GpuMetrics, GpuSystemInfo}; +use rocm_dash_core::traits::{CollectorError, GpuCollector, GpuDevice, GpuProcess, Result}; + +#[derive(Debug, Default)] +pub struct SysfsGpuCollector; + +impl SysfsGpuCollector { + pub fn new() -> Self { + Self + } +} + +impl GpuCollector for SysfsGpuCollector { + fn name(&self) -> &'static str { + "sysfs" + } + + fn devices(&self) -> Result> { + Err(CollectorError::Unsupported("sysfs collector stub".into())) + } + + fn metrics(&self) -> Result> { + Err(CollectorError::Unsupported("sysfs collector stub".into())) + } + + fn system_info(&self) -> Result { + Err(CollectorError::Unsupported("sysfs collector stub".into())) + } + + fn processes(&self) -> Result> { + Err(CollectorError::Unsupported("sysfs collector stub".into())) + } +} diff --git a/crates/rocm-dash-collectors/src/vllm_log.rs b/crates/rocm-dash-collectors/src/vllm_log.rs new file mode 100644 index 00000000..af532e43 --- /dev/null +++ b/crates/rocm-dash-collectors/src/vllm_log.rs @@ -0,0 +1,101 @@ +//! vLLM log slicer — verbatim port of `inspect_bench/log_slicer.py`. +//! See `../wiki/entities/log-slicer.md` and `../wiki/concepts/log-derived-metrics.md`. + +use regex::Regex; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct VllmPeaks { + pub prompt_tps: Option, + pub gen_tps: Option, + pub max_running_reqs: Option, + pub max_waiting_reqs: Option, + pub n_requests: u32, +} + +pub struct VllmLogSlicer { + prompt_tps_re: Regex, + gen_tps_re: Regex, + running_re: Regex, + waiting_re: Regex, + req_re: Regex, +} + +impl Default for VllmLogSlicer { + fn default() -> Self { + Self::new() + } +} + +impl VllmLogSlicer { + pub fn new() -> Self { + Self { + prompt_tps_re: Regex::new(r"Avg prompt throughput: ([0-9.]+)").unwrap(), + gen_tps_re: Regex::new(r"Avg generation throughput: ([0-9.]+)").unwrap(), + running_re: Regex::new(r"Running: ([0-9]+)").unwrap(), + waiting_re: Regex::new(r"Waiting: ([0-9]+)").unwrap(), + req_re: Regex::new(r"POST /v1/(chat/completions|completions|messages)").unwrap(), + } + } + + /// Aggregate peaks over a byte-bounded slice of the vLLM log. + pub fn parse_slice(&self, text: &str) -> VllmPeaks { + let mut out = VllmPeaks::default(); + for cap in self.prompt_tps_re.captures_iter(text) { + if let Ok(v) = cap[1].parse::() { + out.prompt_tps = Some(out.prompt_tps.map_or(v, |p| p.max(v))); + } + } + for cap in self.gen_tps_re.captures_iter(text) { + if let Ok(v) = cap[1].parse::() { + out.gen_tps = Some(out.gen_tps.map_or(v, |p| p.max(v))); + } + } + for cap in self.running_re.captures_iter(text) { + if let Ok(v) = cap[1].parse::() { + out.max_running_reqs = Some(out.max_running_reqs.map_or(v, |p| p.max(v))); + } + } + for cap in self.waiting_re.captures_iter(text) { + if let Ok(v) = cap[1].parse::() { + out.max_waiting_reqs = Some(out.max_waiting_reqs.map_or(v, |p| p.max(v))); + } + } + out.n_requests = self.req_re.find_iter(text).count() as u32; + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_throughput_peaks_and_request_count() { + let log = r#" +[2026-05-26 10:00:00] Avg prompt throughput: 1234.5 tokens/s +[2026-05-26 10:00:01] Avg generation throughput: 67.8 tokens/s +[2026-05-26 10:00:02] Running: 8, Waiting: 2 +[2026-05-26 10:00:03] Avg prompt throughput: 2000.1 tokens/s +[2026-05-26 10:00:03] Avg generation throughput: 50.0 tokens/s +[2026-05-26 10:00:04] Running: 16, Waiting: 0 +INFO POST /v1/chat/completions 200 +INFO POST /v1/completions 200 +INFO POST /v1/messages 200 +INFO POST /v1/chat/completions 200 +"#; + let slicer = VllmLogSlicer::new(); + let peaks = slicer.parse_slice(log); + assert_eq!(peaks.prompt_tps, Some(2000.1)); + assert_eq!(peaks.gen_tps, Some(67.8)); + assert_eq!(peaks.max_running_reqs, Some(16)); + assert_eq!(peaks.max_waiting_reqs, Some(2)); + assert_eq!(peaks.n_requests, 4); + } + + #[test] + fn empty_log_is_all_none() { + let slicer = VllmLogSlicer::new(); + let peaks = slicer.parse_slice(""); + assert_eq!(peaks, VllmPeaks::default()); + } +} diff --git a/crates/rocm-dash-collectors/src/vllm_prom.rs b/crates/rocm-dash-collectors/src/vllm_prom.rs new file mode 100644 index 00000000..75e8cd5f --- /dev/null +++ b/crates/rocm-dash-collectors/src/vllm_prom.rs @@ -0,0 +1,236 @@ +//! vLLM Prometheus `/metrics` scraper. +//! +//! Field paths and the kv-cache 0..1 → 0..100 scaling are vendored from +//! instinct-dash `VllmMetricsService.ts`. See `../../wiki/entities/vllm.md`. +//! +//! Sync `InstanceMetrics::fetch` returns `Unsupported` (the underlying client +//! is async); call `fetch_async` from the runner. + +use std::time::Duration; + +use reqwest::Client; +use rocm_dash_core::traits::{ + CollectorError, DiscoveredService, InstanceMetrics, InstanceSample, Result, +}; +use tracing::warn; + +const KEY_RUNNING: &str = "vllm:num_requests_running"; +const KEY_WAITING: &str = "vllm:num_requests_waiting"; +const KEY_KV_CACHE: &str = "vllm:gpu_cache_usage_perc"; +const KEY_GEN_TOKENS: &str = "vllm:generation_tokens_total"; + +#[derive(Debug, Clone)] +pub struct VllmPrometheusCollector { + host: String, + client: Client, +} + +impl Default for VllmPrometheusCollector { + fn default() -> Self { + Self::new("127.0.0.1", Duration::from_millis(2000)) + } +} + +impl VllmPrometheusCollector { + pub fn new(host: impl Into, timeout: Duration) -> Self { + let client = Client::builder() + .timeout(timeout) + .build() + .unwrap_or_else(|_| Client::new()); + Self { + host: host.into(), + client, + } + } + + /// Scrape `http://{host}:{port}/metrics` and parse the three vLLM keys we care about. + pub async fn fetch_async(&self, svc: &DiscoveredService) -> Result { + let port = svc + .port + .ok_or_else(|| CollectorError::Unsupported("instance has no port".into()))?; + let url = format!("http://{}:{port}/metrics", self.host); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| CollectorError::Transport(format!("GET {url}: {e}")))?; + if !resp.status().is_success() { + return Err(CollectorError::Transport(format!( + "GET {url}: status {}", + resp.status() + ))); + } + let text = resp + .text() + .await + .map_err(|e| CollectorError::Transport(format!("body {url}: {e}")))?; + Ok(parse(&text)) + } +} + +impl InstanceMetrics for VllmPrometheusCollector { + fn name(&self) -> &'static str { + "vllm-prometheus" + } + + fn fetch(&self, _svc: &DiscoveredService) -> Result { + Err(CollectorError::Unsupported( + "VllmPrometheusCollector is async — call fetch_async() from a tokio runtime".into(), + )) + } +} + +// --- pure helpers, fully unit-testable --------------------------------------- + +// `pub(crate)` so the engine registry seam can dispatch to the vLLM parser +// unchanged; the parsing logic itself is untouched. +pub(crate) fn parse(text: &str) -> InstanceSample { + let running = extract(text, KEY_RUNNING).map(|v| v.round() as u32); + let waiting = extract(text, KEY_WAITING).map(|v| v.round() as u32); + // vLLM reports kv-cache as 0..1; expose 0..100 to match the TUI's convention. + let kv = extract(text, KEY_KV_CACHE).map(|v| (v * 100.0) as f32); + // Cumulative output-token counter; the runner differences it into a rate. + let gen_tokens_total = extract(text, KEY_GEN_TOKENS); + if running.is_none() && waiting.is_none() && kv.is_none() && gen_tokens_total.is_none() { + warn!("vllm metrics payload had none of the expected keys"); + } + InstanceSample { + kv_cache_usage_pct: kv, + running_reqs: running, + waiting_reqs: waiting, + gen_tokens_total, + // vLLM uses the cumulative-counter path; no direct rate. + gen_tps: None, + } +} + +/// Extract the first sample value for a Prometheus metric. +/// +/// Matches lines of the form `metric_name{labels...} ` or +/// `metric_name `, skipping `# HELP` / `# TYPE` headers. Returns the +/// first matching numeric value, or `None` if absent / unparseable. +fn extract(text: &str, metric: &str) -> Option { + for line in text.lines() { + let line = line.trim_start(); + if line.starts_with('#') { + continue; + } + let Some(rest) = line.strip_prefix(metric) else { + continue; + }; + // The next char must be a label-opener `{` or whitespace. + // This prevents `vllm:num_requests_running` from matching e.g. + // a hypothetical `vllm:num_requests_running_total`. + let value_part = match rest.chars().next() { + Some('{') => { + let close = rest.find('}')?; + rest[close + 1..].trim_start() + } + Some(c) if c.is_whitespace() => rest.trim_start(), + None => return None, + _ => continue, + }; + let value_str = value_part.split_whitespace().next()?; + return value_str.parse::().ok(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# HELP vllm:num_requests_running Number of requests currently running on GPU. +# TYPE vllm:num_requests_running gauge +vllm:num_requests_running{model=\"deepseek-r1\"} 12.0 +# HELP vllm:num_requests_waiting Number of requests waiting to be processed. +# TYPE vllm:num_requests_waiting gauge +vllm:num_requests_waiting{model=\"deepseek-r1\"} 3.0 +# HELP vllm:gpu_cache_usage_perc GPU KV-cache usage. 1 means 100 percent usage. +# TYPE vllm:gpu_cache_usage_perc gauge +vllm:gpu_cache_usage_perc{model=\"deepseek-r1\"} 0.4231 +# HELP vllm:generation_tokens_total Number of generation tokens processed. +# TYPE vllm:generation_tokens_total counter +vllm:generation_tokens_total{model=\"deepseek-r1\"} 1234567.0 +"; + + #[test] + fn parses_full_sample() { + let s = parse(SAMPLE); + assert_eq!(s.running_reqs, Some(12)); + assert_eq!(s.waiting_reqs, Some(3)); + let kv = s.kv_cache_usage_pct.unwrap(); + assert!((kv - 42.31).abs() < 0.01, "kv was {kv}"); + assert_eq!(s.gen_tokens_total, Some(1234567.0)); + } + + #[test] + fn parses_generation_counter_without_labels() { + let s = parse("vllm:generation_tokens_total 9000\n"); + assert_eq!(s.gen_tokens_total, Some(9000.0)); + } + + #[test] + fn parses_metric_without_labels() { + let text = "vllm:num_requests_running 7\n"; + let s = parse(text); + assert_eq!(s.running_reqs, Some(7)); + } + + #[test] + fn missing_metrics_yield_none() { + let s = parse("# nothing useful here\n"); + assert_eq!(s.running_reqs, None); + assert_eq!(s.waiting_reqs, None); + assert_eq!(s.kv_cache_usage_pct, None); + assert_eq!(s.gen_tokens_total, None); + } + + #[test] + fn kv_cache_scales_zero_to_one_into_pct() { + let s = parse("vllm:gpu_cache_usage_perc 1.0\n"); + assert_eq!(s.kv_cache_usage_pct, Some(100.0)); + let s = parse("vllm:gpu_cache_usage_perc 0.0\n"); + assert_eq!(s.kv_cache_usage_pct, Some(0.0)); + } + + #[test] + fn extract_ignores_comment_lines() { + let text = "# vllm:num_requests_running is a gauge\nvllm:num_requests_running 5\n"; + assert_eq!(extract(text, KEY_RUNNING), Some(5.0)); + } + + #[test] + fn extract_handles_scientific_notation() { + let s = parse("vllm:gpu_cache_usage_perc 5.0e-2\n"); + let kv = s.kv_cache_usage_pct.unwrap(); + assert!((kv - 5.0).abs() < 0.001, "kv was {kv}"); + } + + #[test] + fn extract_does_not_match_prefix() { + // Defensive: `vllm:num_requests_running` must not match a longer key. + let text = "vllm:num_requests_running_total 99\nvllm:num_requests_running 7\n"; + assert_eq!(extract(text, KEY_RUNNING), Some(7.0)); + } + + #[test] + fn sync_fetch_returns_unsupported() { + let c = VllmPrometheusCollector::default(); + let svc = DiscoveredService { + port: Some(8000), + ..Default::default() + }; + assert!(matches!(c.fetch(&svc), Err(CollectorError::Unsupported(_)))); + } + + #[tokio::test] + async fn fetch_async_requires_port() { + let c = VllmPrometheusCollector::default(); + let svc = DiscoveredService::default(); + let r = c.fetch_async(&svc).await; + assert!(matches!(r, Err(CollectorError::Unsupported(_)))); + } +} diff --git a/crates/rocm-dash-collectors/tests/vllm_prom_http.rs b/crates/rocm-dash-collectors/tests/vllm_prom_http.rs new file mode 100644 index 00000000..57cdb6b6 --- /dev/null +++ b/crates/rocm-dash-collectors/tests/vllm_prom_http.rs @@ -0,0 +1,87 @@ +//! End-to-end test for VllmPrometheusCollector: spins up a tiny HTTP server +//! on a random local port, serves a canned `/metrics` payload, asserts that +//! `fetch_async` returns the expected parsed sample. + +use std::time::Duration; + +use rocm_dash_collectors::vllm_prom::VllmPrometheusCollector; +use rocm_dash_core::traits::DiscoveredService; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +const PAYLOAD: &str = "\ +# HELP vllm:num_requests_running running. +# TYPE vllm:num_requests_running gauge +vllm:num_requests_running{model=\"x\"} 17 +# HELP vllm:num_requests_waiting waiting. +# TYPE vllm:num_requests_waiting gauge +vllm:num_requests_waiting{model=\"x\"} 4 +# HELP vllm:gpu_cache_usage_perc kv. +# TYPE vllm:gpu_cache_usage_perc gauge +vllm:gpu_cache_usage_perc{model=\"x\"} 0.873 +"; + +#[tokio::test] +async fn fetch_async_against_local_mock_server() { + // Bind on an OS-assigned port so we don't collide with anything. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + // Serve exactly one request, then exit. + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf).await; + let body = PAYLOAD; + let resp = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/plain; version=0.0.4\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n{}", + body.len(), + body, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + + let collector = VllmPrometheusCollector::new("127.0.0.1", Duration::from_millis(2000)); + let svc = DiscoveredService { + container_id: "test".into(), + port: Some(port), + ..Default::default() + }; + let sample = collector.fetch_async(&svc).await.expect("scrape ok"); + assert_eq!(sample.running_reqs, Some(17)); + assert_eq!(sample.waiting_reqs, Some(4)); + let kv = sample.kv_cache_usage_pct.expect("kv"); + assert!((kv - 87.3).abs() < 0.1, "kv was {kv}"); + + server.await.unwrap(); +} + +#[tokio::test] +async fn fetch_async_propagates_non_200() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let _server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf).await; + let resp = "HTTP/1.1 503 Service Unavailable\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + + let collector = VllmPrometheusCollector::new("127.0.0.1", Duration::from_millis(2000)); + let svc = DiscoveredService { + container_id: "test".into(), + port: Some(port), + ..Default::default() + }; + let r = collector.fetch_async(&svc).await; + assert!(r.is_err(), "expected non-200 to be Err, got {r:?}"); +} diff --git a/crates/rocm-dash-core/Cargo.toml b/crates/rocm-dash-core/Cargo.toml new file mode 100644 index 00000000..9c38ca0c --- /dev/null +++ b/crates/rocm-dash-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rocm-dash-core" +version.workspace = true +# Transplanted from rocm-dash (EAI-6871); migrated to the workspace edition (2024). +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" +toml = "0.8" +dirs = "6" +tracing = "0.1" + +[dev-dependencies] +serde_json = "1" diff --git a/crates/rocm-dash-core/src/bench_rollup.rs b/crates/rocm-dash-core/src/bench_rollup.rs new file mode 100644 index 00000000..f7c73bd0 --- /dev/null +++ b/crates/rocm-dash-core/src/bench_rollup.rs @@ -0,0 +1,275 @@ +//! Pass^N / Pass@N trial-group rollups over benchmark rows. +//! +//! Pure: no rendering, no async. The rollup is cheap, so the TUI recomputes +//! it whenever the row set changes rather than relying on the upstream +//! `pass_n_of_n` / `pass_at_n` CSV columns. +//! +//! See `../wiki/concepts/benchmark-result-schema.md` and +//! `../wiki/entities/normalize-results.md`: rows are grouped by +//! `(cell, model, backend, concurrency)` and each group of N trials yields +//! two verdicts — strict (all N passed) and lenient (at least one passed). + +use std::collections::BTreeMap; + +use crate::bench_schema::{BenchmarkRow, PassFail}; + +/// Effective verdict for one row: prefer the rolled-up `pass_fail`, fall back +/// to the judge verdict when the rollup is `Unknown`. A row that is `Unknown` +/// under both returns `Unknown` and never counts as a pass. +#[must_use] +pub fn row_verdict(row: &BenchmarkRow) -> PassFail { + match row.pass_fail { + PassFail::Unknown => row.judge_pass_fail, + v => v, + } +} + +/// Grouping key for a trial set. Trials within a group differ only by `run` +/// (and `trial_index`); everything that defines the backend config is held +/// fixed so Pass^N / Pass@N compare like-for-like. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct RollupKey { + cell: String, + model: Option, + engine: Option, + tp: Option, + dtype: Option, + concurrency: Option, +} + +impl RollupKey { + fn from_row(r: &BenchmarkRow) -> Self { + Self { + cell: r.cell.clone(), + model: r.model.clone(), + engine: r.engine.clone(), + tp: r.tp, + dtype: r.dtype.clone(), + concurrency: r.concurrency, + } + } +} + +/// Running accumulator while folding rows into a group. +#[derive(Default)] +struct Acc { + n_trials: usize, + n_passed: usize, + ptps_sum: f64, + ptps_n: usize, + gtps_sum: f64, + gtps_n: usize, +} + +/// One trial-group rollup with Pass^N / Pass@N verdicts. +#[derive(Debug, Clone, PartialEq)] +pub struct PassNRollup { + pub cell: String, + pub model: Option, + pub engine: Option, + pub tp: Option, + pub dtype: Option, + pub concurrency: Option, + /// Number of trials in the group (N). + pub n_trials: usize, + /// How many trials passed. + pub n_passed: usize, + /// Strict verdict: all N trials passed (and N > 0). + pub pass_n_of_n: bool, + /// Lenient verdict: at least one of N trials passed. + pub pass_at_n: bool, + /// Mean `prompt_tps` over trials that reported it (`None` if none did). + pub mean_prompt_tps: Option, + /// Mean `gen_tps` over trials that reported it (`None` if none did). + pub mean_gen_tps: Option, +} + +/// Group `rows` into trial sets and compute Pass^N / Pass@N per group. +/// +/// Output is sorted deterministically by the grouping key (cell first, then +/// model where `None` sorts before any name, then engine/tp/dtype/concurrency), +/// so callers can render it directly without re-sorting. Accepts any iterator +/// of row references so callers can avoid materializing a contiguous slice. +#[must_use] +pub fn rollup_pass_n<'a, I>(rows: I) -> Vec +where + I: IntoIterator, +{ + let mut groups: BTreeMap = BTreeMap::new(); + + for r in rows { + let acc = groups.entry(RollupKey::from_row(r)).or_default(); + acc.n_trials += 1; + if row_verdict(r) == PassFail::Pass { + acc.n_passed += 1; + } + if let Some(p) = r.prompt_tps { + acc.ptps_sum += p; + acc.ptps_n += 1; + } + if let Some(g) = r.gen_tps { + acc.gtps_sum += g; + acc.gtps_n += 1; + } + } + + groups + .into_iter() + .map(|(key, acc)| PassNRollup { + cell: key.cell, + model: key.model, + engine: key.engine, + tp: key.tp, + dtype: key.dtype, + concurrency: key.concurrency, + n_trials: acc.n_trials, + n_passed: acc.n_passed, + pass_n_of_n: acc.n_trials > 0 && acc.n_passed == acc.n_trials, + pass_at_n: acc.n_passed > 0, + mean_prompt_tps: mean(acc.ptps_sum, acc.ptps_n), + mean_gen_tps: mean(acc.gtps_sum, acc.gtps_n), + }) + .collect() +} + +fn mean(sum: f64, n: usize) -> Option { + if n > 0 { Some(sum / n as f64) } else { None } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(cell: &str, run: u32, verdict: PassFail, ptps: Option) -> BenchmarkRow { + BenchmarkRow { + cell: cell.to_string(), + run, + model: Some("m".into()), + engine: Some("vllm".into()), + tp: Some(4), + dtype: Some("fp8".into()), + concurrency: Some(64), + pass_fail: verdict, + prompt_tps: ptps, + ..Default::default() + } + } + + #[test] + fn all_trials_pass_is_strict_and_lenient() { + let rows = [ + row("A", 1, PassFail::Pass, Some(100.0)), + row("A", 2, PassFail::Pass, Some(200.0)), + ]; + let out = rollup_pass_n(&rows); + assert_eq!(out.len(), 1); + let g = &out[0]; + assert_eq!(g.n_trials, 2); + assert_eq!(g.n_passed, 2); + assert!(g.pass_n_of_n); + assert!(g.pass_at_n); + assert!((g.mean_prompt_tps.unwrap() - 150.0).abs() < 1e-9); + } + + #[test] + fn mixed_trials_fail_strict_but_pass_lenient() { + let rows = [ + row("A", 1, PassFail::Fail, None), + row("A", 2, PassFail::Pass, Some(50.0)), + ]; + let g = &rollup_pass_n(&rows)[0]; + assert_eq!(g.n_trials, 2); + assert_eq!(g.n_passed, 1); + assert!(!g.pass_n_of_n, "not all trials passed"); + assert!(g.pass_at_n, "one trial passed"); + } + + #[test] + fn all_trials_fail_is_neither() { + let rows = [ + row("A", 1, PassFail::Fail, None), + row("A", 2, PassFail::Fail, None), + ]; + let g = &rollup_pass_n(&rows)[0]; + assert!(!g.pass_n_of_n); + assert!(!g.pass_at_n); + assert_eq!(g.n_passed, 0); + } + + #[test] + fn single_trial_group() { + let g = &rollup_pass_n(&[row("A", 1, PassFail::Pass, Some(10.0))])[0]; + assert_eq!(g.n_trials, 1); + assert!(g.pass_n_of_n); + assert!(g.pass_at_n); + } + + #[test] + fn distinct_configs_do_not_merge() { + let mut r2 = row("A", 1, PassFail::Pass, None); + r2.tp = Some(8); // different backend config → separate group + let rows = [row("A", 1, PassFail::Pass, None), r2]; + assert_eq!(rollup_pass_n(&rows).len(), 2); + } + + #[test] + fn unknown_verdict_does_not_count_as_pass() { + let rows = [ + row("A", 1, PassFail::Unknown, None), + row("A", 2, PassFail::Pass, None), + ]; + let g = &rollup_pass_n(&rows)[0]; + assert_eq!(g.n_trials, 2); + assert_eq!(g.n_passed, 1); + assert!(!g.pass_n_of_n); + assert!(g.pass_at_n); + } + + #[test] + fn row_verdict_falls_back_to_judge() { + let mut r = row("X", 1, PassFail::Unknown, None); + r.judge_pass_fail = PassFail::Pass; + assert_eq!(row_verdict(&r), PassFail::Pass); + r.pass_fail = PassFail::Fail; // explicit rollup wins over judge + assert_eq!(row_verdict(&r), PassFail::Fail); + } + + #[test] + fn mean_throughput_is_none_when_no_trial_reports() { + let g = &rollup_pass_n(&[row("A", 1, PassFail::Pass, None)])[0]; + assert!(g.mean_prompt_tps.is_none()); + } + + #[test] + fn output_sorted_by_cell() { + let rows = [ + row("B", 1, PassFail::Pass, None), + row("A", 1, PassFail::Pass, None), + ]; + let out = rollup_pass_n(&rows); + assert_eq!(out[0].cell, "A"); + assert_eq!(out[1].cell, "B"); + } + + #[test] + fn within_cell_none_model_sorts_before_named() { + let mut named = row("A", 1, PassFail::Pass, None); + named.model = Some("z-model".into()); + let mut unnamed = row("A", 1, PassFail::Pass, None); + unnamed.model = None; + // Pass in named-first order; output must put the None-model group first. + let out = rollup_pass_n(&[named, unnamed]); + assert_eq!(out.len(), 2); + assert_eq!(out[0].model, None); + assert_eq!(out[1].model.as_deref(), Some("z-model")); + } + + #[test] + fn accepts_borrowed_iterator() { + // Exercises the IntoIterator signature with a `.iter()` source. + let v = [row("A", 1, PassFail::Pass, Some(10.0))]; + let out = rollup_pass_n(v.iter()); + assert_eq!(out.len(), 1); + assert!(out[0].pass_n_of_n); + } +} diff --git a/crates/rocm-dash-core/src/bench_schema.rs b/crates/rocm-dash-core/src/bench_schema.rs new file mode 100644 index 00000000..09078b6d --- /dev/null +++ b/crates/rocm-dash-core/src/bench_schema.rs @@ -0,0 +1,101 @@ +//! Benchmark row schema, vendored from instinct-agent-bench. +//! See `../wiki/concepts/benchmark-result-schema.md` and `../wiki/entities/csv-emitter.md`. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PassFail { + Pass, + Fail, + #[default] + Unknown, +} + +/// The canonical row from a benchmark run. +/// Fields are a superset of the 30-col CSV from `csv_emitter.py` plus the expanded fields +/// from `normalize-results.py`. Optional where upstream sets defaults from `UNKNOWN_DEFAULTS`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct BenchmarkRow { + // identity + pub cell: String, + pub run: u32, + pub task: Option, + pub prompt_class: Option, + pub trial_index: Option, + + // platform + pub kernel: Option, + pub rocm_version: Option, + pub gpu_arch: Option, + pub hbm_per_gpu_gb: Option, + pub num_gpus: Option, + + // backend config + pub engine: Option, + pub model: Option, + pub endpoint: Option, + pub tp: Option, + pub pp: Option, + pub dtype: Option, + pub max_num_seqs: Option, + pub attention_backend: Option, + pub kv_dtype: Option, + pub spec: Option, + + // benchmark shape + pub input_len: Option, + pub output_len: Option, + pub cache_mode: Option, + pub concurrency: Option, + + // request stats + pub n_requests: Option, + pub main_prompt_n: Option, + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub out_chars: Option, + pub rc: Option, + + // throughput + pub prompt_tps: Option, + pub gen_tps: Option, + + // concurrency peaks + pub max_running_reqs: Option, + pub max_waiting_reqs: Option, + + // latency + pub ttft_ms: Option, + pub tpot_ms: Option, + pub e2e_ms: Option, + pub wall_s: Option, + + // quality (C1 assertions) + pub assertion_pass: Option, + pub assertion_fail_count: Option, + pub assertion_summary: Option, + pub safety_pass: Option, + pub safety_violations: Option, + pub safety_class: Option, + + // quality (C4 judge) + pub quality_score: Option, + pub judge_pass_fail: PassFail, + pub judge_model: Option, + + // rollup + pub resolved: Option, + pub pass_fail: PassFail, + pub failure_class: Option, + pub pass_n_of_n: Option, + pub pass_at_n: Option, + + // provenance + pub cmd: Option, + pub launcher: Option, + pub server_log: Option, + pub client_log: Option, + pub extra_args: Option, +} diff --git a/crates/rocm-dash-core/src/config.rs b/crates/rocm-dash-core/src/config.rs new file mode 100644 index 00000000..eea846e6 --- /dev/null +++ b/crates/rocm-dash-core/src/config.rs @@ -0,0 +1,292 @@ +//! TOML config loaded from `~/.config/rocm-dash/config.toml`. Missing = defaults. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tracing::{debug, warn}; + +/// Default config location: `$XDG_CONFIG_HOME/rocm-dash/config.toml` +/// (or `~/.config/rocm-dash/config.toml` on Linux). +pub fn default_config_path() -> Option { + dirs::config_dir().map(|p| p.join("rocm-dash").join("config.toml")) +} + +impl Config { + /// Load config from the given path. Returns `Ok(Config::default())` if the + /// file does not exist; `Err` only on read/parse failure. + pub fn load(path: &Path) -> Result { + if !path.exists() { + debug!(path = %path.display(), "config file not found, using defaults"); + return Ok(Self::default()); + } + let raw = std::fs::read_to_string(path).map_err(ConfigError::Io)?; + let cfg: Self = toml::from_str(&raw).map_err(|e| ConfigError::Parse(e.to_string()))?; + Ok(cfg) + } + + /// Load from the default path. Missing file → defaults; bad file → warn + defaults. + pub fn load_default() -> Self { + let Some(path) = default_config_path() else { + return Self::default(); + }; + match Self::load(&path) { + Ok(c) => c, + Err(e) => { + warn!(path = %path.display(), error = %e, "failed to load config; using defaults"); + Self::default() + } + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("parse: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + /// Default serving engine (rocm-cli parity). `None` → platform default. + /// Plain data only — `serve`/`engines` read this; no behavior lives in core. + /// Declared first so it serializes as a top-level scalar before the tables. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_engine: Option, + #[serde(default)] + pub daemon: DaemonConfig, + #[serde(default)] + pub tui: TuiConfig, + /// Per-engine user preferences (runtime/env ids), keyed by engine name. + /// Plain data mirrored from rocm-cli's `EngineUserConfig`. Serialized last + /// (a map of tables). Empty map is omitted. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub engines: BTreeMap, +} + +/// Per-engine user preferences. Plain data only (mirrors rocm-cli's +/// `EngineUserConfig`); no I/O or behavior lives in core. Immutable config +/// transforms + persistence live in the `rocm` binary, off the core boundary. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct EngineConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_env_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_installed_runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_installed_env_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonConfig { + /// `unix:/path/to.sock` or `tcp:host:port`. + pub listen: String, + /// Optional shared secret. Required for TCP, ignored for Unix sockets. + pub token: Option, + #[serde(with = "duration_secs")] + pub gpu_tick: Duration, + #[serde(with = "duration_secs")] + pub discovery_tick: Duration, + #[serde(with = "duration_secs")] + pub instance_tick: Duration, + /// Watch this directory for new normalized CSVs. + pub bench_results_dir: Option, +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + listen: "unix:/tmp/rocmdashd.sock".into(), + token: None, + gpu_tick: Duration::from_secs(1), + discovery_tick: Duration::from_secs(5), + instance_tick: Duration::from_secs(2), + bench_results_dir: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuiConfig { + pub connect: String, + pub theme: String, + /// Base URL of an OpenAI-compatible endpoint for the chat surface. + /// Plain data only — the actual HTTP/async client lives in the TUI crate + /// (core stays render/async-free, invariant `LRN-20260405-004`). + #[serde(default)] + pub chat_url: Option, + /// Model name to request on the chat endpoint. Plain data only. + #[serde(default)] + pub chat_model: Option, + /// Optional custom auth header NAME for gateways that don't use + /// `Authorization: Bearer` (e.g. `Ocp-Apim-Subscription-Key` for Azure APIM). + /// The header NAME is plain data; the secret VALUE is still env-only. + #[serde(default)] + pub chat_auth_header: Option, +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + connect: "unix:/tmp/rocmdashd.sock".into(), + theme: "default-dark".into(), + chat_url: None, + chat_model: None, + chat_auth_header: None, + } + } +} + +mod duration_secs { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; + + pub fn serialize(d: &Duration, s: S) -> Result { + d.as_secs_f64().serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let secs = f64::deserialize(d)?; + Ok(Duration::from_secs_f64(secs)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_serialize_and_round_trip() { + let c = Config::default(); + let s = toml::to_string(&c).unwrap(); + let back: Config = toml::from_str(&s).unwrap(); + assert_eq!(back.daemon.listen, c.daemon.listen); + assert_eq!(back.tui.theme, c.tui.theme); + // Chat fields default to None and survive a round-trip. + assert_eq!(back.tui.chat_url, None); + assert_eq!(back.tui.chat_model, None); + } + + #[test] + fn chat_fields_round_trip_when_set_and_default_when_absent() { + // Explicit values survive a TOML round-trip. + let mut c = Config::default(); + c.tui.chat_url = Some("http://127.0.0.1:8000".into()); + c.tui.chat_model = Some("llama-3.1-8b".into()); + c.tui.chat_auth_header = Some("Ocp-Apim-Subscription-Key".into()); + let s = toml::to_string(&c).unwrap(); + let back: Config = toml::from_str(&s).unwrap(); + assert_eq!(back.tui.chat_url.as_deref(), Some("http://127.0.0.1:8000")); + assert_eq!(back.tui.chat_model.as_deref(), Some("llama-3.1-8b")); + assert_eq!( + back.tui.chat_auth_header.as_deref(), + Some("Ocp-Apim-Subscription-Key") + ); + + // A [tui] table omitting the chat keys still parses (serde default). + let partial = "[tui]\nconnect = \"unix:/tmp/x.sock\"\ntheme = \"nord\"\n"; + let parsed: Config = toml::from_str(partial).expect("partial tui parses"); + assert_eq!(parsed.tui.chat_url, None); + assert_eq!(parsed.tui.chat_model, None); + } + + #[test] + fn engine_fields_round_trip_and_default_when_absent() { + // default_engine + per-engine prefs survive a TOML round-trip. + let mut c = Config { + default_engine: Some("vllm".into()), + ..Default::default() + }; + c.engines.insert( + "vllm".into(), + EngineConfig { + preferred_env_id: Some("env-1".into()), + last_installed_runtime_id: Some("therock-release".into()), + ..Default::default() + }, + ); + let s = toml::to_string(&c).unwrap(); + let back: Config = toml::from_str(&s).unwrap(); + assert_eq!(back.default_engine.as_deref(), Some("vllm")); + assert_eq!( + back.engines["vllm"].preferred_env_id.as_deref(), + Some("env-1") + ); + assert_eq!( + back.engines["vllm"].last_installed_runtime_id.as_deref(), + Some("therock-release") + ); + // The chat fields shipped earlier still round-trip alongside the new ones. + assert_eq!(back.tui.theme, c.tui.theme); + + // A config omitting the engine keys parses to defaults (no engine config). + let parsed: Config = + toml::from_str("[tui]\nconnect = \"unix:/tmp/x.sock\"\ntheme = \"nord\"\n") + .expect("partial config parses"); + assert_eq!(parsed.default_engine, None); + assert!(parsed.engines.is_empty()); + } + + #[test] + fn load_missing_file_returns_defaults() { + let mut p = std::env::temp_dir(); + p.push(format!("rocm-dash-no-such-{}.toml", std::process::id())); + let c = Config::load(&p).expect("missing file is not an error"); + assert_eq!(c.daemon.listen, Config::default().daemon.listen); + } + + #[test] + fn load_overrides_only_specified_fields() { + let mut p = std::env::temp_dir(); + p.push(format!( + "rocm-dash-partial-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write( + &p, + r#" +[daemon] +listen = "unix:/tmp/custom.sock" +token = "secret" +gpu_tick = 0.5 +discovery_tick = 10 +instance_tick = 3 + +[tui] +connect = "unix:/tmp/custom.sock" +theme = "default-dark" +"#, + ) + .unwrap(); + let c = Config::load(&p).expect("load"); + assert_eq!(c.daemon.listen, "unix:/tmp/custom.sock"); + assert_eq!(c.daemon.token.as_deref(), Some("secret")); + assert_eq!(c.daemon.gpu_tick.as_secs_f64(), 0.5); + assert_eq!(c.daemon.discovery_tick.as_secs(), 10); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn load_bad_toml_is_error() { + let mut p = std::env::temp_dir(); + p.push(format!( + "rocm-dash-bad-{}-{}.toml", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&p, "this is = not = valid = toml").unwrap(); + assert!(Config::load(&p).is_err()); + let _ = std::fs::remove_file(&p); + } +} diff --git a/crates/rocm-dash-core/src/efficiency.rs b/crates/rocm-dash-core/src/efficiency.rs new file mode 100644 index 00000000..d41f7215 --- /dev/null +++ b/crates/rocm-dash-core/src/efficiency.rs @@ -0,0 +1,139 @@ +//! Pure efficiency derivations (tokens-per-watt). No I/O, no rendering — this +//! is the join that turns vLLM throughput + amd-smi power into a per-instance +//! capacity number. See `../wiki/concepts/metric-registry.md`. + +use crate::metrics::GpuMetrics; + +/// Normalize a GPU identifier to a bare index for joining. +/// +/// Docker discovery records `Instance.gpu_ids` as bare indices (`"0"`, `"2"`, +/// from `HIP_VISIBLE_DEVICES`), while amd-smi records `GpuMetrics.device_id` +/// as `"gpu-0"`. Stripping the `gpu-` prefix lets the two match; any other +/// shape passes through unchanged so unexpected ids still compare by equality. +pub fn normalize_gpu_id(id: &str) -> &str { + id.strip_prefix("gpu-").unwrap_or(id) +} + +/// Whether a GPU's `device_id` is one of `gpu_ids`, after normalization. +pub(crate) fn device_in(device_id: &str, gpu_ids: &[String]) -> bool { + let dev = normalize_gpu_id(device_id); + gpu_ids.iter().any(|id| normalize_gpu_id(id) == dev) +} + +/// Whether any GPU in `gpus` belongs to `gpu_ids`. Distinguishes a failed id +/// join (no overlap) from a successful join where power happens to be zero — +/// used by the runner to warn when ids don't line up on real hardware. +pub fn gpu_ids_overlap(gpu_ids: &[String], gpus: &[GpuMetrics]) -> bool { + gpus.iter().any(|g| device_in(&g.device_id, gpu_ids)) +} + +/// Tokens-per-watt for one serving instance: generation throughput (tok/s) +/// divided by the summed power (W) of the GPUs it occupies. +/// +/// Returns `None` when throughput is unknown (`gen_tps` is `None`) or the +/// matched GPUs report no power (e.g. amd-smi unavailable, or the id join +/// found nothing) — never a divide-by-zero or a negative number. A zero +/// throughput with live GPUs is a real `Some(0.0)`, not `None`. +pub fn tokens_per_watt( + gen_tps: Option, + gpu_ids: &[String], + gpus: &[GpuMetrics], +) -> Option { + let tps = gen_tps?; + let total_w: f64 = gpus + .iter() + .filter(|g| device_in(&g.device_id, gpu_ids)) + .map(|g| g.power_w as f64) + .sum(); + if total_w > 0.0 { + Some(tps / total_w) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gpu(device_id: &str, power_w: f32) -> GpuMetrics { + GpuMetrics { + device_id: device_id.into(), + power_w, + ..GpuMetrics::default() + } + } + + #[test] + fn normalize_strips_gpu_prefix_only() { + assert_eq!(normalize_gpu_id("gpu-0"), "0"); + assert_eq!(normalize_gpu_id("gpu-7"), "7"); + assert_eq!(normalize_gpu_id("0"), "0"); + assert_eq!(normalize_gpu_id("3"), "3"); + } + + #[test] + fn joins_bare_index_to_prefixed_device_id() { + // The crux: instance "0" must match amd-smi "gpu-0". + let gpus = [gpu("gpu-0", 250.0)]; + let r = tokens_per_watt(Some(500.0), &["0".into()], &gpus); + assert_eq!(r, Some(2.0)); + } + + #[test] + fn sums_power_across_tensor_parallel_gpus() { + let gpus = [ + gpu("gpu-0", 200.0), + gpu("gpu-1", 300.0), + gpu("gpu-2", 999.0), + ]; + // Instance occupies gpus 0 and 1 → 500 W; gpu-2 excluded. + let r = tokens_per_watt(Some(1000.0), &["0".into(), "1".into()], &gpus); + assert_eq!(r, Some(2.0)); + } + + #[test] + fn none_throughput_yields_none() { + let gpus = [gpu("gpu-0", 250.0)]; + assert_eq!(tokens_per_watt(None, &["0".into()], &gpus), None); + } + + #[test] + fn zero_total_power_yields_none() { + let gpus = [gpu("gpu-0", 0.0)]; + assert_eq!(tokens_per_watt(Some(500.0), &["0".into()], &gpus), None); + } + + #[test] + fn unmatched_ids_yield_none() { + let gpus = [gpu("gpu-5", 250.0)]; + assert_eq!(tokens_per_watt(Some(500.0), &["0".into()], &gpus), None); + } + + #[test] + fn empty_gpu_ids_yield_none() { + let gpus = [gpu("gpu-0", 250.0)]; + assert_eq!(tokens_per_watt(Some(500.0), &[], &gpus), None); + } + + #[test] + fn no_gpu_telemetry_yields_none() { + assert_eq!(tokens_per_watt(Some(500.0), &["0".into()], &[]), None); + } + + #[test] + fn zero_throughput_with_live_gpus_is_some_zero() { + // Idle instance, GPUs powered: 0 tok/W is a real reading, not "-". + let gpus = [gpu("gpu-0", 250.0)]; + assert_eq!(tokens_per_watt(Some(0.0), &["0".into()], &gpus), Some(0.0)); + } + + #[test] + fn overlap_detects_match_and_mismatch() { + let gpus = [gpu("gpu-0", 250.0), gpu("gpu-1", 250.0)]; + assert!(gpu_ids_overlap(&["1".into()], &gpus)); + assert!(!gpu_ids_overlap(&["5".into()], &gpus)); + assert!(!gpu_ids_overlap(&[], &gpus)); + assert!(!gpu_ids_overlap(&["0".into()], &[])); + } +} diff --git a/crates/rocm-dash-core/src/lib.rs b/crates/rocm-dash-core/src/lib.rs new file mode 100644 index 00000000..5c70d8eb --- /dev/null +++ b/crates/rocm-dash-core/src/lib.rs @@ -0,0 +1,27 @@ +//! rocm-dash-core +//! +//! Pure types, traits, schemas, and the reducer for rocm-dash. +//! No rendering deps. No async deps at the type boundary. +//! +//! See `../wiki/concepts/tea-reducer-pattern.md` for the architectural pattern. + +#![allow(dead_code)] // scaffold; remove as modules flesh out + +pub mod bench_rollup; +pub mod bench_schema; +pub mod config; +pub mod efficiency; +pub mod metrics; +pub mod partition; +pub mod persist; +pub mod protocol; +pub mod state; +pub mod traits; +pub mod vram; + +pub use bench_rollup::{PassNRollup, rollup_pass_n, row_verdict}; +pub use bench_schema::BenchmarkRow; +pub use metrics::{GpuMetrics, Instance, Snapshot, SystemMetrics}; +pub use partition::{ComputePartitionMode, MemoryPartitionMode}; +pub use protocol::{Command, Event}; +pub use state::{SideEffect, State, StateEvent}; diff --git a/crates/rocm-dash-core/src/metrics.rs b/crates/rocm-dash-core/src/metrics.rs new file mode 100644 index 00000000..3664ae5a --- /dev/null +++ b/crates/rocm-dash-core/src/metrics.rs @@ -0,0 +1,122 @@ +//! Live metric types. snake_case + units encoded in field names. +//! See `../wiki/concepts/metric-registry.md` and `../wiki/data/metric-field-index.md`. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::partition::{ComputePartitionMode, MemoryPartitionMode}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Snapshot { + pub timestamp: DateTime, + pub host: SystemMetrics, + pub gpus: Vec, + pub gpu_system_info: Option, + pub instances: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SystemMetrics { + pub cpu_overall_pct: f32, + pub cpu_per_core_pct: Vec, + pub memory_used_mb: u64, + pub memory_total_mb: u64, + pub swap_used_mb: u64, + pub swap_total_mb: u64, + pub disk_read_bps: u64, + pub disk_write_bps: u64, + pub net_rx_bps: u64, + pub net_tx_bps: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GpuMetrics { + pub device_id: String, + pub vram_used_mb: u64, + pub vram_total_mb: u64, + pub gpu_utilization_pct: f32, + pub temperature_c: f32, + pub power_w: f32, + pub clock_mhz: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GpuSystemInfo { + pub rocm_version: Option, + pub driver_version: Option, + pub gpu_model: String, + pub physical_gpu_count: u32, + pub logical_gpu_count: u32, + pub partition_mode: ComputePartitionMode, + pub memory_partition_mode: MemoryPartitionMode, + pub compute_partition_mode: ComputePartitionMode, + pub vram_per_logical_gpu_mb: u64, + pub lemond_version: Option, + pub llama_server_build: Option, + pub ccr_version: Option, + pub llamacpp_backend: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum InstanceStatus { + Running, + Starting, + Stopped, + Error, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Instance { + pub container_id: String, + pub container_name: String, + pub status: InstanceStatus, + pub model_name: String, + pub gpu_ids: Vec, + pub partition_info: Option, + pub quantization: Option, + pub tensor_parallel_size: u32, + pub port: Option, + pub vram_used_mb: u64, + pub vram_total_mb: u64, + pub kv_cache_usage_pct: Option, + pub running_reqs: Option, + pub waiting_reqs: Option, + /// Live generation throughput (tokens/s), derived from the vLLM + /// `generation_tokens_total` counter rate. `None` until two scrapes seen. + #[serde(default)] + pub gen_tps: Option, + /// Efficiency: `gen_tps` ÷ summed power (W) of the GPUs this instance + /// occupies. `None` when throughput or GPU power telemetry is unavailable. + #[serde(default)] + pub tokens_per_watt: Option, + pub launch_args: Vec, + pub env_vars: std::collections::BTreeMap, + pub log_file: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_deserializes_without_efficiency_fields() { + // Replay back-compat: NDJSON sessions recorded before tokens_per_watt + // existed must still load, defaulting the new fields to None. + let legacy = r#"{ + "container_id": "c1", "container_name": "vllm-a", "status": "running", + "model_name": "deepseek-r1", "gpu_ids": ["0"], "partition_info": null, + "quantization": null, "tensor_parallel_size": 1, "port": 8000, + "vram_used_mb": 0, "vram_total_mb": 0, "kv_cache_usage_pct": 42.0, + "running_reqs": 3, "waiting_reqs": 0, "launch_args": [], "env_vars": {}, + "log_file": null + }"#; + let inst: Instance = serde_json::from_str(legacy).expect("legacy instance must parse"); + assert_eq!(inst.gen_tps, None); + assert_eq!(inst.tokens_per_watt, None); + assert_eq!(inst.kv_cache_usage_pct, Some(42.0)); + } +} diff --git a/crates/rocm-dash-core/src/partition.rs b/crates/rocm-dash-core/src/partition.rs new file mode 100644 index 00000000..0a1eabcf --- /dev/null +++ b/crates/rocm-dash-core/src/partition.rs @@ -0,0 +1,24 @@ +//! GPU partition mode enums. See `../wiki/concepts/gpu-partition-modes.md`. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "UPPERCASE")] +pub enum ComputePartitionMode { + Spx, + Dpx, + Qpx, + Cpx, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "UPPERCASE")] +pub enum MemoryPartitionMode { + Nps1, + Nps2, + Nps4, + #[default] + Unknown, +} diff --git a/crates/rocm-dash-core/src/persist.rs b/crates/rocm-dash-core/src/persist.rs new file mode 100644 index 00000000..4e4126cd --- /dev/null +++ b/crates/rocm-dash-core/src/persist.rs @@ -0,0 +1,64 @@ +//! On-disk session format. Used by the daemon to write each broadcast Event +//! and by the TUI replay mode to read them back. +//! +//! One [`PersistedEntry`] per NDJSON line. The wallclock `ts_us` records when +//! the entry was written (microseconds since the UNIX epoch) so the replayer +//! can pace playback against real-time deltas — including events that don't +//! carry their own timestamp. + +use serde::{Deserialize, Serialize}; + +use crate::protocol::Event; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedEntry { + /// Microseconds since UNIX epoch when the daemon wrote the entry. + pub ts_us: u64, + pub event: Event, +} + +impl PersistedEntry { + /// Stamp `event` with the current wallclock time. Falls back to 0 if the + /// system clock predates the epoch (won't happen in practice). + pub fn now(event: Event) -> Self { + let ts_us = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as u64) + .unwrap_or(0); + Self { ts_us, event } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::Event; + + #[test] + fn round_trips_through_json() { + let entry = PersistedEntry { + ts_us: 1_700_000_000_000_000, + event: Event::Welcome { + protocol_version: 1, + daemon_version: "0.1.0".into(), + host: "host".into(), + }, + }; + let s = serde_json::to_string(&entry).unwrap(); + let back: PersistedEntry = serde_json::from_str(&s).unwrap(); + assert_eq!(back.ts_us, entry.ts_us); + assert!(matches!(back.event, Event::Welcome { .. })); + } + + #[test] + fn now_stamps_current_time() { + let before = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() as u64; + let e = PersistedEntry::now(Event::Bye); + assert!(e.ts_us >= before); + // Within a reasonable window. + assert!(e.ts_us < before + 10_000_000); + } +} diff --git a/crates/rocm-dash-core/src/protocol.rs b/crates/rocm-dash-core/src/protocol.rs new file mode 100644 index 00000000..48afcf4c --- /dev/null +++ b/crates/rocm-dash-core/src/protocol.rs @@ -0,0 +1,128 @@ +//! NDJSON command/event protocol between rocmdashd and rocmdash. +//! See `../wiki/comparisons/ctux-vs-rocm-dash.md` (resolved decisions). + +use serde::{Deserialize, Serialize}; + +use crate::bench_schema::BenchmarkRow; +use crate::metrics::{Instance, Snapshot}; + +pub const PROTOCOL_VERSION: u32 = 1; + +/// Sent by the TUI to the daemon. One per line. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Command { + Hello { + protocol_version: u32, + client: String, + token: Option, + }, + Subscribe, + RequestSnapshot, + RescanInstances, + Pause, + Resume, + Goodbye, +} + +/// Sent by the daemon to the TUI. One per line. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Event { + Welcome { + protocol_version: u32, + daemon_version: String, + host: String, + }, + Snapshot(Snapshot), + InstanceDiscovered(Instance), + InstanceGone { + container_id: String, + }, + BenchmarkRowsAppended { + rows: Vec, + }, + Warning { + message: String, + }, + Error { + message: String, + }, + Bye, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_hello_roundtrips() { + let cmd = Command::Hello { + protocol_version: PROTOCOL_VERSION, + client: "rocmdash/0.1.0".into(), + token: None, + }; + let s = serde_json::to_string(&cmd).unwrap(); + let back: Command = serde_json::from_str(&s).unwrap(); + match back { + Command::Hello { + protocol_version, .. + } => assert_eq!(protocol_version, PROTOCOL_VERSION), + _ => panic!("unexpected"), + } + } + + #[test] + fn event_welcome_roundtrips() { + let ev = Event::Welcome { + protocol_version: PROTOCOL_VERSION, + daemon_version: "0.1.0".into(), + host: "gpu-host-01".into(), + }; + let s = serde_json::to_string(&ev).unwrap(); + let back: Event = serde_json::from_str(&s).unwrap(); + assert!(matches!(back, Event::Welcome { .. })); + } + + /// Regression guard: serde's internally-tagged enums reject newtype + /// variants containing sequences. Every `Event` variant must be either + /// a struct variant or wrap a struct — never `Vec` directly. + #[test] + fn every_event_variant_round_trips_through_json() { + use crate::bench_schema::BenchmarkRow; + use crate::metrics::{Instance, Snapshot}; + + let variants = vec![ + Event::Welcome { + protocol_version: 1, + daemon_version: "v".into(), + host: "h".into(), + }, + Event::Snapshot(Snapshot::default()), + Event::InstanceDiscovered(Instance::default()), + Event::InstanceGone { + container_id: "c1".into(), + }, + Event::BenchmarkRowsAppended { + rows: vec![BenchmarkRow::default()], + }, + Event::Warning { + message: "w".into(), + }, + Event::Error { + message: "e".into(), + }, + Event::Bye, + ]; + for ev in variants { + let s = serde_json::to_string(&ev).unwrap_or_else(|e| panic!("serialize {ev:?}: {e}")); + let back: Event = + serde_json::from_str(&s).unwrap_or_else(|e| panic!("deserialize {s}: {e}")); + assert_eq!( + std::mem::discriminant(&ev), + std::mem::discriminant(&back), + "variant changed across round-trip: {s}" + ); + } + } +} diff --git a/crates/rocm-dash-core/src/state.rs b/crates/rocm-dash-core/src/state.rs new file mode 100644 index 00000000..3c13f07a --- /dev/null +++ b/crates/rocm-dash-core/src/state.rs @@ -0,0 +1,460 @@ +//! Pure reducer. `State::apply(StateEvent) -> Vec`. +//! See `../wiki/concepts/tea-reducer-pattern.md`. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::bench_schema::BenchmarkRow; +use crate::metrics::{Instance, Snapshot}; + +/// Maximum sparkline history we keep in-state. +pub const SNAPSHOT_RING_CAP: usize = 300; + +/// Maximum benchmark rows kept in memory (FIFO). +pub const BENCH_RING_CAP: usize = 10_000; + +/// Maximum streamed output lines retained per job (FIFO ring). +pub const JOB_OUTPUT_RING_CAP: usize = 1_000; + +/// Identifier for a background job. A caller-supplied, human-meaningful string +/// (e.g. `"serve-llama3"`); the reducer treats it as opaque. +pub type JobId = String; + +/// Lifecycle of a background job, as the reducer sees it. A semantic enum — the +/// core never carries `tokio`/`ratatui` types (invariant `LRN-20260405-004`); +/// the async runtime that actually spawns the process lives in `rocm-dash-tui`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobStatus { + /// The job has been requested / is streaming output. + Running, + /// The process exited with this status code. + Done { code: i32 }, + /// The job failed before/while running (spawn error, non-UTF8, etc.). + Failed { message: String }, + /// The user cancelled the job; the runtime is tearing the process down. + Cancelled, +} + +/// Per-job model: the streamed output ring plus the shared cancel flag the +/// async runtime watches. `Arc` is `std` only (no `tokio`), so it +/// is safe at the core boundary. +#[derive(Debug)] +pub struct JobState { + pub cmd: String, + pub args: Vec, + pub status: JobStatus, + /// Streamed stdout/stderr lines, bounded to [`JOB_OUTPUT_RING_CAP`]. + pub output: VecDeque, + /// Set to `true` by [`StateEvent::CancelJob`]; the runtime polls it and + /// kills the child process. The runtime holds a clone handed to it via + /// [`SideEffect::SpawnJob`]. + pub cancel: Arc, +} + +impl JobState { + /// `true` once the job has reached any terminal status. + pub fn is_terminal(&self) -> bool { + !matches!(self.status, JobStatus::Running) + } + + /// The most recent output line, if any. (D4 `ring.latest()` convenience.) + pub fn latest(&self) -> Option<&str> { + self.output.back().map(String::as_str) + } +} + +#[derive(Debug, Default)] +pub struct State { + pub latest: Option, + pub history: VecDeque, + pub instances: HashMap, + pub bench_rows: VecDeque, + pub paused: bool, + /// Background jobs keyed by [`JobId`]. The async runtime feeds line/done/err + /// events back through the reducer. + pub jobs: HashMap, +} + +impl State { + /// Read-only accessor for a job by id. + pub fn job(&self, id: &str) -> Option<&JobState> { + self.jobs.get(id) + } +} + +#[derive(Debug, Clone)] +pub enum StateEvent { + Tick(Snapshot), + InstanceUpserted(Instance), + InstanceRemoved(String), + BenchmarkRows(Vec), + Pause, + Resume, + Reset, + // --- Job bridge (Phase 3 Wave 0) --- + /// Request a background job. The reducer registers it `Running` and emits + /// [`SideEffect::SpawnJob`] for the async runtime to actually launch. + StartJob { + id: JobId, + cmd: String, + args: Vec, + }, + /// One streamed output line from a running job. + JobLine { + id: JobId, + line: String, + }, + /// The job's process exited with `code`. + JobDone { + id: JobId, + code: i32, + }, + /// The job failed (spawn error / I/O error). Carries a human message. + JobErr { + id: JobId, + message: String, + }, + /// User-requested cancellation. Flips the shared cancel flag and marks the + /// job `Cancelled` immediately; the runtime tears the process down. + CancelJob(JobId), +} + +#[derive(Debug, Clone)] +pub enum SideEffect { + Persist, + BroadcastSnapshot, + BroadcastInstance(String), + BroadcastInstanceRemoved(String), + BroadcastBenchRows(usize), + // --- Job bridge (Phase 3 Wave 0) --- + /// Launch a background process. The async runtime (in `rocm-dash-tui`) + /// interprets this: spawn the child, stream lines back as + /// [`StateEvent::JobLine`], finish with [`StateEvent::JobDone`] / + /// [`StateEvent::JobErr`], and watch `cancel` to kill it early. + SpawnJob { + id: JobId, + cmd: String, + args: Vec, + cancel: Arc, + }, +} + +impl State { + pub fn apply(&mut self, event: StateEvent) -> Vec { + match event { + StateEvent::Tick(snap) => { + if self.paused { + return Vec::new(); + } + self.history.push_back(snap.clone()); + while self.history.len() > SNAPSHOT_RING_CAP { + self.history.pop_front(); + } + self.latest = Some(snap); + vec![SideEffect::BroadcastSnapshot] + } + StateEvent::InstanceUpserted(inst) => { + let id = inst.container_id.clone(); + self.instances.insert(id.clone(), inst); + vec![SideEffect::BroadcastInstance(id)] + } + StateEvent::InstanceRemoved(id) => { + self.instances.remove(&id); + vec![SideEffect::BroadcastInstanceRemoved(id)] + } + StateEvent::BenchmarkRows(rows) => { + let n = rows.len(); + for r in rows { + self.bench_rows.push_back(r); + while self.bench_rows.len() > BENCH_RING_CAP { + self.bench_rows.pop_front(); + } + } + vec![SideEffect::BroadcastBenchRows(n), SideEffect::Persist] + } + StateEvent::Pause => { + self.paused = true; + Vec::new() + } + StateEvent::Resume => { + self.paused = false; + Vec::new() + } + StateEvent::Reset => { + self.latest = None; + self.history.clear(); + self.instances.clear(); + self.bench_rows.clear(); + Vec::new() + } + StateEvent::StartJob { id, cmd, args } => { + // Idempotent: never double-spawn a job that is already running. + if self.jobs.get(&id).is_some_and(|j| !j.is_terminal()) { + return Vec::new(); + } + let cancel = Arc::new(AtomicBool::new(false)); + self.jobs.insert( + id.clone(), + JobState { + cmd: cmd.clone(), + args: args.clone(), + status: JobStatus::Running, + output: VecDeque::new(), + cancel: Arc::clone(&cancel), + }, + ); + vec![SideEffect::SpawnJob { + id, + cmd, + args, + cancel, + }] + } + StateEvent::JobLine { id, line } => { + if let Some(job) = self.jobs.get_mut(&id) { + // Drop late lines for terminal jobs (cancel/done race). + if !job.is_terminal() { + job.output.push_back(line); + while job.output.len() > JOB_OUTPUT_RING_CAP { + job.output.pop_front(); + } + } + } + Vec::new() + } + StateEvent::JobDone { id, code } => { + if let Some(job) = self.jobs.get_mut(&id) + && !job.is_terminal() + { + job.status = JobStatus::Done { code }; + } + Vec::new() + } + StateEvent::JobErr { id, message } => { + if let Some(job) = self.jobs.get_mut(&id) + && !job.is_terminal() + { + job.status = JobStatus::Failed { message }; + } + Vec::new() + } + StateEvent::CancelJob(id) => { + if let Some(job) = self.jobs.get_mut(&id) + && !job.is_terminal() + { + // Signal the runtime, then reflect the cancel immediately so + // the UI updates without waiting for the process teardown. + job.cancel.store(true, Ordering::SeqCst); + job.status = JobStatus::Cancelled; + } + Vec::new() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn snap_at(secs: i64) -> Snapshot { + Snapshot { + timestamp: chrono::DateTime::::from_timestamp(secs, 0).unwrap(), + ..Snapshot::default() + } + } + + #[test] + fn tick_pushes_history_and_broadcasts() { + let mut s = State::default(); + let fx = s.apply(StateEvent::Tick(snap_at(1))); + assert_eq!(s.history.len(), 1); + assert!(s.latest.is_some()); + assert!(matches!(fx.as_slice(), [SideEffect::BroadcastSnapshot])); + } + + #[test] + fn pause_drops_ticks() { + let mut s = State::default(); + s.apply(StateEvent::Pause); + let fx = s.apply(StateEvent::Tick(snap_at(1))); + assert!(fx.is_empty()); + assert_eq!(s.history.len(), 0); + } + + #[test] + fn history_caps_at_ring_size() { + let mut s = State::default(); + for i in 0..(SNAPSHOT_RING_CAP + 5) as i64 { + s.apply(StateEvent::Tick(snap_at(i))); + } + assert_eq!(s.history.len(), SNAPSHOT_RING_CAP); + } + + #[test] + fn instance_lifecycle() { + let mut s = State::default(); + let inst = Instance { + container_id: "c1".into(), + ..Instance::default() + }; + s.apply(StateEvent::InstanceUpserted(inst)); + assert!(s.instances.contains_key("c1")); + s.apply(StateEvent::InstanceRemoved("c1".into())); + assert!(!s.instances.contains_key("c1")); + } + + // --- Job bridge (Phase 3 Wave 0) --- + + fn start(s: &mut State, id: &str) -> Vec { + s.apply(StateEvent::StartJob { + id: id.into(), + cmd: "echo".into(), + args: vec!["hi".into()], + }) + } + + #[test] + fn start_job_registers_running_and_emits_spawn() { + let mut s = State::default(); + let fx = start(&mut s, "j1"); + let job = s.job("j1").expect("job registered"); + assert_eq!(job.status, JobStatus::Running); + assert!(job.output.is_empty()); + assert!(!job.cancel.load(Ordering::SeqCst)); + match fx.as_slice() { + [ + SideEffect::SpawnJob { + id, + cmd, + args, + cancel, + }, + ] => { + assert_eq!(id, "j1"); + assert_eq!(cmd, "echo"); + assert_eq!(args, &vec!["hi".to_string()]); + // The spawned cancel handle is the same flag the job holds. + assert!(!cancel.load(Ordering::SeqCst)); + } + other => panic!("expected one SpawnJob, got {other:?}"), + } + } + + #[test] + fn restarting_a_running_job_is_idempotent() { + let mut s = State::default(); + start(&mut s, "j1"); + let fx = start(&mut s, "j1"); + assert!(fx.is_empty(), "running job must not respawn"); + assert_eq!(s.jobs.len(), 1); + } + + #[test] + fn terminal_job_can_be_restarted() { + let mut s = State::default(); + start(&mut s, "j1"); + s.apply(StateEvent::JobDone { + id: "j1".into(), + code: 0, + }); + let fx = start(&mut s, "j1"); + assert_eq!(fx.len(), 1, "a finished job may be relaunched"); + assert_eq!(s.job("j1").unwrap().status, JobStatus::Running); + } + + #[test] + fn job_lines_append_and_are_bounded() { + let mut s = State::default(); + start(&mut s, "j1"); + for i in 0..(JOB_OUTPUT_RING_CAP + 25) { + s.apply(StateEvent::JobLine { + id: "j1".into(), + line: format!("line {i}"), + }); + } + let job = s.job("j1").unwrap(); + assert_eq!(job.output.len(), JOB_OUTPUT_RING_CAP); + // Oldest evicted; newest retained. + assert_eq!( + job.latest(), + Some(format!("line {}", JOB_OUTPUT_RING_CAP + 24)).as_deref() + ); + assert_eq!(job.output.front().map(String::as_str), Some("line 25")); + } + + #[test] + fn job_done_and_err_mark_terminal_once() { + let mut s = State::default(); + start(&mut s, "ok"); + s.apply(StateEvent::JobDone { + id: "ok".into(), + code: 3, + }); + assert_eq!(s.job("ok").unwrap().status, JobStatus::Done { code: 3 }); + // A later JobErr must not overwrite a terminal status. + s.apply(StateEvent::JobErr { + id: "ok".into(), + message: "ignored".into(), + }); + assert_eq!(s.job("ok").unwrap().status, JobStatus::Done { code: 3 }); + + let mut s = State::default(); + start(&mut s, "bad"); + s.apply(StateEvent::JobErr { + id: "bad".into(), + message: "boom".into(), + }); + assert_eq!( + s.job("bad").unwrap().status, + JobStatus::Failed { + message: "boom".into() + } + ); + } + + #[test] + fn cancel_sets_flag_and_status_and_drops_late_lines() { + let mut s = State::default(); + let fx = start(&mut s, "j1"); + // Grab the runtime's cancel handle from the emitted effect. + let cancel = match fx.into_iter().next().unwrap() { + SideEffect::SpawnJob { cancel, .. } => cancel, + _ => unreachable!(), + }; + s.apply(StateEvent::CancelJob("j1".into())); + assert_eq!(s.job("j1").unwrap().status, JobStatus::Cancelled); + assert!(cancel.load(Ordering::SeqCst), "runtime flag must be set"); + + // Lines arriving after cancellation are dropped. + s.apply(StateEvent::JobLine { + id: "j1".into(), + line: "late".into(), + }); + assert!(s.job("j1").unwrap().output.is_empty()); + // A racing JobDone does not resurrect a cancelled job. + s.apply(StateEvent::JobDone { + id: "j1".into(), + code: 0, + }); + assert_eq!(s.job("j1").unwrap().status, JobStatus::Cancelled); + } + + #[test] + fn events_for_unknown_jobs_are_ignored() { + let mut s = State::default(); + // None of these panic or create a job. + s.apply(StateEvent::JobLine { + id: "ghost".into(), + line: "x".into(), + }); + s.apply(StateEvent::JobDone { + id: "ghost".into(), + code: 0, + }); + s.apply(StateEvent::CancelJob("ghost".into())); + assert!(s.jobs.is_empty()); + } +} diff --git a/crates/rocm-dash-core/src/traits.rs b/crates/rocm-dash-core/src/traits.rs new file mode 100644 index 00000000..d7a13b45 --- /dev/null +++ b/crates/rocm-dash-core/src/traits.rs @@ -0,0 +1,160 @@ +//! Collector traits. Impls live in `rocm-dash-collectors`. +//! These are sync + `Send` so collector code can decide its own async/blocking strategy. + +use thiserror::Error; + +use crate::bench_schema::BenchmarkRow; +use crate::metrics::{GpuMetrics, GpuSystemInfo, Instance}; + +#[derive(Debug, Error)] +pub enum CollectorError { + #[error("collector not available on this host: {0}")] + Unsupported(String), + #[error("transport: {0}")] + Transport(String), + #[error("parse: {0}")] + Parse(String), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("other: {0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Discrete GPU device summary, separate from per-tick `GpuMetrics`. +#[derive(Debug, Clone)] +pub struct GpuDevice { + pub device_id: String, + pub model: String, + pub vram_total_mb: u64, +} + +/// Per-process GPU resource usage. +#[derive(Debug, Clone)] +pub struct GpuProcess { + pub pid: u32, + pub device_id: String, + pub vram_used_mb: u64, +} + +pub trait GpuCollector: Send + Sync { + fn name(&self) -> &'static str; + fn devices(&self) -> Result>; + fn metrics(&self) -> Result>; + fn system_info(&self) -> Result; + fn processes(&self) -> Result>; +} + +/// A discovered serving process or container. +#[derive(Debug, Clone, Default)] +pub struct DiscoveredService { + pub container_id: String, + pub container_name: String, + pub model_name: String, + pub gpu_ids: Vec, + pub port: Option, + pub tensor_parallel_size: u32, + pub dtype: Option, + /// Effective quantization for the instance. Prefers vLLM's explicit + /// `--quantization` flag; falls back to `--dtype` when no explicit + /// quantization was passed (the closest signal vLLM exposes). `dtype` + /// retains the `--dtype`-only value independently. + pub quantization: Option, + pub launch_args: Vec, + pub env_vars: std::collections::BTreeMap, + pub pid: u32, + pub log_file: Option, +} + +pub trait ServiceDiscovery: Send + Sync { + fn name(&self) -> &'static str; + fn discover(&self) -> Result>; +} + +/// Per-tick liveness metrics for one instance (KV cache, request counts). +#[derive(Debug, Clone, Default)] +pub struct InstanceSample { + pub kv_cache_usage_pct: Option, + pub running_reqs: Option, + pub waiting_reqs: Option, + /// Cumulative vLLM `generation_tokens_total` counter at scrape time. The + /// runner differences successive readings into `Instance.gen_tps`; this + /// raw counter is not stored on `Instance`. + pub gen_tokens_total: Option, + /// Instantaneous generation throughput (tokens/sec) reported **directly** by + /// an engine that exposes a rate rather than a cumulative counter (e.g. + /// Lemonade `/api/v1/stats.tokens_per_second`). vLLM leaves this `None` and + /// uses the `gen_tokens_total` delta path. Plain data — no async/HTTP/render. + pub gen_tps: Option, +} + +pub trait InstanceMetrics: Send + Sync { + fn name(&self) -> &'static str; + /// Returns `None` when the service is reachable but reports no data, + /// `Err` when unreachable. + fn fetch(&self, service: &DiscoveredService) -> Result; +} + +/// Tail benchmark results from disk (CSV + JSON sidecars). +pub trait BenchTailer: Send + Sync { + fn name(&self) -> &'static str; + /// Drain any rows that appeared since the last call. + fn drain(&mut self) -> Result>; +} + +/// Merge per-instance metadata + live sample into a finished `Instance`. +pub fn merge_instance( + svc: &DiscoveredService, + sample: &InstanceSample, + vram_used_mb: u64, + vram_total_mb: u64, +) -> Instance { + Instance { + container_id: svc.container_id.clone(), + container_name: svc.container_name.clone(), + status: crate::metrics::InstanceStatus::Running, + model_name: svc.model_name.clone(), + gpu_ids: svc.gpu_ids.clone(), + partition_info: None, + quantization: svc.quantization.clone(), + tensor_parallel_size: svc.tensor_parallel_size, + port: svc.port, + vram_used_mb, + vram_total_mb, + kv_cache_usage_pct: sample.kv_cache_usage_pct, + running_reqs: sample.running_reqs, + waiting_reqs: sample.waiting_reqs, + // Engines that report an instantaneous rate (Lemonade) carry it on the + // sample; counter-based engines (vLLM) leave it `None` and the runner + // fills `gen_tps` from the `gen_tokens_total` delta. `tokens_per_watt` + // is derived at snapshot assembly. + gen_tps: sample.gen_tps, + tokens_per_watt: None, + launch_args: svc.launch_args.clone(), + env_vars: svc.env_vars.clone(), + log_file: svc.log_file.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_instance_propagates_quantization() { + let svc = DiscoveredService { + quantization: Some("fp8".into()), + ..Default::default() + }; + let inst = merge_instance(&svc, &InstanceSample::default(), 0, 0); + assert_eq!(inst.quantization.as_deref(), Some("fp8")); + } + + #[test] + fn merge_instance_quantization_none_when_svc_none() { + let svc = DiscoveredService::default(); + let inst = merge_instance(&svc, &InstanceSample::default(), 0, 0); + assert_eq!(inst.quantization, None); + } +} diff --git a/crates/rocm-dash-core/src/vram.rs b/crates/rocm-dash-core/src/vram.rs new file mode 100644 index 00000000..4254d703 --- /dev/null +++ b/crates/rocm-dash-core/src/vram.rs @@ -0,0 +1,169 @@ +//! Pure VRAM attribution helpers. No I/O, no rendering — the `/proc` read and +//! the amd-smi subprocess that feed these live in the collectors/daemon; here +//! we only do the math that turns per-process VRAM + device totals into a +//! per-instance `(used, total)` pair. +//! +//! The attribution has two paths, in priority order: +//! 1. **Per-process** — sum the VRAM of the GPU processes that resolve (via an +//! injected PID→container resolver) to the instance's container id. +//! 2. **Device-summed fallback** — when no per-process entry exists for the +//! instance, attribute the device-summed *used* over the instance's GPUs. +//! `total` is always device-summed over the instance's GPUs. +//! +//! Graceful degradation: an instance that matches nothing (empty `gpu_ids`, +//! no container entry — e.g. Lemonade) resolves to `(0, 0)`, never a panic and +//! never a confidently-wrong number. See `../wiki/concepts/metric-registry.md`. + +use std::collections::HashMap; + +use crate::efficiency::device_in; +use crate::metrics::GpuMetrics; +use crate::traits::GpuProcess; + +/// Device-summed `(used_mb, total_mb)` over the GPUs in `gpu_ids`. +/// +/// Reuses the `efficiency` join (`"0"` ↔ `"gpu-0"` normalization), so it does +/// not duplicate the id-matching logic. Empty `gpu_ids` or no matching GPU → +/// `(0, 0)`. +pub fn device_vram(gpu_ids: &[String], gpus: &[GpuMetrics]) -> (u64, u64) { + gpus.iter() + .filter(|g| device_in(&g.device_id, gpu_ids)) + .fold((0u64, 0u64), |(used, total), g| { + (used + g.vram_used_mb, total + g.vram_total_mb) + }) +} + +/// Aggregate per-process VRAM into a per-container `used_mb` map. +/// +/// `resolve` maps a process host PID to its container id (the runner injects a +/// `/proc//cgroup` reader; tests inject a fixture closure). Processes +/// whose PID resolves to `None` are skipped. Multiple processes of the same +/// container accumulate; different containers stay separate. This keeps all +/// `/proc` I/O out of core while leaving the aggregation fully unit-testable. +pub fn aggregate_process_vram Option>( + procs: &[GpuProcess], + resolve: F, +) -> HashMap { + let mut per_container: HashMap = HashMap::new(); + for p in procs { + if let Some(container_id) = resolve(p.pid) { + *per_container.entry(container_id).or_insert(0) += p.vram_used_mb; + } + } + per_container +} + +/// Resolve one instance's `(used_mb, total_mb)`. +/// +/// `total` is always the device-summed total over `gpu_ids`. `used` is the +/// per-container value from `per_container_used` when present (the per-process +/// path) and otherwise the device-summed used over `gpu_ids` (the fallback). +pub fn resolve_instance_vram( + container_id: &str, + gpu_ids: &[String], + gpus: &[GpuMetrics], + per_container_used: &HashMap, +) -> (u64, u64) { + let (device_used, total) = device_vram(gpu_ids, gpus); + let used = match per_container_used.get(container_id) { + Some(&v) => v, + None => device_used, + }; + (used, total) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gpu(device_id: &str, used: u64, total: u64) -> GpuMetrics { + GpuMetrics { + device_id: device_id.into(), + vram_used_mb: used, + vram_total_mb: total, + ..GpuMetrics::default() + } + } + + fn proc(pid: u32, device_id: &str, used: u64) -> GpuProcess { + GpuProcess { + pid, + device_id: device_id.into(), + vram_used_mb: used, + } + } + + #[test] + fn device_vram_sums_used_and_total_with_normalization() { + // Instance "0","1" must match amd-smi "gpu-0","gpu-1". + let gpus = [ + gpu("gpu-0", 1000, 8000), + gpu("gpu-1", 2000, 8000), + gpu("gpu-2", 9999, 8000), // excluded + ]; + let (used, total) = device_vram(&["0".into(), "1".into()], &gpus); + assert_eq!((used, total), (3000, 16000)); + } + + #[test] + fn device_vram_empty_and_no_match_are_zero() { + let gpus = [gpu("gpu-0", 1000, 8000)]; + assert_eq!(device_vram(&[], &gpus), (0, 0)); + assert_eq!(device_vram(&["5".into()], &gpus), (0, 0)); + assert_eq!(device_vram(&["0".into()], &[]), (0, 0)); + } + + #[test] + fn aggregate_sums_per_container_and_skips_unresolved() { + let procs = [ + proc(10, "gpu-0", 500), + proc(11, "gpu-0", 700), // same container as pid 10 + proc(20, "gpu-1", 300), // different container + proc(99, "gpu-2", 999), // resolves to None → skipped + ]; + let resolve = |pid: u32| match pid { + 10 | 11 => Some("container-a".to_string()), + 20 => Some("container-b".to_string()), + _ => None, + }; + let map = aggregate_process_vram(&procs, resolve); + assert_eq!(map.get("container-a"), Some(&1200)); + assert_eq!(map.get("container-b"), Some(&300)); + assert_eq!(map.len(), 2); // pid 99 skipped + } + + #[test] + fn aggregate_empty_when_nothing_resolves() { + let procs = [proc(1, "gpu-0", 500)]; + let map = aggregate_process_vram(&procs, |_| None); + assert!(map.is_empty()); + } + + #[test] + fn resolve_uses_per_container_map_when_present() { + let gpus = [gpu("gpu-0", 1000, 8000)]; + let mut per = HashMap::new(); + per.insert("abc".to_string(), 4242); + // used comes from the map (per-process), total from device sum. + let (used, total) = resolve_instance_vram("abc", &["0".into()], &gpus, &per); + assert_eq!((used, total), (4242, 8000)); + } + + #[test] + fn resolve_falls_back_to_device_used_when_absent() { + let gpus = [gpu("gpu-0", 1000, 8000), gpu("gpu-1", 2000, 8000)]; + let per = HashMap::new(); // container not present + let (used, total) = + resolve_instance_vram("missing", &["0".into(), "1".into()], &gpus, &per); + assert_eq!((used, total), (3000, 16000)); + } + + #[test] + fn resolve_lemonade_empty_gpu_ids_stays_zero() { + // Empty gpu_ids + not in map → (0,0), no panic (Lemonade case). + let gpus = [gpu("gpu-0", 1000, 8000)]; + let per = HashMap::new(); + let (used, total) = resolve_instance_vram("lemonade-synthetic", &[], &gpus, &per); + assert_eq!((used, total), (0, 0)); + } +} diff --git a/crates/rocm-dash-daemon/Cargo.toml b/crates/rocm-dash-daemon/Cargo.toml new file mode 100644 index 00000000..e01e8fe7 --- /dev/null +++ b/crates/rocm-dash-daemon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "rocm-dash-daemon" +version.workspace = true +# Transplanted from rocm-dash (EAI-6871); migrated to the workspace edition (2024). +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lib] +name = "rocm_dash_daemon" +path = "src/lib.rs" + +[dependencies] +rocm-dash-core = { path = "../rocm-dash-core" } +rocm-dash-collectors = { path = "../rocm-dash-collectors" } +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +color-eyre = "0.6" +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/rocm-dash-daemon/src/bench_ring.rs b/crates/rocm-dash-daemon/src/bench_ring.rs new file mode 100644 index 00000000..dbfe3bd6 --- /dev/null +++ b/crates/rocm-dash-daemon/src/bench_ring.rs @@ -0,0 +1,67 @@ +//! Rolling benchmark-row history kept by the daemon so late-joining clients can hydrate. + +use std::collections::VecDeque; + +use rocm_dash_core::bench_schema::BenchmarkRow; + +pub struct BenchRing { + cap: usize, + inner: VecDeque, +} + +impl BenchRing { + pub fn new(cap: usize) -> Self { + Self { + cap, + inner: VecDeque::with_capacity(cap), + } + } + + pub fn push(&mut self, row: BenchmarkRow) { + if self.inner.len() == self.cap { + self.inner.pop_front(); + } + self.inner.push_back(row); + } + + pub fn iter(&self) -> impl Iterator { + self.inner.iter() + } + + /// Cloned snapshot of the ring contents, oldest first. + pub fn snapshot(&self) -> Vec { + self.inner.iter().cloned().collect() + } + + pub fn len(&self) -> usize { + self.inner.len() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(run: u32) -> BenchmarkRow { + BenchmarkRow { + run, + ..BenchmarkRow::default() + } + } + + #[test] + fn caps_at_capacity() { + let mut r = BenchRing::new(3); + for i in 0..10 { + r.push(row(i)); + } + assert_eq!(r.len(), 3); + let snap = r.snapshot(); + assert_eq!(snap.first().unwrap().run, 7); + assert_eq!(snap.last().unwrap().run, 9); + } +} diff --git a/crates/rocm-dash-daemon/src/demo.rs b/crates/rocm-dash-daemon/src/demo.rs new file mode 100644 index 00000000..3e90ed9c --- /dev/null +++ b/crates/rocm-dash-daemon/src/demo.rs @@ -0,0 +1,761 @@ +//! Synthetic-session generator (single source of truth for demo data). +//! +//! Simulates a single 8× AMD Instinct MI355X node serving inference traffic +//! through three vLLM containers. Output is the same `PersistedEntry` NDJSON +//! the daemon writes when `--persist-dir` is set, so `rocm replay` (and +//! `rocm demo`) play it back unmodified. +//! +//! Deterministic: the same [`DemoOptions`] always produce byte-identical +//! output (seeded xorshift jitter + a fixed wallclock anchor), which the +//! tests below pin as a hard invariant. `rocm gen-demo`, `rocm demo`, and the +//! marketing screenshot/cast examples all consume this one generator. + +use std::collections::BTreeMap; +use std::f64::consts::PI; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use chrono::{TimeZone, Utc}; +use rocm_dash_core::bench_schema::{BenchmarkRow, PassFail}; +use rocm_dash_core::metrics::{ + GpuMetrics, GpuSystemInfo, Instance, InstanceStatus, Snapshot, SystemMetrics, +}; +use rocm_dash_core::partition::{ComputePartitionMode, MemoryPartitionMode}; +use rocm_dash_core::persist::PersistedEntry; +use rocm_dash_core::protocol::{Event, PROTOCOL_VERSION}; + +/// Parameters for a synthetic session. Same options → byte-identical output. +#[derive(Debug, Clone, Copy)] +pub struct DemoOptions { + /// Number of seconds to simulate (one snapshot per second). + pub seconds: u64, + /// Seed for the deterministic synthetic data. Same seed → same bytes. + pub seed: u64, +} + +impl Default for DemoOptions { + fn default() -> Self { + // Historical defaults from the former `gen_demo` example. + Self { + seconds: 120, + seed: 42, + } + } +} + +/// Write a synthetic session as `PersistedEntry` NDJSON to `w`. +/// +/// The byte stream is identical for identical [`DemoOptions`]. +pub fn generate_to_writer(opts: &DemoOptions, w: &mut W) -> anyhow::Result<()> { + // Guard absurd durations: keep the i64 second-of-epoch math (and the run + // time / output size) bounded. Far beyond any real demo; rejects values + // that would otherwise overflow the snapshot timestamp. Does not affect the + // byte stream for any realistic `seconds`. + anyhow::ensure!( + opts.seconds <= MAX_DEMO_SECONDS, + "seconds={} exceeds the maximum of {} (~115 days at 1Hz)", + opts.seconds, + MAX_DEMO_SECONDS + ); + + // Anchor wallclock to a fixed point so the file is byte-stable for any + // given seed — useful for golden test fixtures and reproducible demos. + // Wallclock used for replay pacing; the recording's `Snapshot.timestamp` + // uses the same base. + let wall_base_us: u64 = 1_750_000_000_000_000; // 2025-06-15T22:13:20Z + let base_secs: i64 = 1_750_000_000; + let mut seed = opts.seed; + + // Synthetic Welcome — the replayer emits its own ClientMsg::Connected, + // but having a Welcome in-file makes the recording self-describing. + let welcome = PersistedEntry { + ts_us: wall_base_us, + event: Event::Welcome { + protocol_version: PROTOCOL_VERSION, + daemon_version: "demo/gen_demo".into(), + host: "demo-mi355x-node".into(), + }, + }; + write_line(w, &welcome)?; + + // Schedule bench rows by their offset_s. + let mut bench = demo_bench_rows(); + bench.sort_by_key(|(t, _)| *t); + let mut bench_iter = bench.into_iter().peekable(); + + for t in 0..opts.seconds { + let t_s = t as f64; + // Wallclock advances 1s per snapshot. + let ts_us = wall_base_us + (t * 1_000_000); + let snap = build_snapshot(t_s, base_secs, &mut seed); + + // Snapshot. + let snap_entry = PersistedEntry { + ts_us, + event: Event::Snapshot(snap), + }; + write_line(w, &snap_entry)?; + + // Instance discovery events on the very first tick. + if t == 0 { + for inst in build_instances(0.0, &mut seed) { + let ev = PersistedEntry { + ts_us: ts_us + 1_000, + event: Event::InstanceDiscovered(inst), + }; + write_line(w, &ev)?; + } + } + + // Any bench rows scheduled at this second. + while bench_iter + .peek() + .map(|(t_off, _)| *t_off == t) + .unwrap_or(false) + { + let (_t_off, row) = bench_iter.next().unwrap(); + let ev = PersistedEntry { + ts_us: ts_us + 500_000, // half a tick after the snapshot + event: Event::BenchmarkRowsAppended { rows: vec![row] }, + }; + write_line(w, &ev)?; + } + } + + // Final Bye marker — replay's EOF path doesn't need this but it makes + // the file complete-looking when curled. + let bye = PersistedEntry { + ts_us: wall_base_us + opts.seconds * 1_000_000 + 100_000, + event: Event::Bye, + }; + write_line(w, &bye)?; + + w.flush()?; + Ok(()) +} + +/// Write a synthetic session to `path` (via a buffered file writer). +/// +/// On a mid-write failure the partial file is removed so a failed run never +/// leaves a corrupt session behind. +pub fn generate_file(opts: &DemoOptions, path: &Path) -> anyhow::Result<()> { + let mut w = BufWriter::new(File::create(path)?); + if let Err(e) = generate_to_writer(opts, &mut w) { + drop(w); + let _ = std::fs::remove_file(path); + return Err(e); + } + Ok(()) +} + +/// Upper bound on `DemoOptions::seconds` — ~115 days at 1Hz. Keeps generation +/// O(seconds) bounded and the snapshot-timestamp math safely within `i64`. +const MAX_DEMO_SECONDS: u64 = 10_000_000; + +const HOST_CORES: usize = 192; +const NUM_GPUS: usize = 8; +const VRAM_TOTAL_MB: u64 = 288 * 1024; // MI355X: 288 GiB HBM3E per GPU +const MEMORY_TOTAL_MB: u64 = 1024 * 1024; // 1 TiB host RAM +const SWAP_TOTAL_MB: u64 = 0; // typical for an inference box + +/// Inference container template — a "tenant" of the node. +struct ContainerSpec { + container_id: &'static str, + container_name: &'static str, + model_name: &'static str, + gpu_ids: &'static [&'static str], + tp: u32, + port: u16, + /// Phase of the activity sinusoid in seconds — staggers the three + /// instances so the heatmap and per-GPU sparklines aren't synchronised. + phase_s: f64, + /// Mean kv-cache utilization (0..100) around which it oscillates. + kv_mean: f32, + /// Amplitude of the kv-cache oscillation. + kv_amp: f32, + /// Mean GPU utilization 0..100 used by the GPUs assigned to this container. + util_mean: f32, + util_amp: f32, + /// Typical request concurrency (running_reqs mean). + req_mean: u32, + /// Quantization label shown in the detail modal. + quantization: Option<&'static str>, + /// Launch args excerpt rendered in the instance card / detail modal. + launch_args: &'static [&'static str], +} + +const CONTAINERS: &[ContainerSpec] = &[ + ContainerSpec { + container_id: "c-llama3-70b-fp8-tp4", + container_name: "vllm-llama3-70b", + model_name: "meta-llama/Llama-3-70B-Instruct", + gpu_ids: &["0", "1", "2", "3"], + tp: 4, + port: 8000, + phase_s: 0.0, + kv_mean: 55.0, + kv_amp: 30.0, + util_mean: 78.0, + util_amp: 15.0, + req_mean: 18, + quantization: Some("fp8"), + launch_args: &[ + "--model", + "meta-llama/Llama-3-70B-Instruct", + "--tensor-parallel-size", + "4", + "--dtype", + "fp8", + "--max-num-seqs", + "256", + ], + }, + ContainerSpec { + container_id: "c-mixtral-8x7b-bf16-tp2", + container_name: "vllm-mixtral-8x7b", + model_name: "mistralai/Mixtral-8x7B-Instruct-v0.1", + gpu_ids: &["4", "5"], + tp: 2, + port: 8001, + phase_s: 17.0, + kv_mean: 35.0, + kv_amp: 25.0, + util_mean: 62.0, + util_amp: 22.0, + req_mean: 8, + quantization: None, + launch_args: &[ + "--model", + "mistralai/Mixtral-8x7B-Instruct-v0.1", + "--tensor-parallel-size", + "2", + "--dtype", + "bfloat16", + "--max-num-seqs", + "128", + ], + }, + ContainerSpec { + container_id: "c-deepseek-r1-fp8-tp2", + container_name: "vllm-deepseek-r1", + model_name: "deepseek-ai/DeepSeek-R1", + gpu_ids: &["6", "7"], + tp: 2, + port: 8002, + phase_s: 33.0, + kv_mean: 70.0, + kv_amp: 22.0, + util_mean: 85.0, + util_amp: 12.0, + req_mean: 12, + quantization: Some("fp8"), + launch_args: &[ + "--model", + "deepseek-ai/DeepSeek-R1", + "--tensor-parallel-size", + "2", + "--dtype", + "fp8", + "--max-num-seqs", + "192", + "--enable-prefix-caching", + ], + }, +]; + +/// Bench rows we sprinkle through the timeline. Each tuple is +/// (offset_s, BenchmarkRow). Times are picked so the Bench tab fills +/// gradually rather than all at once. +fn demo_bench_rows() -> Vec<(u64, BenchmarkRow)> { + let mk = |cell: &str, + run: u32, + model: &str, + tp: u32, + dtype: &str, + wall: f64, + ptps: f64, + gtps: f64, + pass: PassFail| + -> BenchmarkRow { + BenchmarkRow { + cell: cell.into(), + run, + model: Some(model.into()), + engine: Some("vllm".into()), + endpoint: Some("http://127.0.0.1:8000".to_string()), + tp: Some(tp), + dtype: Some(dtype.into()), + attention_backend: Some("flash-attn-3".into()), + concurrency: Some(64), + max_num_seqs: Some(256), + wall_s: Some(wall), + n_requests: Some(64), + prompt_tokens: Some(64 * 1024), + completion_tokens: Some(64 * 512), + prompt_tps: Some(ptps), + gen_tps: Some(gtps), + max_running_reqs: Some(48), + max_waiting_reqs: Some(4), + out_chars: Some(64 * 1500), + pass_fail: pass, + judge_pass_fail: pass, + assertion_pass: Some(matches!(pass, PassFail::Pass)), + ..Default::default() + } + }; + + vec![ + ( + 8, + mk( + "O-arch-llama3", + 1, + "meta-llama/Llama-3-70B-Instruct", + 4, + "fp8", + 42.3, + 1240.5, + 68.2, + PassFail::Pass, + ), + ), + ( + 18, + mk( + "O-arch-mixtral", + 1, + "mistralai/Mixtral-8x7B-Instruct-v0.1", + 2, + "bf16", + 55.7, + 980.1, + 54.6, + PassFail::Pass, + ), + ), + ( + 32, + mk( + "S-bench-deepseek", + 1, + "deepseek-ai/DeepSeek-R1", + 2, + "fp8", + 38.9, + 1610.4, + 82.1, + PassFail::Pass, + ), + ), + ( + 47, + mk( + "O-arch-llama3", + 2, + "meta-llama/Llama-3-70B-Instruct", + 4, + "fp8", + 41.8, + 1268.2, + 69.4, + PassFail::Pass, + ), + ), + ( + 62, + mk( + "S-bench-deepseek", + 2, + "deepseek-ai/DeepSeek-R1", + 2, + "fp8", + 40.1, + 1572.0, + 80.7, + PassFail::Pass, + ), + ), + ( + 74, + mk( + "S-stress-mixtral", + 1, + "mistralai/Mixtral-8x7B-Instruct-v0.1", + 2, + "bf16", + 92.6, + 612.0, + 31.8, + PassFail::Fail, + ), + ), + ( + 98, + mk( + "S-stress-mixtral", + 2, + "mistralai/Mixtral-8x7B-Instruct-v0.1", + 2, + "bf16", + 58.0, + 890.0, + 49.0, + PassFail::Pass, + ), + ), + ( + 89, + mk( + "O-arch-mixtral", + 2, + "mistralai/Mixtral-8x7B-Instruct-v0.1", + 2, + "bf16", + 54.2, + 1006.3, + 56.0, + PassFail::Pass, + ), + ), + ( + 104, + mk( + "R-reproduce", + 1, + "deepseek-ai/DeepSeek-R1", + 2, + "fp8", + 37.4, + 1644.8, + 84.0, + PassFail::Pass, + ), + ), + ] +} + +/// Deterministic pseudo-random oscillator. Same seed + t → same value. +fn osc(t_s: f64, period_s: f64, phase_s: f64, mean: f32, amp: f32) -> f32 { + let theta = 2.0 * PI * (t_s + phase_s) / period_s; + mean + amp * theta.sin() as f32 +} + +/// Tiny xorshift32 used to give each value a small jitter so the trace +/// doesn't look like a perfect sine wave. Returns 0.0..1.0. +fn jitter(seed: &mut u64) -> f32 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + ((*seed % 1000) as f32) / 1000.0 +} + +fn build_host(t_s: f64, seed: &mut u64) -> SystemMetrics { + // Inference is GPU-bound, so CPU stays moderate with some noise. + let agg = osc(t_s, 23.0, 0.0, 28.0, 8.0) + (jitter(seed) - 0.5) * 4.0; + let agg = agg.clamp(0.0, 100.0); + + let mut per_core: Vec = Vec::with_capacity(HOST_CORES); + for i in 0..HOST_CORES { + // Each core's load is the aggregate ± a per-core offset so the + // CoreBars widget shows visible variation across cores. + let off = osc(t_s, 7.0 + (i as f64 % 11.0), i as f64 * 0.7, 0.0, 18.0); + let v = (agg + off + (jitter(seed) - 0.5) * 6.0).clamp(0.0, 100.0); + per_core.push(v); + } + + let mem_used = (MEMORY_TOTAL_MB as f64 * 0.42 * (1.0 + 0.02 * (t_s / 31.0).sin())) as u64; + + SystemMetrics { + cpu_overall_pct: agg, + cpu_per_core_pct: per_core, + memory_used_mb: mem_used, + memory_total_mb: MEMORY_TOTAL_MB, + swap_used_mb: 0, + swap_total_mb: SWAP_TOTAL_MB, + disk_read_bps: (jitter(seed) * 50_000_000.0) as u64, + disk_write_bps: (jitter(seed) * 20_000_000.0) as u64, + net_rx_bps: (jitter(seed) * 80_000_000.0) as u64, + net_tx_bps: (jitter(seed) * 80_000_000.0) as u64, + } +} + +fn build_gpu(idx: usize, t_s: f64, seed: &mut u64) -> GpuMetrics { + // Find which container owns this GPU and inherit its activity profile. + let (util_mean, util_amp, phase) = CONTAINERS + .iter() + .find(|c| { + c.gpu_ids + .iter() + .any(|g| g.parse::().ok() == Some(idx)) + }) + .map(|c| (c.util_mean, c.util_amp, c.phase_s)) + .unwrap_or((20.0, 5.0, 0.0)); + + let util = + osc(t_s, 11.0, phase + idx as f64 * 1.3, util_mean, util_amp) + (jitter(seed) - 0.5) * 5.0; + let util = util.clamp(0.0, 100.0); + + // Temp tracks util with thermal lag — small offset, narrower swing. + let temp = 55.0 + 0.22 * util + (jitter(seed) - 0.5) * 1.5; + // Power tracks util more linearly. MI355X TDP ~ 750W. + let power = 220.0 + 5.2 * util + (jitter(seed) - 0.5) * 18.0; + // VRAM is mostly the model weights once loaded. + let vram_used = + (VRAM_TOTAL_MB as f64 * 0.74 + (jitter(seed) as f64 * VRAM_TOTAL_MB as f64 * 0.02)) as u64; + + GpuMetrics { + device_id: format!("gpu-{idx}"), + vram_used_mb: vram_used, + vram_total_mb: VRAM_TOTAL_MB, + gpu_utilization_pct: util, + temperature_c: temp, + power_w: power, + clock_mhz: Some(1850.0 + (jitter(seed) - 0.5) * 80.0), + } +} + +fn build_sysinfo() -> GpuSystemInfo { + GpuSystemInfo { + rocm_version: Some("7.13.0".into()), + driver_version: Some("6.10.5".into()), + gpu_model: "AMD Instinct MI355X".into(), + physical_gpu_count: NUM_GPUS as u32, + logical_gpu_count: NUM_GPUS as u32, + partition_mode: ComputePartitionMode::Spx, + memory_partition_mode: MemoryPartitionMode::Nps1, + compute_partition_mode: ComputePartitionMode::Spx, + vram_per_logical_gpu_mb: VRAM_TOTAL_MB, + lemond_version: None, + llama_server_build: None, + ccr_version: None, + llamacpp_backend: None, + } +} + +fn build_instances(t_s: f64, seed: &mut u64) -> Vec { + CONTAINERS + .iter() + .map(|c| { + let kv = osc(t_s, 13.0, c.phase_s, c.kv_mean, c.kv_amp) + (jitter(seed) - 0.5) * 4.0; + let kv = kv.clamp(0.0, 100.0); + let running = ((c.req_mean as f32 + + osc(t_s, 17.0, c.phase_s, 0.0, c.req_mean as f32 * 0.6)) + .max(0.0)) as u32; + // Waiting reqs occasionally burst. + let waiting_base = osc(t_s, 29.0, c.phase_s + 5.0, 1.5, 1.5).max(0.0); + let waiting = if jitter(seed) > 0.92 { + (waiting_base + 4.0) as u32 + } else { + waiting_base as u32 + }; + + // VRAM per instance = sum of its assigned GPUs' weight footprint. + let vram_used = c.gpu_ids.len() as u64 * (VRAM_TOTAL_MB * 74 / 100); + let vram_total = c.gpu_ids.len() as u64 * VRAM_TOTAL_MB; + + let mut env_vars = BTreeMap::new(); + env_vars.insert("HIP_VISIBLE_DEVICES".into(), c.gpu_ids.join(",")); + env_vars.insert("VLLM_USE_TRITON_FLASH_ATTN".into(), "1".into()); + env_vars.insert("HF_HOME".into(), "/data/hf".into()); + + // Generation throughput scales with the instance's GPU count and + // oscillates with load; tokens_per_watt is filled in build_snapshot + // once GPU power is known (mirrors the runner's assembly step). + let gen_tps = + (c.gpu_ids.len() as f64 * osc(t_s, 19.0, c.phase_s, 180.0, 90.0) as f64).max(0.0); + + Instance { + container_id: c.container_id.into(), + container_name: c.container_name.into(), + status: InstanceStatus::Running, + model_name: c.model_name.into(), + gpu_ids: c.gpu_ids.iter().map(|s| (*s).to_string()).collect(), + partition_info: Some("SPX/NPS1".into()), + quantization: c.quantization.map(|s| s.to_string()), + tensor_parallel_size: c.tp, + port: Some(c.port), + vram_used_mb: vram_used, + vram_total_mb: vram_total, + kv_cache_usage_pct: Some(kv), + running_reqs: Some(running), + waiting_reqs: Some(waiting), + gen_tps: Some(gen_tps), + tokens_per_watt: None, + launch_args: c.launch_args.iter().map(|s| (*s).to_string()).collect(), + env_vars, + log_file: Some(format!("/var/log/vllm/{}.log", c.container_name)), + } + }) + .collect() +} + +fn build_snapshot(t_s: f64, base_secs: i64, seed: &mut u64) -> Snapshot { + let timestamp = Utc.timestamp_opt(base_secs + t_s as i64, 0).unwrap(); + let gpus: Vec<_> = (0..NUM_GPUS).map(|i| build_gpu(i, t_s, seed)).collect(); + let mut instances = build_instances(t_s, seed); + // Mirror the runner: derive tokens_per_watt once both throughput and GPU + // power are in hand, exercising the same id-normalizing join. + for inst in &mut instances { + inst.tokens_per_watt = + rocm_dash_core::efficiency::tokens_per_watt(inst.gen_tps, &inst.gpu_ids, &gpus); + } + Snapshot { + timestamp, + host: build_host(t_s, seed), + gpus, + gpu_system_info: Some(build_sysinfo()), + instances, + warnings: Vec::new(), + } +} + +fn write_line(w: &mut W, entry: &PersistedEntry) -> anyhow::Result<()> { + let line = serde_json::to_string(entry)?; + w.write_all(line.as_bytes())?; + w.write_all(b"\n")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Same options must yield byte-identical output (hard invariant). + #[test] + fn deterministic_same_seed_byte_identical() { + let opts = DemoOptions { + seconds: 30, + seed: 7, + }; + let mut a = Vec::new(); + let mut b = Vec::new(); + generate_to_writer(&opts, &mut a).unwrap(); + generate_to_writer(&opts, &mut b).unwrap(); + assert_eq!(a, b, "identical DemoOptions produced differing bytes"); + assert!(!a.is_empty(), "generator produced no output"); + } + + /// Different seeds should diverge (sanity: the seed actually matters). + #[test] + fn different_seed_differs() { + let mut a = Vec::new(); + let mut b = Vec::new(); + generate_to_writer( + &DemoOptions { + seconds: 20, + seed: 1, + }, + &mut a, + ) + .unwrap(); + generate_to_writer( + &DemoOptions { + seconds: 20, + seed: 2, + }, + &mut b, + ) + .unwrap(); + assert_ne!(a, b, "distinct seeds produced identical output"); + } + + /// Every emitted line must deserialize as a `PersistedEntry`, the first + /// event must be a `Welcome`, and the stream must end with `Event::Bye`. + #[test] + fn replay_parity_lines_deserialize_and_end_with_bye() { + let opts = DemoOptions { + seconds: 15, + seed: 42, + }; + let mut buf = Vec::new(); + generate_to_writer(&opts, &mut buf).unwrap(); + let text = String::from_utf8(buf).unwrap(); + + let entries: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + serde_json::from_str::(l) + .unwrap_or_else(|e| panic!("line failed to deserialize: {e}\n{l}")) + }) + .collect(); + + assert!(!entries.is_empty(), "no entries produced"); + assert!( + matches!(entries.first().unwrap().event, Event::Welcome { .. }), + "stream did not start with Welcome" + ); + assert!( + matches!(entries.last().unwrap().event, Event::Bye), + "stream did not end with Bye" + ); + // At least one Snapshot-bearing entry exists. + assert!( + entries + .iter() + .any(|e| matches!(e.event, Event::Snapshot(_))), + "no Snapshot events in stream" + ); + } + + /// seconds = 0 still yields a valid minimal stream (Welcome + Bye). + #[test] + fn seconds_zero_is_valid_minimal_stream() { + let opts = DemoOptions { + seconds: 0, + seed: 42, + }; + let mut buf = Vec::new(); + generate_to_writer(&opts, &mut buf).unwrap(); + let text = String::from_utf8(buf).unwrap(); + let entries: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).unwrap()) + .collect(); + assert_eq!(entries.len(), 2, "expected exactly Welcome + Bye"); + assert!(matches!(entries[0].event, Event::Welcome { .. })); + assert!(matches!(entries[1].event, Event::Bye)); + } + + /// `generate_file` writes the same bytes as `generate_to_writer`. + #[test] + fn generate_file_matches_writer() { + let opts = DemoOptions { + seconds: 10, + seed: 3, + }; + let mut expected = Vec::new(); + generate_to_writer(&opts, &mut expected).unwrap(); + + let mut path = std::env::temp_dir(); + path.push(format!( + "rocm-dash-demo-test-{}-{}.ndjson", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + generate_file(&opts, &path).unwrap(); + let on_disk = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(on_disk, expected, "generate_file diverged from writer"); + } + + /// Absurd durations are rejected with a clean error, not a panic. + #[test] + fn seconds_over_cap_errors_cleanly() { + let opts = DemoOptions { + seconds: MAX_DEMO_SECONDS + 1, + seed: 42, + }; + let mut buf = Vec::new(); + let err = generate_to_writer(&opts, &mut buf).unwrap_err(); + assert!( + err.to_string().contains("exceeds the maximum"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/rocm-dash-daemon/src/lib.rs b/crates/rocm-dash-daemon/src/lib.rs new file mode 100644 index 00000000..85908c89 --- /dev/null +++ b/crates/rocm-dash-daemon/src/lib.rs @@ -0,0 +1,22 @@ +//! rocm-dash-daemon — daemon library. +//! +//! Runs on the GPU host and serves NDJSON snapshot/bench streams to `rocm` +//! TUI clients over a Unix socket. The composition-root binary (`rocm`) +//! drives this library via its `serve` subcommand. + +#![allow(dead_code)] + +pub mod bench_ring; +pub mod demo; +pub mod persist; +pub mod registry; +pub mod runner; +pub mod server; +pub mod snapshot_ring; +pub mod transport; + +/// Daemon crate version, surfaced in the `Welcome` handshake. +pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub use runner::RunnerOptions; +pub use server::run; diff --git a/crates/rocm-dash-daemon/src/persist.rs b/crates/rocm-dash-daemon/src/persist.rs new file mode 100644 index 00000000..ae5e408f --- /dev/null +++ b/crates/rocm-dash-daemon/src/persist.rs @@ -0,0 +1,116 @@ +//! Append-only NDJSON session writer. +//! +//! Each daemon session opens exactly one file under `--persist-dir`, named +//! `session-YYYYMMDD-HHMMSS.ndjson`. Every broadcast [`Event`] is written as +//! a [`PersistedEntry`] line with a wallclock timestamp. The TUI's +//! `--replay ` mode reads these back. +//! +//! Buffered + line-flushed: under power loss we lose at most the in-flight +//! tick. The writer is intentionally simple — no rotation, no compression. +//! One file per session means each file is self-contained and trivially +//! shareable / portable. + +use std::fs::{File, OpenOptions, create_dir_all}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use rocm_dash_core::persist::PersistedEntry; +use rocm_dash_core::protocol::Event; + +pub struct SessionWriter { + path: PathBuf, + writer: BufWriter, +} + +impl SessionWriter { + /// Create a fresh session file in `dir`. The directory is created if it + /// does not exist. Returns an error if the file cannot be created. + pub fn new(dir: &Path) -> std::io::Result { + create_dir_all(dir)?; + let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let path = dir.join(format!("session-{stamp}.ndjson")); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + Ok(Self { + path, + writer: BufWriter::new(file), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Stamp `event` with the current time and append as a single NDJSON line. + /// Flushes after each write so the file survives a crash. + pub fn append(&mut self, event: &Event) -> std::io::Result<()> { + let entry = PersistedEntry::now(event.clone()); + let line = serde_json::to_string(&entry).map_err(std::io::Error::other)?; + self.writer.write_all(line.as_bytes())?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + + fn tmp_dir(label: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "rocm-dash-persist-test-{}-{}-{label}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p + } + + #[test] + fn creates_a_session_file_under_dir() { + let dir = tmp_dir("new"); + let writer = SessionWriter::new(&dir).expect("create"); + assert!(writer.path().exists()); + assert!( + writer + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("session-") + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn appends_serializable_lines() { + let dir = tmp_dir("append"); + let mut writer = SessionWriter::new(&dir).expect("create"); + let ev1 = Event::Welcome { + protocol_version: 1, + daemon_version: "0.0.0".into(), + host: "h".into(), + }; + let ev2 = Event::Bye; + writer.append(&ev1).expect("append 1"); + writer.append(&ev2).expect("append 2"); + + let path = writer.path().to_path_buf(); + drop(writer); + let file = File::open(&path).unwrap(); + let lines: Vec = BufReader::new(file).lines().map(Result::unwrap).collect(); + assert_eq!(lines.len(), 2); + let e1: PersistedEntry = serde_json::from_str(&lines[0]).unwrap(); + let e2: PersistedEntry = serde_json::from_str(&lines[1]).unwrap(); + assert!(matches!(e1.event, Event::Welcome { .. })); + assert!(matches!(e2.event, Event::Bye)); + assert!(e2.ts_us >= e1.ts_us); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/rocm-dash-daemon/src/registry.rs b/crates/rocm-dash-daemon/src/registry.rs new file mode 100644 index 00000000..ba2b64db --- /dev/null +++ b/crates/rocm-dash-daemon/src/registry.rs @@ -0,0 +1,382 @@ +//! Managed-service registry → scrape-target seam (EAI-6871 D7). +//! +//! The rocm-cli `serve` lifecycle records every managed engine as a +//! `ManagedServiceRecord` JSON file under `AppPaths::services_dir()`. This +//! module reads that registry and converts live records into the collector +//! pipeline's existing [`DiscoveredService`] shape, so a model served via +//! `rocm serve` shows up in the dashboard and gets scraped for `gen_tps` — +//! without going through Docker discovery. +//! +//! The on-disk record is read through [`ServiceRecord`], a minimal mirror of the +//! stable fields of `rocm_core::ManagedServiceRecord` (every field +//! `#[serde(default)]`, unknown fields ignored). This keeps the async daemon +//! decoupled from `rocm-core`'s sync/ureq surface and its churn; the shape is +//! drift-tolerant because rocm-cli serializes the record as a JSON object and we +//! read only the fields the scrape seam needs. +//! +//! **Port authority:** the bound port comes from the registry record +//! (`ServiceRecord.port`), never a hardcoded default. The engine defaults +//! (`EngineKind::default_port`, `docker.rs`, `lemonade.rs`) remain only as a +//! fallback for *unmanaged/external* discovery. +//! +//! **Co-located scope (known limitation):** scrape targets carry only a port; +//! the runner's vLLM collector scrapes `opts.vllm_metrics_host` (default +//! `127.0.0.1`), so `record.host` is honored only for the co-located daemon +//! (the same assumption the Docker-discovery path already makes). A managed +//! service on a non-loopback host is surfaced but its metrics are scraped at the +//! local address. Per-host scrape targeting is tracked as a D7 follow-up. + +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use crate::runner::instance_from_discovered; +use rocm_dash_collectors::engine_registry::EngineKind; +use rocm_dash_core::metrics::Instance; +use rocm_dash_core::traits::DiscoveredService; +use serde::Deserialize; + +/// Minimal read-only view of a rocm-cli `ManagedServiceRecord` on disk. Mirrors +/// the stable subset the scrape seam needs; unknown fields (manifest paths, +/// pids, recipe json, …) are ignored. Every field defaults so partial/older +/// records still parse. +#[derive(Debug, Clone, Deserialize)] +pub struct ServiceRecord { + #[serde(default)] + pub service_id: String, + #[serde(default)] + pub engine: String, + #[serde(default)] + pub model_ref: String, + #[serde(default)] + pub canonical_model_id: String, + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: u16, + #[serde(default)] + pub status: String, + #[serde(default)] + pub created_at_unix_ms: u128, +} + +/// Service statuses worth scraping. Matches the live set the rocm-cli supervisor +/// overlays onto a record (`ready`/`running`/`starting`); a `failed`/`stopped` +/// record is skipped so we never poll a dead port. +pub fn is_scrapeable_status(status: &str) -> bool { + matches!( + status.trim().to_ascii_lowercase().as_str(), + "ready" | "running" | "starting" + ) +} + +/// Load every managed-service record under `services_dir`, newest first. +/// +/// Best-effort and side-effect-free: a missing directory yields an empty list, +/// and an individual unreadable/malformed `*.json` is skipped rather than +/// failing the whole load (mirrors the canonical rocm-cli `load_managed_services` +/// semantics, minus the engine-state status overlay which the supervisor owns). +pub fn load_service_records(services_dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(services_dir) else { + return Vec::new(); + }; + let mut records = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|v| v.to_str()) != Some("json") { + continue; + } + if let Ok(bytes) = fs::read(&path) + && let Ok(record) = serde_json::from_slice::(&bytes) + { + records.push(record); + } + } + records.sort_by_key(|record| std::cmp::Reverse(record.created_at_unix_ms)); + records +} + +/// Convert a managed-service record into a [`DiscoveredService`] for the +/// collector pipeline, or `None` when the record is not in a scrapeable state. +/// +/// The port is taken verbatim from the registry (`record.port`) — the registry +/// is the authority, never a hardcoded default. `gpu_ids` is left empty because +/// the record carries no concrete device ids; VRAM and tokens/W attribution +/// degrade gracefully (device-summed / `None`) for managed services until a +/// richer device mapping is recorded. +pub fn discovered_from_record(record: &ServiceRecord) -> Option { + if !is_scrapeable_status(&record.status) { + return None; + } + // A `#[serde(default)]` u16 missing from the JSON deserializes to 0; an + // unbound/malformed port is not a real scrape target (would poll :0 forever). + if record.port == 0 { + return None; + } + let model_name = if record.model_ref.is_empty() { + record.canonical_model_id.clone() + } else { + record.model_ref.clone() + }; + Some(DiscoveredService { + container_id: record.service_id.clone(), + container_name: record.service_id.clone(), + model_name, + port: Some(record.port), + ..Default::default() + }) +} + +/// The engine kind for a record's `engine` label, if recognized. Lets the +/// scrape pipeline pick the right per-engine parser (vLLM Prometheus vs +/// Lemonade JSON) for a managed service. +pub fn engine_kind_for(record: &ServiceRecord) -> Option { + EngineKind::from_label(&record.engine) +} + +/// The result of turning a batch of registry records into dashboard instances: +/// the live instances to upsert, their ids (`seen`), and the subset whose engine +/// is NOT vLLM (`non_vllm`, excluded from the vLLM Prometheus scrape so they are +/// not mis-parsed). Pure — the runner applies it (upsert/broadcast/Gone-diff). +#[derive(Debug, Default)] +pub struct ManagedDiscovery { + pub instances: Vec, + pub seen: HashSet, + pub non_vllm: HashSet, +} + +/// Convert the loaded registry records into a [`ManagedDiscovery`] — the pure +/// core of the daemon's managed-service discovery tick. Non-scrapeable records +/// (bad status / port 0) are dropped; vLLM-engine services flow to the +/// Prometheus scrape, others are flagged in `non_vllm`. +/// +/// NOTE: gen_tps for managed **non-vLLM** engines (e.g. Lemonade) is not yet +/// wired — they appear in the dashboard but are excluded from the vLLM scrape +/// and not routed to a per-engine collector here. vLLM (the Phase-2 acceptance +/// target) is fully wired. Non-vLLM managed scraping is a D7 follow-up. +pub fn discover_managed_services(records: &[ServiceRecord]) -> ManagedDiscovery { + let mut out = ManagedDiscovery::default(); + for record in records { + let Some(svc) = discovered_from_record(record) else { + continue; + }; + out.seen.insert(svc.container_id.clone()); + if engine_kind_for(record) != Some(EngineKind::Vllm) { + out.non_vllm.insert(svc.container_id.clone()); + } + out.instances.push(instance_from_discovered(&svc)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runner::gen_tps_from_delta; + use chrono::{DateTime, TimeZone, Utc}; + use std::path::PathBuf; + + fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(secs, 0).unwrap() + } + + fn test_dir(name: &str) -> PathBuf { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join(".rocm-work") + .join("tests") + .join("daemon-registry") + .join(format!("{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A `ManagedServiceRecord`-shaped JSON object (extra rocm-cli fields the + /// seam ignores are included to prove unknown-field tolerance). + fn record_json( + service_id: &str, + engine: &str, + port: u16, + status: &str, + created: u128, + ) -> String { + format!( + r#"{{ + "service_id": "{service_id}", + "engine": "{engine}", + "model_ref": "meta-llama/Llama-3.1-8B", + "canonical_model_id": "llama-3.1-8b", + "host": "127.0.0.1", + "port": {port}, + "endpoint_url": "http://127.0.0.1:{port}/v1", + "mode": "managed", + "status": "{status}", + "supervisor_pid": 4242, + "manifest_path": "/tmp/{service_id}.json", + "log_path": "/tmp/{service_id}.log", + "engine_state_path": "/tmp/{service_id}-state.json", + "created_at_unix_ms": {created} + }}"# + ) + } + + #[test] + fn load_missing_services_dir_is_empty() { + let dir = test_dir("absent").join("nope"); + assert!(load_service_records(&dir).is_empty()); + } + + #[test] + fn load_reads_json_records_newest_first() { + let dir = test_dir("load-sort"); + fs::write( + dir.join("a.json"), + record_json("svc-a", "vllm", 8000, "running", 100), + ) + .unwrap(); + fs::write( + dir.join("b.json"), + record_json("svc-b", "vllm", 8001, "running", 200), + ) + .unwrap(); + // A non-json file and a malformed json are skipped, not fatal. + fs::write(dir.join("notes.txt"), "ignore me").unwrap(); + fs::write(dir.join("bad.json"), "{ not valid").unwrap(); + + let records = load_service_records(&dir); + assert_eq!(records.len(), 2); + // Newest (created=200) first. + assert_eq!(records[0].service_id, "svc-b"); + assert_eq!(records[1].service_id, "svc-a"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn discovered_uses_registry_port_as_authority() { + let dir = test_dir("port-authority"); + // A non-default port (8123, not the vLLM hardcoded 8000) must survive. + fs::write( + dir.join("svc.json"), + record_json("svc-hot", "vllm", 8123, "running", 1), + ) + .unwrap(); + let records = load_service_records(&dir); + let svc = discovered_from_record(&records[0]).expect("live record converts"); + assert_eq!(svc.port, Some(8123)); + assert_eq!(svc.container_id, "svc-hot"); + assert_eq!(svc.model_name, "meta-llama/Llama-3.1-8B"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn discovered_skips_non_scrapeable_status() { + for dead in ["stopped", "failed", "exited", ""] { + let rec: ServiceRecord = + serde_json::from_str(&record_json("svc", "vllm", 8000, dead, 1)).unwrap(); + assert!( + discovered_from_record(&rec).is_none(), + "status {dead:?} must not be scraped" + ); + } + for live in ["running", "ready", "starting", "RUNNING"] { + let rec: ServiceRecord = + serde_json::from_str(&record_json("svc", "vllm", 8000, live, 1)).unwrap(); + assert!( + discovered_from_record(&rec).is_some(), + "status {live:?} must be scraped" + ); + } + } + + #[test] + fn discovered_skips_zero_port() { + // A record whose port field is absent (serde default 0) is not a real + // scrape target even when its status is live. + let rec: ServiceRecord = + serde_json::from_str(&record_json("svc", "vllm", 0, "running", 1)).unwrap(); + assert!(discovered_from_record(&rec).is_none()); + // A missing `port` key entirely → default 0 → also skipped. + let rec: ServiceRecord = + serde_json::from_str(r#"{"service_id":"x","engine":"vllm","status":"running"}"#) + .unwrap(); + assert_eq!(rec.port, 0); + assert!(discovered_from_record(&rec).is_none()); + } + + #[test] + fn discover_managed_services_classifies_and_filters() { + let records: Vec = [ + record_json("svc-vllm", "vllm", 8000, "running", 3), + record_json("svc-lemon", "lemonade", 13305, "ready", 2), + record_json("svc-dead", "vllm", 9000, "stopped", 1), + record_json("svc-noport", "vllm", 0, "running", 0), + ] + .iter() + .map(|j| serde_json::from_str(j).unwrap()) + .collect(); + + let disc = discover_managed_services(&records); + // Two live, scrapeable instances (dead + zero-port dropped). + assert_eq!(disc.instances.len(), 2); + assert!(disc.seen.contains("svc-vllm")); + assert!(disc.seen.contains("svc-lemon")); + assert!(!disc.seen.contains("svc-dead")); + assert!(!disc.seen.contains("svc-noport")); + // Only the Lemonade service is excluded from the vLLM scrape. + assert!(disc.non_vllm.contains("svc-lemon")); + assert!(!disc.non_vllm.contains("svc-vllm")); + // Instances carry the registry port + Running status. + let vllm = disc + .instances + .iter() + .find(|i| i.container_id == "svc-vllm") + .unwrap(); + assert_eq!(vllm.port, Some(8000)); + assert_eq!( + vllm.status, + rocm_dash_core::metrics::InstanceStatus::Running + ); + } + + /// Deterministic end-to-end of the registry→scrape→dashboard data path (no + /// GPU required): a `rocm serve`-style managed record on disk → loaded → + /// converted to a scrape target on the **registry port** → the engine-kind + /// parser turns two successive vLLM Prometheus bodies into a cumulative + /// counter → the runner's delta yields a live `gen_tps`. This is the + /// test-level proof for Phase-2 acceptance criterion 3 (no ROCm GPU here). + #[test] + fn serve_record_to_live_gen_tps_end_to_end() { + let dir = test_dir("e2e-gen-tps"); + fs::write( + dir.join("svc.json"), + record_json("svc-llama", "vllm", 8123, "running", 1), + ) + .unwrap(); + + // 1. Registry → scrape target (port authority is the registry's 8123). + let records = load_service_records(&dir); + assert_eq!(records.len(), 1); + let svc = discovered_from_record(&records[0]).expect("live record"); + let scrape_port = svc.port.expect("registry port"); + assert_eq!(scrape_port, 8123); + + // 2. Engine-kind seam picks the vLLM Prometheus parser for this record. + let kind = engine_kind_for(&records[0]).expect("known engine"); + assert_eq!(kind, EngineKind::Vllm); + + // 3. Two successive scrapes of that port's /metrics → cumulative counter. + let body_t0 = "vllm:generation_tokens_total 1000\n"; + let body_t1 = "vllm:generation_tokens_total 1400\n"; + let s0 = kind.parse_sample(body_t0); + let s1 = kind.parse_sample(body_t1); + let c0 = s0.gen_tokens_total.expect("counter at t0"); + let c1 = s1.gen_tokens_total.expect("counter at t1"); + + // 4. Runner delta → live gen_tps: 400 tokens over 2 s = 200 tok/s. + let gen_tps = gen_tps_from_delta(Some((c0, at(10))), c1, at(12)); + assert_eq!(gen_tps, Some(200.0)); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/rocm-dash-daemon/src/runner.rs b/crates/rocm-dash-daemon/src/runner.rs new file mode 100644 index 00000000..b6878d8d --- /dev/null +++ b/crates/rocm-dash-daemon/src/runner.rs @@ -0,0 +1,743 @@ +//! Owner task: drives collectors on tick cadences and broadcasts Snapshots. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::bench_ring::BenchRing; +use crate::persist::SessionWriter; +use crate::snapshot_ring::SnapshotRing; + +use chrono::{DateTime, Utc}; +use rocm_dash_collectors::amd_smi::AmdSmiCollector; +use rocm_dash_collectors::bench_tail::CsvBenchTailer; +use rocm_dash_collectors::docker::DockerDiscovery; +use rocm_dash_collectors::host::HostCollector; +use rocm_dash_collectors::lemonade::LemonadeCollector; +use rocm_dash_collectors::parallel::parallel_scrape; +use rocm_dash_collectors::vllm_prom::VllmPrometheusCollector; +use rocm_dash_core::metrics::{GpuMetrics, GpuSystemInfo, Instance, InstanceStatus, Snapshot}; +use rocm_dash_core::protocol::Event; +use rocm_dash_core::state::{State, StateEvent}; +use rocm_dash_core::traits::{BenchTailer, DiscoveredService, InstanceSample, merge_instance}; +use tokio::sync::broadcast; +use tokio::time::{MissedTickBehavior, interval}; +use tracing::{info, trace, warn}; + +/// Refresh GPU system info (versions, partition modes) every N seconds. +const SYSINFO_REFRESH_SECS: u64 = 30; + +/// Options for `run_loop`. Mirrors daemon CLI flags + config. +#[derive(Debug, Clone)] +pub struct RunnerOptions { + pub bench_csv: Option, + pub enable_docker: bool, + pub image_patterns: Option, + pub gpu_tick: Duration, + pub discovery_tick: Duration, + pub instance_tick: Duration, + /// Disable the per-instance Prometheus scrape (otherwise runs whenever + /// docker discovery is enabled). + pub disable_vllm_metrics: bool, + /// Hostname to scrape; `127.0.0.1` for the typical co-located daemon. + pub vllm_metrics_host: String, + /// Opt-in probe-based Lemonade discovery: when enabled, probe a local + /// Lemonade endpoint each discovery tick and surface it as an Instance. + /// Off by default so hosts with no Lemonade server never poll a dead port. + pub enable_lemonade: bool, + /// Lemonade endpoint host + port (defaults `127.0.0.1:13305`). + pub lemonade_host: String, + pub lemonade_port: u16, + /// When set, every broadcast Event is appended to + /// `{persist_dir}/session-{ts}.ndjson` for offline replay. + pub persist_dir: Option, + /// rocm-cli managed-service registry directory (`AppPaths::services_dir()`). + /// When set, the daemon reads `ManagedServiceRecord`s each discovery tick and + /// surfaces live ones as scrape targets (port from the registry), so a model + /// served via `rocm serve` appears in the dashboard with live `gen_tps` + /// without Docker discovery (EAI-6871 D7). Off by default. + pub services_dir: Option, +} + +impl Default for RunnerOptions { + fn default() -> Self { + Self { + bench_csv: None, + enable_docker: false, + image_patterns: None, + gpu_tick: Duration::from_secs(1), + discovery_tick: Duration::from_secs(5), + instance_tick: Duration::from_secs(2), + disable_vllm_metrics: false, + vllm_metrics_host: "127.0.0.1".into(), + enable_lemonade: false, + lemonade_host: "127.0.0.1".into(), + lemonade_port: rocm_dash_collectors::lemonade::LEMONADE_PORT, + persist_dir: None, + services_dir: None, + } + } +} + +#[derive(Default)] +pub struct Runner { + pub state: State, +} + +/// Loop forever: tick host + gpu metrics + bench rows, apply through reducer, broadcast. +/// +/// `tick_override` lets tests run faster than `opts.gpu_tick`; production passes +/// `None` so the configured cadence drives the loop. +pub async fn run_loop( + tick_override: Option, + tx: broadcast::Sender, + ring: Arc>, + bench_ring: Arc>, + persist: Option>>, + opts: RunnerOptions, +) { + let mut runner = Runner::default(); + let mut host = HostCollector::new(); + let tick = tick_override.unwrap_or(opts.gpu_tick); + let mut ticker = interval(tick); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Compute multipliers vs the gpu tick. + let discovery_ticks = ticks_per(opts.discovery_tick, tick); + let instance_ticks = ticks_per(opts.instance_tick, tick); + let sysinfo_refresh_ticks = ticks_per(Duration::from_secs(SYSINFO_REFRESH_SECS), tick); + + let vllm = if opts.enable_docker && !opts.disable_vllm_metrics { + Some(Arc::new(VllmPrometheusCollector::new( + opts.vllm_metrics_host.clone(), + Duration::from_millis(1500), + ))) + } else { + None + }; + + // Opt-in Lemonade discovery (probe-based; off unless a Lemonade endpoint is + // configured). Distinct from Docker/vLLM discovery — a local server, not a + // container — so it is tracked separately from `known_instances`. + let lemonade = if opts.enable_lemonade { + info!( + host = %opts.lemonade_host, + port = opts.lemonade_port, + "lemonade discovery enabled" + ); + Some(LemonadeCollector::new( + opts.lemonade_host.clone(), + opts.lemonade_port, + Duration::from_millis(1500), + )) + } else { + None + }; + // The id of the currently-live Lemonade instance, if any. + let mut lemonade_id: Option = None; + + let mut bench = opts.bench_csv.as_ref().map(|p| { + info!(path = %p.display(), "tailing benchmark CSV"); + CsvBenchTailer::new(p.clone()) + }); + + let docker = if opts.enable_docker { + match DockerDiscovery::detect(opts.image_patterns.clone()).await { + Some(d) => { + info!("docker discovery enabled"); + Some(d) + } + None => { + warn!("docker discovery requested but daemon unreachable; disabled"); + None + } + } + } else { + None + }; + let mut known_instances: HashSet = HashSet::new(); + // Managed-service registry (EAI-6871 D7): ids surfaced from the rocm-cli + // `serve` registry, and the subset whose engine is NOT vLLM (excluded from + // the vLLM Prometheus scrape so they aren't mis-parsed). + let mut known_services: HashSet = HashSet::new(); + let mut managed_non_vllm: HashSet = HashSet::new(); + // Previous `generation_tokens_total` reading per instance, for rate calc. + let mut prev_gen_tokens: HashMap)> = HashMap::new(); + // Per-container VRAM (MB) from the last amd-smi `process` scrape. Refreshed + // on the instance cadence and reused every tick so the attributed value is + // stable between scrapes (mirrors how GPU power drives tokens_per_watt). + let mut per_container_used: HashMap = HashMap::new(); + + let gpu = AmdSmiCollector::detect().await; + let mut gpu_system_info: Option = if let Some(g) = &gpu { + let info = g.system_info().await; + info!( + gpus = info.physical_gpu_count, + model = %info.gpu_model, + rocm = info.rocm_version.as_deref().unwrap_or("?"), + "amd-smi detected" + ); + Some(info) + } else { + warn!("amd-smi not available (no /dev/kfd or `amd-smi version` failed); GPU disabled"); + None + }; + + let mut tick_count: u64 = 0; + let mut last_sysinfo_refresh: u64 = 0; + + loop { + ticker.tick().await; + tick_count += 1; + + let mut warnings = Vec::new(); + let gpus = match &gpu { + Some(g) => match g.metrics().await { + Ok(v) => v, + Err(e) => { + warnings.push(format!("amd-smi metric: {e}")); + Vec::new() + } + }, + None => { + warnings.push("amd-smi unavailable (no /dev/kfd or binary missing)".into()); + Vec::new() + } + }; + + if gpu.is_some() + && tick_count.saturating_sub(last_sysinfo_refresh) >= sysinfo_refresh_ticks + && let Some(g) = &gpu + { + gpu_system_info = Some(g.system_info().await); + last_sysinfo_refresh = tick_count; + } + + // Service discovery — every DISCOVERY_TICKS ticks, diff vs known set, + // emit Discovered/Gone events, and update reducer state. + if let Some(d) = docker.as_ref() + && (tick_count == 1 || tick_count.is_multiple_of(discovery_ticks)) + { + match d.discover_async().await { + Ok(svcs) => { + let seen: HashSet = + svcs.iter().map(|s| s.container_id.clone()).collect(); + for svc in &svcs { + let inst = instance_from_discovered(svc); + runner + .state + .apply(StateEvent::InstanceUpserted(inst.clone())); + if !known_instances.contains(&svc.container_id) { + info!( + id = %svc.container_id, + name = %svc.container_name, + model = %svc.model_name, + "instance discovered" + ); + broadcast_and_persist( + &tx, + persist.as_ref(), + Event::InstanceDiscovered(inst), + ); + } + } + for gone in known_instances.difference(&seen) { + info!(id = %gone, "instance gone"); + prev_gen_tokens.remove(gone); + runner + .state + .apply(StateEvent::InstanceRemoved(gone.clone())); + broadcast_and_persist( + &tx, + persist.as_ref(), + Event::InstanceGone { + container_id: gone.clone(), + }, + ); + } + known_instances = seen; + } + Err(e) => warnings.push(format!("docker discover: {e}")), + } + } + + // Lemonade discovery — probe the endpoint on the discovery cadence; add a + // Lemonade Instance when reachable, emit Gone when it disappears, and stay + // a clean no-op (no warning/panic) when no endpoint is configured. + if let Some(l) = lemonade.as_ref() + && (tick_count == 1 || tick_count.is_multiple_of(discovery_ticks)) + { + match l.discover().await { + Some(svc) => { + let inst = instance_from_discovered(&svc); + runner + .state + .apply(StateEvent::InstanceUpserted(inst.clone())); + if lemonade_id.as_deref() != Some(svc.container_id.as_str()) { + info!( + id = %svc.container_id, + model = %svc.model_name, + "lemonade instance discovered" + ); + broadcast_and_persist( + &tx, + persist.as_ref(), + Event::InstanceDiscovered(inst), + ); + lemonade_id = Some(svc.container_id.clone()); + } + } + None => { + if let Some(id) = lemonade_id.take() { + info!(id = %id, "lemonade instance gone"); + prev_gen_tokens.remove(&id); + runner.state.apply(StateEvent::InstanceRemoved(id.clone())); + broadcast_and_persist( + &tx, + persist.as_ref(), + Event::InstanceGone { container_id: id }, + ); + } + } + } + } + + // Managed-service registry discovery (EAI-6871 D7) — read the rocm-cli + // `serve` records and surface live ones as scrape targets. The port is + // the registry's authority; non-vLLM engines are tracked so the vLLM + // scrape below skips them. A model served via `rocm serve` thus appears + // in the dashboard and is scraped for `gen_tps` without Docker. + if let Some(services_dir) = opts.services_dir.as_ref() + && (tick_count == 1 || tick_count.is_multiple_of(discovery_ticks)) + { + let records = crate::registry::load_service_records(services_dir); + let disc = crate::registry::discover_managed_services(&records); + managed_non_vllm = disc.non_vllm; + for inst in disc.instances { + let is_new = !known_services.contains(&inst.container_id); + let id = inst.container_id.clone(); + runner + .state + .apply(StateEvent::InstanceUpserted(inst.clone())); + if is_new { + info!(id = %id, "managed service discovered"); + broadcast_and_persist(&tx, persist.as_ref(), Event::InstanceDiscovered(inst)); + } + } + for gone in known_services.difference(&disc.seen) { + info!(id = %gone, "managed service gone"); + prev_gen_tokens.remove(gone); + runner + .state + .apply(StateEvent::InstanceRemoved(gone.clone())); + broadcast_and_persist( + &tx, + persist.as_ref(), + Event::InstanceGone { + container_id: gone.clone(), + }, + ); + } + known_services = disc.seen; + } + + // Per-instance vLLM metric scrape, parallel, on its own cadence. The + // Lemonade instance (if any) is scraped via its own collector below, so + // exclude it from the vLLM Prometheus targets. Managed non-vLLM services + // are excluded too (their engine uses a different parser). + if let Some(prom) = vllm.as_ref() + && !runner.state.instances.is_empty() + && tick_count.is_multiple_of(instance_ticks) + { + let targets: Vec<(String, u16)> = runner + .state + .instances + .values() + .filter(|i| Some(i.container_id.as_str()) != lemonade_id.as_deref()) + .filter(|i| !managed_non_vllm.contains(&i.container_id)) + .filter_map(|i| i.port.map(|p| (i.container_id.clone(), p))) + .collect(); + let prom = prom.clone(); + let results = parallel_scrape(targets, move |(id, port)| { + let prom = prom.clone(); + async move { + let svc = DiscoveredService { + container_id: id.clone(), + port: Some(port), + ..Default::default() + }; + prom.fetch_async(&svc).await + } + }) + .await; + let mut fail_count: usize = 0; + let mut last_err: Option = None; + for ((id, _port), fetch) in results { + match fetch { + Ok(sample) => { + // Difference the cumulative token counter into a live + // rate; first reading (or a restart) yields None. + let gen_tps = sample.gen_tokens_total.and_then(|cur| { + let now = Utc::now(); + let prev = prev_gen_tokens.insert(id.clone(), (cur, now)); + gen_tps_from_delta(prev, cur, now) + }); + if let Some(mut inst) = runner.state.instances.get(&id).cloned() { + inst.kv_cache_usage_pct = sample.kv_cache_usage_pct; + inst.running_reqs = sample.running_reqs; + inst.waiting_reqs = sample.waiting_reqs; + inst.gen_tps = gen_tps; + runner.state.apply(StateEvent::InstanceUpserted(inst)); + } + } + Err(e) => { + fail_count += 1; + let msg = format!("{e}"); + trace!(id = %id, error = %msg, "vllm scrape failed"); + last_err = Some(msg); + // Don't let a dead instance show a frozen rate: clear + // throughput and drop the baseline so recovery re-bases. + prev_gen_tokens.remove(&id); + if let Some(mut inst) = runner.state.instances.get(&id).cloned() + && inst.gen_tps.is_some() + { + inst.gen_tps = None; + runner.state.apply(StateEvent::InstanceUpserted(inst)); + } + } + } + } + if fail_count > 0 { + warnings.push(match last_err { + Some(e) => format!("vllm scrape: {fail_count} failed (last: {e})"), + None => format!("vllm scrape: {fail_count} failed"), + }); + } + } + + // Lemonade per-instance scrape — reports an instantaneous rate directly + // (`gen_tps`), so no counter-differencing. A scrape failure leaves the + // last-known fields and warns; it never panics. + if let (Some(l), Some(id)) = (lemonade.as_ref(), lemonade_id.clone()) + && tick_count.is_multiple_of(instance_ticks) + { + match l.fetch_stats().await { + Ok(sample) => { + if let Some(mut inst) = runner.state.instances.get(&id).cloned() { + inst.gen_tps = sample.gen_tps; + inst.kv_cache_usage_pct = sample.kv_cache_usage_pct; + inst.running_reqs = sample.running_reqs; + inst.waiting_reqs = sample.waiting_reqs; + runner.state.apply(StateEvent::InstanceUpserted(inst)); + } + } + Err(e) => { + trace!(id = %id, error = %e, "lemonade scrape failed"); + warnings.push(format!("lemonade scrape: {e}")); + } + } + } + + // Per-process VRAM attribution: refresh the per-container map on the + // instance cadence via one amd-smi `process` scrape, joining GPU-process + // host PIDs to container ids through `/proc//cgroup`. On Err/empty + // the device-summed fallback still applies. `procs_nonempty` gates the + // fallback warning so we only warn on a real (non-empty) scrape. + let mut procs_nonempty = false; + if let Some(g) = &gpu + && !runner.state.instances.is_empty() + && tick_count.is_multiple_of(instance_ticks) + { + match g.processes().await { + Ok(procs) => { + procs_nonempty = !procs.is_empty(); + per_container_used = rocm_dash_core::vram::aggregate_process_vram( + &procs, + rocm_dash_collectors::cgroup::container_id_for_pid, + ); + } + Err(e) => { + // Keep the last-known map; device fallback covers the gap. + trace!(error = %e, "amd-smi process scrape failed"); + } + } + } + + let mut instances: Vec = runner.state.instances.values().cloned().collect(); + // Derive per-instance efficiency now that this tick's GPU power is known. + // Count instances that have throughput + live GPUs but whose gpu_ids + // don't line up with any amd-smi device_id — the silent-None failure + // mode on real hardware, surfaced via the header ⚠ badge. + let mut id_join_misses = 0usize; + for inst in &mut instances { + inst.tokens_per_watt = + rocm_dash_core::efficiency::tokens_per_watt(inst.gen_tps, &inst.gpu_ids, &gpus); + if inst.gen_tps.is_some() + && !inst.gpu_ids.is_empty() + && !gpus.is_empty() + && !rocm_dash_core::efficiency::gpu_ids_overlap(&inst.gpu_ids, &gpus) + { + id_join_misses += 1; + } + } + if id_join_misses > 0 { + warnings.push(format!( + "tokens_per_watt: gpu_ids matched no GPU for {id_join_misses} instance(s) \ + (check HIP_VISIBLE_DEVICES vs amd-smi device_id)" + )); + } + // Attribute per-instance VRAM (per-process where the cgroup join hit, + // device-summed otherwise). Pure; uses this tick's `gpus` for totals. + enrich_instance_vram(&mut instances, &gpus, &per_container_used); + // Warn only when a real process scrape happened but an instance with + // live GPUs still fell back to device-summed (cgroup join missed). + if procs_nonempty { + let vram_fallbacks = instances + .iter() + .filter(|i| { + !i.gpu_ids.is_empty() + && rocm_dash_core::efficiency::gpu_ids_overlap(&i.gpu_ids, &gpus) + && !per_container_used.contains_key(&i.container_id) + }) + .count(); + if vram_fallbacks > 0 { + warnings.push(format!( + "vram attribution: {vram_fallbacks} instance(s) fell back to device-summed \ + VRAM (no per-process cgroup match; check /proc access)" + )); + } + } + let snap = Snapshot { + timestamp: Utc::now(), + host: host.tick(), + gpus, + gpu_system_info: gpu_system_info.clone(), + instances, + warnings, + }; + runner.state.apply(StateEvent::Tick(snap.clone())); + if let Ok(mut r) = ring.lock() { + r.push(snap.clone()); + } + broadcast_and_persist(&tx, persist.as_ref(), Event::Snapshot(snap)); + trace!(tick = tick_count, "snapshot broadcast"); + + // Drain any new benchmark rows that landed since the last tick. + if let Some(b) = bench.as_mut() { + match b.drain() { + Ok(rows) if !rows.is_empty() => { + info!(count = rows.len(), "bench rows broadcast"); + runner.state.apply(StateEvent::BenchmarkRows(rows.clone())); + if let Ok(mut br) = bench_ring.lock() { + for row in &rows { + br.push(row.clone()); + } + } + broadcast_and_persist( + &tx, + persist.as_ref(), + Event::BenchmarkRowsAppended { rows }, + ); + } + Ok(_) => {} + Err(e) => warn!(error = %e, "bench tailer drain failed"), + } + } + } +} + +/// Build an `Instance` from a `DiscoveredService` with no live KV/req sample yet. +/// vLLM Prometheus scraping will fill these fields in a later collector. +pub(crate) fn instance_from_discovered(svc: &DiscoveredService) -> Instance { + let mut inst = merge_instance(svc, &InstanceSample::default(), 0, 0); + inst.status = InstanceStatus::Running; + inst +} + +/// Set `vram_used_mb`/`vram_total_mb` on each instance from the per-process +/// attribution map, falling back to device-summed VRAM over the instance's +/// GPUs when its container has no per-process entry. Pure — the runner does the +/// amd-smi + cgroup I/O and passes `per_container_used` in. `total` is always +/// device-summed over `gpu_ids`; an instance with empty `gpu_ids` and no map +/// entry (e.g. Lemonade) stays at `(0, 0)`. +fn enrich_instance_vram( + instances: &mut [Instance], + gpus: &[GpuMetrics], + per_container_used: &HashMap, +) { + for inst in instances { + let (used, total) = rocm_dash_core::vram::resolve_instance_vram( + &inst.container_id, + &inst.gpu_ids, + gpus, + per_container_used, + ); + inst.vram_used_mb = used; + inst.vram_total_mb = total; + } +} + +/// How many `tick`s fit into `period`, rounded to the nearest, minimum 1. +/// Broadcast `ev` to subscribers and, if a session writer is wired, append +/// the same event to disk for `--replay`. Persistence is best-effort — a +/// write failure logs at warn level but does not interrupt the loop. +fn broadcast_and_persist( + tx: &broadcast::Sender, + persist: Option<&Arc>>, + ev: Event, +) { + if let Some(w) = persist + && let Ok(mut writer) = w.lock() + && let Err(e) = writer.append(&ev) + { + warn!(error = %e, "session persist failed"); + } + let _ = tx.send(ev); +} + +fn ticks_per(period: Duration, tick: Duration) -> u64 { + let n = (period.as_secs_f64() / tick.as_secs_f64()).round() as i64; + n.max(1) as u64 +} + +/// A gap longer than this between counter readings makes the baseline stale — +/// the rate would be a misleadingly low average across an outage or a forward +/// wall-clock jump (NTP, VM resume), so we re-baseline instead. +const MAX_RATE_INTERVAL_S: f64 = 60.0; + +/// Instantaneous generation tok/s from two cumulative counter readings. +/// +/// Returns `None` on the first reading (`prev` is `None`), a non-positive or +/// stale interval (backwards/forward clock jump, scrape outage), or a counter +/// reset (`cur < prev`, e.g. the vLLM process restarted) — so the rate is +/// never negative, stale, or otherwise bogus. +pub(crate) fn gen_tps_from_delta( + prev: Option<(f64, DateTime)>, + cur: f64, + now: DateTime, +) -> Option { + let (prev_val, prev_at) = prev?; + let dt = (now - prev_at).num_milliseconds() as f64 / 1000.0; + if dt <= 0.0 || dt > MAX_RATE_INTERVAL_S || cur < prev_val { + return None; + } + Some((cur - prev_val) / dt) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(secs, 0).unwrap() + } + + #[test] + fn gen_tps_none_on_first_reading() { + assert_eq!(gen_tps_from_delta(None, 100.0, at(10)), None); + } + + #[test] + fn gen_tps_computes_rate() { + // 200 tokens accumulated over 2 s → 100 tok/s. + assert_eq!( + gen_tps_from_delta(Some((100.0, at(10))), 300.0, at(12)), + Some(100.0) + ); + } + + #[test] + fn gen_tps_none_on_stale_interval() { + // Gap beyond MAX_RATE_INTERVAL_S → re-baseline rather than a bogus avg. + assert_eq!( + gen_tps_from_delta(Some((100.0, at(10))), 9000.0, at(10 + 120)), + None + ); + } + + #[test] + fn gen_tps_none_on_counter_reset() { + // Process restarted: cur < prev → no negative rate. + assert_eq!( + gen_tps_from_delta(Some((500.0, at(10))), 20.0, at(12)), + None + ); + } + + #[test] + fn gen_tps_none_on_nonpositive_interval() { + assert_eq!( + gen_tps_from_delta(Some((100.0, at(12))), 300.0, at(12)), + None + ); + } + + fn gpu(device_id: &str, used: u64, total: u64) -> GpuMetrics { + GpuMetrics { + device_id: device_id.into(), + vram_used_mb: used, + vram_total_mb: total, + ..GpuMetrics::default() + } + } + + fn inst(container_id: &str, gpu_ids: &[&str]) -> Instance { + Instance { + container_id: container_id.into(), + gpu_ids: gpu_ids.iter().map(|s| s.to_string()).collect(), + ..Instance::default() + } + } + + #[test] + fn enrich_uses_per_process_used_with_device_total() { + let gpus = [gpu("gpu-0", 1000, 8000), gpu("gpu-1", 2000, 8000)]; + let mut per = HashMap::new(); + per.insert("abc".to_string(), 4242); + let mut instances = [inst("abc", &["0", "1"])]; + enrich_instance_vram(&mut instances, &gpus, &per); + // used from the per-process map, total device-summed over gpu 0+1. + assert_eq!(instances[0].vram_used_mb, 4242); + assert_eq!(instances[0].vram_total_mb, 16000); + } + + #[test] + fn enrich_falls_back_to_device_summed_when_unmatched() { + let gpus = [gpu("gpu-0", 1000, 8000), gpu("gpu-1", 2000, 8000)]; + let per = HashMap::new(); // container not present + let mut instances = [inst("missing", &["0", "1"])]; + enrich_instance_vram(&mut instances, &gpus, &per); + assert_eq!(instances[0].vram_used_mb, 3000); // device-summed used + assert_eq!(instances[0].vram_total_mb, 16000); + } + + #[test] + fn enrich_leaves_lemonade_style_instance_at_zero() { + // Empty gpu_ids + synthetic id not in map → (0, 0), no panic. + let gpus = [gpu("gpu-0", 1000, 8000)]; + let per = HashMap::new(); + let mut instances = [inst("lemonade-synthetic", &[])]; + enrich_instance_vram(&mut instances, &gpus, &per); + assert_eq!(instances[0].vram_used_mb, 0); + assert_eq!(instances[0].vram_total_mb, 0); + } + + #[test] + fn ticks_per_rounds_and_floors_to_one() { + assert_eq!(ticks_per(Duration::from_secs(5), Duration::from_secs(1)), 5); + assert_eq!( + ticks_per(Duration::from_millis(900), Duration::from_secs(1)), + 1 + ); + assert_eq!( + ticks_per(Duration::from_millis(0), Duration::from_secs(1)), + 1 + ); + assert_eq!( + ticks_per(Duration::from_secs(30), Duration::from_millis(250)), + 120 + ); + } +} diff --git a/crates/rocm-dash-daemon/src/server.rs b/crates/rocm-dash-daemon/src/server.rs new file mode 100644 index 00000000..5602eaa6 --- /dev/null +++ b/crates/rocm-dash-daemon/src/server.rs @@ -0,0 +1,195 @@ +//! Listener + per-client task loop + runner-to-clients broadcast. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, anyhow}; +use rocm_dash_core::protocol::{Command, Event, PROTOCOL_VERSION}; +use tokio::io::{AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::broadcast; +use tracing::{info, warn}; + +use crate::DAEMON_VERSION; +use crate::bench_ring::BenchRing; +use crate::persist::SessionWriter; +use crate::runner::{self, RunnerOptions}; +use crate::snapshot_ring::SnapshotRing; +use crate::transport::{read_line, write_line}; + +/// Broadcast capacity. A slow client that lags more than this many ticks will +/// receive `RecvError::Lagged` and skip ahead (no crash). +const BROADCAST_CAP: usize = 64; + +/// Snapshot history kept for late-joining clients (~30s at 1Hz). +const RING_CAP: usize = 30; + +/// Benchmark-row history kept for late-joining clients (matches TUI `BENCH_CAP`). +const BENCH_RING_CAP: usize = 200; + +pub async fn run(listen: &str, _token: Option<&str>, opts: RunnerOptions) -> anyhow::Result<()> { + let (scheme, target) = listen + .split_once(':') + .ok_or_else(|| anyhow!("listen must be `unix:/path` or `tcp:host:port`"))?; + + match scheme { + "unix" => run_unix(PathBuf::from(target), opts).await, + "tcp" => Err(anyhow!("tcp listener not implemented in scaffold")), + other => Err(anyhow!("unknown listen scheme: {other}")), + } +} + +async fn run_unix(path: PathBuf, opts: RunnerOptions) -> anyhow::Result<()> { + if path.exists() { + std::fs::remove_file(&path).with_context(|| format!("removing stale socket {path:?}"))?; + } + let listener = UnixListener::bind(&path).with_context(|| format!("binding {path:?}"))?; + info!(socket = %path.display(), "listening"); + + let (snap_tx, _) = broadcast::channel::(BROADCAST_CAP); + let ring = Arc::new(Mutex::new(SnapshotRing::new(RING_CAP))); + let bench_ring = Arc::new(Mutex::new(BenchRing::new(BENCH_RING_CAP))); + + // Optional session writer for --persist-dir / `daemon.persist_dir`. + let persist = match &opts.persist_dir { + Some(dir) => match SessionWriter::new(dir) { + Ok(w) => { + info!(file = %w.path().display(), "session persistence enabled"); + Some(Arc::new(Mutex::new(w))) + } + Err(e) => { + warn!(dir = %dir.display(), error = %e, "could not open session file; persistence disabled"); + None + } + }, + None => None, + }; + + // Runner task — produces snapshots and (optionally) bench rows. + let runner_tx = snap_tx.clone(); + let runner_ring = ring.clone(); + let runner_bench_ring = bench_ring.clone(); + let runner_persist = persist.clone(); + let runner_opts = opts.clone(); + tokio::spawn(async move { + runner::run_loop( + None, + runner_tx, + runner_ring, + runner_bench_ring, + runner_persist, + runner_opts, + ) + .await; + }); + + loop { + let (stream, _addr) = listener.accept().await?; + let rx = snap_tx.subscribe(); + let client_ring = ring.clone(); + let client_bench_ring = bench_ring.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(stream, rx, client_ring, client_bench_ring).await { + warn!(error = %e, "client task ended with error"); + } + }); + } +} + +async fn handle_client( + stream: UnixStream, + mut rx: broadcast::Receiver, + ring: Arc>, + bench_ring: Arc>, +) -> anyhow::Result<()> { + let (rd, mut wr) = stream.into_split(); + let mut rd = BufReader::new(rd); + + let welcome = Event::Welcome { + protocol_version: PROTOCOL_VERSION, + daemon_version: DAEMON_VERSION.into(), + host: hostname().unwrap_or_else(|| "unknown".into()), + }; + write_line(&mut wr, &welcome).await?; + + let mut subscribed = false; + + loop { + tokio::select! { + biased; + + // Inbound command from client. + maybe_cmd = read_line::<_, Command>(&mut rd) => { + let Some(cmd) = maybe_cmd? else { break }; + match cmd { + Command::Hello { client, protocol_version, .. } => { + info!(client = %client, protocol_version, "client hello"); + } + Command::Subscribe => { + subscribed = true; + // Hydrate the late-joining client with whatever history we have. + let backlog = ring.lock().map(|r| r.snapshot()).unwrap_or_default(); + let n = backlog.len(); + for snap in backlog { + if write_line(&mut wr, &Event::Snapshot(snap)).await.is_err() { + break; + } + } + let bench_backlog = bench_ring + .lock() + .map(|r| r.snapshot()) + .unwrap_or_default(); + let bn = bench_backlog.len(); + if !bench_backlog.is_empty() { + let _ = write_line( + &mut wr, + &Event::BenchmarkRowsAppended { + rows: bench_backlog, + }, + ) + .await; + } + info!(replayed = n, bench_replayed = bn, "client subscribed"); + } + Command::RequestSnapshot | Command::RescanInstances => { + // The runner pushes snapshots regularly; nothing to do here yet. + } + Command::Pause | Command::Resume => { + info!(?cmd, "pause/resume — runner does not honor yet"); + } + Command::Goodbye => { + let _ = write_line(&mut wr, &Event::Bye).await; + break; + } + } + } + + // Outbound event from runner broadcast. + recv = rx.recv() => { + match recv { + Ok(ev) if subscribed => { + if write_line(&mut wr, &ev).await.is_err() { + break; + } + } + Ok(_) => { /* not subscribed yet — drop */ } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(skipped = n, "client lagged broadcast"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + + let _ = wr.shutdown().await; + Ok(()) +} + +fn hostname() -> Option { + std::env::var("HOSTNAME").ok().or_else(|| { + std::fs::read_to_string("/proc/sys/kernel/hostname") + .ok() + .map(|s| s.trim().to_string()) + }) +} diff --git a/crates/rocm-dash-daemon/src/snapshot_ring.rs b/crates/rocm-dash-daemon/src/snapshot_ring.rs new file mode 100644 index 00000000..73ee504b --- /dev/null +++ b/crates/rocm-dash-daemon/src/snapshot_ring.rs @@ -0,0 +1,70 @@ +//! Rolling snapshot history kept by the daemon so late-joining clients can hydrate. + +use std::collections::VecDeque; + +use rocm_dash_core::metrics::Snapshot; + +pub struct SnapshotRing { + cap: usize, + inner: VecDeque, +} + +impl SnapshotRing { + pub fn new(cap: usize) -> Self { + Self { + cap, + inner: VecDeque::with_capacity(cap), + } + } + + pub fn push(&mut self, snap: Snapshot) { + if self.inner.len() == self.cap { + self.inner.pop_front(); + } + self.inner.push_back(snap); + } + + pub fn latest(&self) -> Option<&Snapshot> { + self.inner.back() + } + + pub fn iter(&self) -> impl Iterator { + self.inner.iter() + } + + /// Cloned snapshot of the ring contents, oldest first. + pub fn snapshot(&self) -> Vec { + self.inner.iter().cloned().collect() + } + + pub fn len(&self) -> usize { + self.inner.len() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, Utc}; + + fn at(t: i64) -> Snapshot { + Snapshot { + timestamp: DateTime::::from_timestamp(t, 0).unwrap(), + ..Snapshot::default() + } + } + + #[test] + fn caps_at_capacity() { + let mut r = SnapshotRing::new(3); + for i in 0..10 { + r.push(at(i)); + } + assert_eq!(r.len(), 3); + assert_eq!(r.latest().unwrap().timestamp.timestamp(), 9); + } +} diff --git a/crates/rocm-dash-daemon/src/transport.rs b/crates/rocm-dash-daemon/src/transport.rs new file mode 100644 index 00000000..9c78f9f9 --- /dev/null +++ b/crates/rocm-dash-daemon/src/transport.rs @@ -0,0 +1,34 @@ +//! NDJSON framing helpers. + +use std::io; + +use serde::{Serialize, de::DeserializeOwned}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +pub async fn write_line(w: &mut W, value: &T) -> io::Result<()> +where + W: AsyncWriteExt + Unpin, + T: Serialize, +{ + let mut buf = + serde_json::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + buf.push(b'\n'); + w.write_all(&buf).await?; + w.flush().await?; + Ok(()) +} + +pub async fn read_line(r: &mut BufReader) -> io::Result> +where + R: tokio::io::AsyncRead + Unpin, + T: DeserializeOwned, +{ + let mut line = String::new(); + let n = r.read_line(&mut line).await?; + if n == 0 { + return Ok(None); + } + let value: T = serde_json::from_str(line.trim()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + Ok(Some(value)) +} diff --git a/crates/rocm-dash-daemon/tests/end_to_end.rs b/crates/rocm-dash-daemon/tests/end_to_end.rs new file mode 100644 index 00000000..67af6a87 --- /dev/null +++ b/crates/rocm-dash-daemon/tests/end_to_end.rs @@ -0,0 +1,209 @@ +//! End-to-end integration test: spawn the runner with a CSV, drive a broadcast +//! subscriber from the same process, and assert Snapshot + BenchmarkRowsAppended +//! events arrive. +//! +//! Exercises the daemon's public library surface (`rocm_dash_daemon::*`). + +use std::time::Duration; + +use rocm_dash_core::protocol::Event; +use tokio::sync::broadcast; +use tokio::time::timeout; + +use std::sync::{Arc, Mutex}; + +use rocm_dash_daemon::bench_ring::BenchRing; +use rocm_dash_daemon::runner; +use rocm_dash_daemon::snapshot_ring::SnapshotRing; + +const HEADER: &str = "cell,run,wall_s,n_requests,main_prompt_n,prompt_tokens,prompt_tps,\ + completion_tokens,gen_tps,max_running_reqs,max_waiting_reqs,out_chars,rc,\ + assertion_pass,assertion_fail_count,assertion_summary,quality_score,\ + judge_pass_fail,judge_model,model,endpoint,tp,pp,dtype,max_num_seqs,\ + attention_backend,concurrency,extra_args,safety_pass,safety_violations\n"; +const ROW: &str = "O-arch,1,42.3,8,512,4096,1240.5,2048,68.2,8,2,8192,0,true,0,all-pass,\ + 4.5,pass,claude,deepseek-r1,http://vllm:8000,8,1,fp8,32,triton,1,,true,0\n"; + +#[tokio::test] +async fn runner_broadcasts_snapshots_and_bench_rows() { + let mut path = std::env::temp_dir(); + path.push(format!( + "rocm-dash-runner-test-{}-{}.csv", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, HEADER).unwrap(); + + let (tx, mut rx) = broadcast::channel::(64); + let path_for_runner = path.clone(); + let _handle = tokio::spawn(async move { + // 250ms tick — faster than 1Hz so the test doesn't stall. + let opts = runner::RunnerOptions { + bench_csv: Some(path_for_runner), + ..Default::default() + }; + let ring = Arc::new(Mutex::new(SnapshotRing::new(8))); + let bench_ring = Arc::new(Mutex::new(BenchRing::new(8))); + runner::run_loop( + Some(Duration::from_millis(250)), + tx, + ring, + bench_ring, + None, + opts, + ) + .await; + }); + + // First Snapshot should land within ~500ms. + let first = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("snapshot timeout") + .expect("recv"); + assert!(matches!(first, Event::Snapshot(_))); + + // No bench rows in the file yet — drain returns empty, no event. + // Now append a row and expect a BenchmarkRowsAppended within the next tick. + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + f.write_all(ROW.as_bytes()).unwrap(); + } + + let mut saw_rows = false; + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while std::time::Instant::now() < deadline && !saw_rows { + let ev = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("event timeout") + .expect("recv"); + if let Event::BenchmarkRowsAppended { rows } = ev { + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cell, "O-arch"); + assert_eq!(rows[0].run, 1); + saw_rows = true; + } + } + assert!(saw_rows, "never saw BenchmarkRowsAppended"); + + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn ring_accumulates_snapshots_for_replay() { + use std::time::Instant; + + let (tx, _rx) = broadcast::channel::(64); + let ring = Arc::new(Mutex::new(SnapshotRing::new(4))); + let bench_ring = Arc::new(Mutex::new(BenchRing::new(4))); + let runner_ring = ring.clone(); + let runner_bench_ring = bench_ring.clone(); + let _handle = tokio::spawn(async move { + let opts = runner::RunnerOptions::default(); + runner::run_loop( + Some(Duration::from_millis(100)), + tx, + runner_ring, + runner_bench_ring, + None, + opts, + ) + .await; + }); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let n = ring.lock().unwrap().len(); + if n >= 3 { + break; + } + if Instant::now() > deadline { + panic!("ring never reached 3 snapshots; len={n}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + tokio::time::sleep(Duration::from_millis(800)).await; + let snaps = ring.lock().unwrap().snapshot(); + assert!(snaps.len() <= 4, "ring exceeded cap: {}", snaps.len()); + assert!(snaps.len() >= 3, "ring underfull: {}", snaps.len()); + + for w in snaps.windows(2) { + assert!(w[0].timestamp <= w[1].timestamp); + } +} + +#[tokio::test] +async fn bench_ring_accumulates_rows_for_replay() { + use std::time::Instant; + + let mut path = std::env::temp_dir(); + path.push(format!( + "rocm-dash-ring-test-{}-{}.csv", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, HEADER).unwrap(); + + let (tx, _rx) = broadcast::channel::(64); + let ring = Arc::new(Mutex::new(SnapshotRing::new(8))); + let bench_ring = Arc::new(Mutex::new(BenchRing::new(8))); + let runner_ring = ring.clone(); + let runner_bench_ring = bench_ring.clone(); + let path_for_runner = path.clone(); + let _handle = tokio::spawn(async move { + let opts = runner::RunnerOptions { + bench_csv: Some(path_for_runner), + ..Default::default() + }; + runner::run_loop( + Some(Duration::from_millis(100)), + tx, + runner_ring, + runner_bench_ring, + None, + opts, + ) + .await; + }); + + // Let the runner take at least one tick so the tailer is initialized. + tokio::time::sleep(Duration::from_millis(200)).await; + + { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + f.write_all(ROW.as_bytes()).unwrap(); + } + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let n = bench_ring.lock().unwrap().len(); + if n >= 1 { + break; + } + if Instant::now() > deadline { + panic!("bench ring never accumulated a row; len={n}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let rows = bench_ring.lock().unwrap().snapshot(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].cell, "O-arch"); + assert_eq!(rows[0].run, 1); + + let _ = std::fs::remove_file(&path); +} diff --git a/crates/rocm-dash-tui/Cargo.toml b/crates/rocm-dash-tui/Cargo.toml new file mode 100644 index 00000000..a1d3ec60 --- /dev/null +++ b/crates/rocm-dash-tui/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "rocm-dash-tui" +version.workspace = true +# Transplanted from rocm-dash (EAI-6871); migrated to the workspace edition (2024). +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lib] +name = "rocm_dash_tui" +path = "src/lib.rs" + +[dependencies] +rocm-dash-core = { path = "../rocm-dash-core" } +# Unified dashboard TUI base (EAI-6871 / D1): ratatui 0.30 + crossterm 0.28 stay +# confined to this crate. The frozen rocm-cli tui.rs keeps ratatui 0.29; the two +# majors coexist in the tree, each confined to its own crate. +ratatui = "0.30" +crossterm = { version = "0.28", features = ["event-stream"] } +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +color-eyre = "0.6" +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" +# Skill manifests are TOML (the external-team contribution contract). +toml = "0.8" +async-trait = "0.1" +# Header types for the custom auth header some gateways require (e.g. APIM's +# `Ocp-Apim-Subscription-Key`). `http` is the types-only crate Rig already uses. +http = "1" +# Rig — the chat agent backend. Pinned EXACT, default features OFF so the +# optional `epub` (GPL-3.0) and other heavy/GPL deps stay out (crate is +# MIT-clean). Only the minimal OpenAI provider feature is enabled. reqwest +# 0.13 arrives transitively here (TUI crate only); collectors keep 0.12. +rig = { package = "rig-core", version = "=0.38.1", default-features = false, features = ["reqwest", "rustls"] } +# Direct reqwest for the in-TUI local-engine `/v1/models` query. Pinned to the +# same 0.13 line Rig already pulls in (TUI crate only) so the tree keeps one +# reqwest version; `json` for response parsing, `rustls-tls` to match Rig's stack. +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } diff --git a/crates/rocm-dash-tui/examples/gen_cast.rs b/crates/rocm-dash-tui/examples/gen_cast.rs new file mode 100644 index 00000000..b3fc19a1 --- /dev/null +++ b/crates/rocm-dash-tui/examples/gen_cast.rs @@ -0,0 +1,353 @@ +//! Render the TUI against a synthetic session NDJSON to an asciicast v2 +//! recording. The marketing page replays it as a real animated terminal +//! demo — no live terminal, no external capture tools, no drift. +//! +//! Usage: +//! cargo run --release --example gen_cast -p rocm-dash-tui -- \ +//! --input /tmp/mi355x-demo.ndjson \ +//! --output ../marketing/asciinema/rocm-dash.cast +//! +//! Each frame is the actual `ui::draw` framebuffer for a point in a +//! scripted storyboard (tab + modal walk), converted to a truecolor ANSI +//! string. Frames overwrite in place (cursor home), so the cast plays back +//! as a smooth animation rather than a scrolling log. + +use std::fs; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; + +use clap::Parser; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::style::{Color, Modifier}; +use rocm_dash_core::persist::PersistedEntry; +use rocm_dash_core::protocol::Event; +use rocm_dash_tui::app::{ActiveTab, AppState, ConnState, Modal}; +use rocm_dash_tui::ui; + +/// Per-frame cast time, in seconds. ~0.45s gives a calm, readable cadence. +const FRAME_DELAY_S: f64 = 0.45; + +/// Default-dark fallback colours for `Color::Reset`/non-RGB variants, chosen +/// to match the SVG hero so the recording is visually consistent. +const DEFAULT_FG: (u8, u8, u8) = (235, 235, 236); +const DEFAULT_BG: (u8, u8, u8) = (13, 15, 18); + +#[derive(Parser)] +#[command( + name = "gen_cast", + about = "Render TUI storyboard to an asciicast v2 recording" +)] +struct Args { + /// Synthetic session file (produced by `make demo`). + #[arg(long, default_value = "/tmp/mi355x-demo.ndjson")] + input: PathBuf, + + /// Output cast file. Parent dir created if absent. Default targets the + /// repo-root `marketing/asciinema/` directory when run from `app/`. + #[arg(long, default_value = "../marketing/asciinema/rocm-dash.cast")] + output: PathBuf, + + /// Terminal grid (cols × rows) the cast is rendered at. + #[arg(long, default_value_t = 160)] + cols: u16, + #[arg(long, default_value_t = 44)] + rows: u16, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + if let Some(parent) = args.output.parent() { + fs::create_dir_all(parent)?; + } + + let entries = read_entries(&args.input)?; + eprintln!( + "loaded {} entries from {}", + entries.len(), + args.input.display() + ); + + // Snapshot events drive the animated widgets (sparklines, gauges). We + // index them so the storyboard can advance the timeline a few snapshots + // at a time between frames. + let snapshot_idxs: Vec = entries + .iter() + .enumerate() + .filter(|(_, e)| matches!(e.event, Event::Snapshot(_))) + .map(|(i, _)| i) + .collect(); + let total_snaps = snapshot_idxs.len(); + eprintln!(" {total_snaps} snapshots in timeline"); + + let mut state = AppState::new("demo-mi355x-01".into(), "default-dark".into()); + state.conn = ConnState::Connected { + host: "demo-mi355x-01".into(), + version: "0.1.0".into(), + }; + + // Replay cursor: `cursor` indexes into `entries`, `snaps_applied` counts + // snapshot events consumed so the storyboard can target a timeline depth. + let mut replay = Replay { + entries: &entries, + cursor: 0, + snaps_applied: 0, + total_snaps, + }; + + // Storyboard: (frames, snapshot-advance-per-frame, tab, modal). Counts are + // clamped against the file length so a short demo still produces a valid + // (if shorter) cast. + let overview_frames = 30usize; + let hardware_frames = 10usize; + let instances_frames = 10usize; + let bench_frames = 15usize; + let help_frames = 3usize; + + // Spread the snapshot timeline across the data-driven tabs so each frame + // shows movement. Bench rows accumulate from events, so give Bench the + // densest advance. + let total_advancing_frames = + overview_frames + hardware_frames + instances_frames + bench_frames; + let per_frame_snaps = (total_snaps / total_advancing_frames.max(1)).max(1); + + let mut frames: Vec = Vec::new(); + + // --- Overview tab ------------------------------------------------------- + state.active_tab = ActiveTab::Overview; + state.modal = Modal::None; + for _ in 0..overview_frames { + replay.advance_by(&mut state, per_frame_snaps); + frames.push(capture(&mut state, args.cols, args.rows)?); + } + + // --- Hardware tab ------------------------------------------------------- + state.active_tab = ActiveTab::Hardware; + for _ in 0..hardware_frames { + replay.advance_by(&mut state, per_frame_snaps); + frames.push(capture(&mut state, args.cols, args.rows)?); + } + + // --- Instances tab ------------------------------------------------------ + state.active_tab = ActiveTab::Instances; + for _ in 0..instances_frames { + replay.advance_by(&mut state, per_frame_snaps); + frames.push(capture(&mut state, args.cols, args.rows)?); + } + + // --- Bench tab ---------------------------------------------------------- + // Drain whatever remains of the timeline here so the Pass^N/Pass@N rollup + // (incl. the mixed `S-stress-mixtral` group) fills in over the frames. + state.active_tab = ActiveTab::Bench; + let remaining = total_snaps.saturating_sub(replay.snaps_applied); + let bench_advance = (remaining / bench_frames.max(1)).max(1); + for _ in 0..bench_frames { + replay.advance_by(&mut state, bench_advance); + frames.push(capture(&mut state, args.cols, args.rows)?); + } + + // --- Help modal --------------------------------------------------------- + state.modal = Modal::Help; + for _ in 0..help_frames { + frames.push(capture(&mut state, args.cols, args.rows)?); + } + state.modal = Modal::None; + frames.push(capture(&mut state, args.cols, args.rows)?); + + write_cast(&args, &frames)?; + + let duration = frames.len() as f64 * FRAME_DELAY_S; + eprintln!( + "done — {} frames, {:.1}s cast → {}", + frames.len(), + duration, + args.output.display() + ); + Ok(()) +} + +/// Walks the persisted entry list, applying every event to `state` while +/// counting consumed `Snapshot` events. `advance_by` moves the cursor forward +/// until `n` more snapshots have been applied (or the timeline is exhausted), +/// so each storyboard frame shows fresh, advanced data. +struct Replay<'a> { + entries: &'a [PersistedEntry], + cursor: usize, + snaps_applied: usize, + total_snaps: usize, +} + +impl Replay<'_> { + fn advance_by(&mut self, state: &mut AppState, n: usize) { + let target = (self.snaps_applied + n).min(self.total_snaps); + // Apply events until we've consumed `n` more snapshots. + while self.snaps_applied < target && self.cursor < self.entries.len() { + let event = self.entries[self.cursor].event.clone(); + let is_snap = matches!(event, Event::Snapshot(_)); + state.apply_event(event); + if is_snap { + self.snaps_applied += 1; + } + self.cursor += 1; + } + // Drain trailing non-snapshot events (e.g. bench rows emitted right + // after the last counted snapshot) so they aren't stranded. + while self.cursor < self.entries.len() + && !matches!(self.entries[self.cursor].event, Event::Snapshot(_)) + { + let event = self.entries[self.cursor].event.clone(); + state.apply_event(event); + self.cursor += 1; + } + } +} + +/// Render the current `AppState` to a `TestBackend` buffer and convert it to a +/// truecolor ANSI frame. Mirrors `gen_screenshots::render_to_svg`. +fn capture(state: &mut AppState, cols: u16, rows: u16) -> anyhow::Result { + let backend = TestBackend::new(cols, rows); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| ui::draw(f, state))?; + let buf = terminal.backend().buffer().clone(); + Ok(buffer_to_ansi(&buf)) +} + +fn read_entries(path: &std::path::Path) -> anyhow::Result> { + let raw = fs::read_to_string(path)?; + raw.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).map_err(Into::into)) + .collect() +} + +/// Write the asciicast v2 file: a JSON header line followed by one +/// `[t, "o", payload]` event line per frame. `serde_json` handles all string +/// escaping (quotes, backslashes, ESC, newlines). +fn write_cast(args: &Args, frames: &[String]) -> anyhow::Result<()> { + let file = fs::File::create(&args.output)?; + let mut w = BufWriter::new(file); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let header = serde_json::json!({ + "version": 2, + "width": args.cols, + "height": args.rows, + "timestamp": timestamp, + "title": "rocm.ai — MI355X demo", + }); + writeln!(w, "{}", serde_json::to_string(&header)?)?; + + let mut t = 0.0f64; + for frame in frames { + t += FRAME_DELAY_S; + let event = serde_json::Value::Array(vec![ + serde_json::json!(t), + serde_json::Value::String("o".into()), + serde_json::Value::String(frame.clone()), + ]); + writeln!(w, "{}", serde_json::to_string(&event)?)?; + } + w.flush()?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Buffer → ANSI +// --------------------------------------------------------------------------- + +/// Convert a rendered buffer to a truecolor ANSI frame. Frames are written to +/// overwrite in place: hide cursor + home, then per-row SGR runs. The first +/// frame additionally clears the screen (callers prepend the clear once; here +/// we always emit home so any frame is self-positioning). +fn buffer_to_ansi(buf: &Buffer) -> String { + let cols = buf.area.width; + let rows = buf.area.height; + let mut out = String::with_capacity(rows as usize * cols as usize * 8); + + // Hide cursor, clear, home. Clearing every frame is cheap and guarantees + // no stale cells if a later frame is shorter than an earlier one. + out.push_str("\u{1b}[?25l\u{1b}[2J\u{1b}[H"); + + for y in 0..rows { + // Reset SGR tracking at the start of each row. Position the cursor + // absolutely (row y+1, col 1) so frames overwrite in place — emitting + // newlines instead would scroll the emulator and stack frames. + let mut cur_fg: Option<(u8, u8, u8)> = None; + let mut cur_bg: Option<(u8, u8, u8)> = None; + let mut cur_bold = false; + out.push_str(&format!("\u{1b}[{};1H\u{1b}[0m", y + 1)); + + for x in 0..cols { + let cell = buf.cell((x, y)).unwrap(); + let fg = resolve_color(cell.style().fg, DEFAULT_FG); + let bg = resolve_color(cell.style().bg, DEFAULT_BG); + let bold = cell.style().add_modifier.contains(Modifier::BOLD); + + let attrs_changed = Some(fg) != cur_fg || Some(bg) != cur_bg || bold != cur_bold; + if attrs_changed { + // If bold is turning off, a hard reset is the only way to drop + // the BOLD attribute; re-emit colours afterwards. + if cur_bold && !bold { + out.push_str("\u{1b}[0m"); + cur_fg = None; + cur_bg = None; + } + if bold && !cur_bold { + out.push_str("\u{1b}[1m"); + } + if Some(fg) != cur_fg { + let (r, g, b) = fg; + out.push_str(&format!("\u{1b}[38;2;{r};{g};{b}m")); + } + if Some(bg) != cur_bg { + let (r, g, b) = bg; + out.push_str(&format!("\u{1b}[48;2;{r};{g};{b}m")); + } + cur_fg = Some(fg); + cur_bg = Some(bg); + cur_bold = bold; + } + + let sym = cell.symbol(); + if sym.is_empty() { + out.push(' '); + } else { + out.push_str(sym); + } + } + // Clear to end of line (no newline) so we never scroll. + out.push_str("\u{1b}[0m\u{1b}[K"); + } + + out +} + +/// Map a ratatui `Color` to a concrete RGB triple, falling back to `default` +/// for `Reset`/indexed/named variants we don't translate. +fn resolve_color(c: Option, default: (u8, u8, u8)) -> (u8, u8, u8) { + match c { + Some(Color::Rgb(r, g, b)) => (r, g, b), + Some(Color::Black) => (0, 0, 0), + Some(Color::Red) => (204, 0, 0), + Some(Color::Green) => (78, 154, 6), + Some(Color::Yellow) => (196, 160, 0), + Some(Color::Blue) => (52, 101, 164), + Some(Color::Magenta) => (117, 80, 123), + Some(Color::Cyan) => (6, 152, 154), + Some(Color::Gray) => (211, 215, 207), + Some(Color::White) => (238, 238, 236), + Some(Color::DarkGray) => (85, 87, 83), + Some(Color::LightRed) => (239, 41, 41), + Some(Color::LightGreen) => (138, 226, 52), + Some(Color::LightYellow) => (252, 233, 79), + Some(Color::LightBlue) => (114, 159, 207), + Some(Color::LightMagenta) => (173, 127, 168), + Some(Color::LightCyan) => (52, 226, 226), + _ => default, + } +} diff --git a/crates/rocm-dash-tui/examples/gen_screenshots.rs b/crates/rocm-dash-tui/examples/gen_screenshots.rs new file mode 100644 index 00000000..82f60497 --- /dev/null +++ b/crates/rocm-dash-tui/examples/gen_screenshots.rs @@ -0,0 +1,440 @@ +//! Render the TUI against a synthetic session NDJSON and dump each view to +//! a standalone SVG. The screenshots embed cleanly in the marketing page +//! and demo decks — no live terminal, no manual capture, no drift. +//! +//! Usage: +//! cargo run --release --example gen_screenshots -p rocm-dash-tui -- \ +//! --input /tmp/mi355x-demo.ndjson \ +//! --output-dir marketing/screenshots +//! +//! Each output is the actual `ui::draw` framebuffer for a specific +//! AppState configuration (tab + modal + theme). Cells get run-length +//! coalesced per row so the SVG stays compact (~30-80 KB / view). + +use std::fs; +use std::path::PathBuf; + +use clap::Parser; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::style::{Color, Modifier}; +use rocm_dash_core::persist::PersistedEntry; +use rocm_dash_core::protocol::Event; +use rocm_dash_tui::app::{ActiveTab, AppState, ConnState, Modal}; +use rocm_dash_tui::ui; + +#[derive(Parser)] +#[command( + name = "gen_screenshots", + about = "Render TUI views to SVG for marketing" +)] +struct Args { + /// Synthetic session file (produced by `make demo`). + #[arg(long, default_value = "/tmp/mi355x-demo.ndjson")] + input: PathBuf, + + /// Output directory. Created if absent. SVGs are named per view. + /// Default targets the repo-root `marketing/screenshots/` directory + /// when run from the `app/` workspace. + #[arg(long, default_value = "../marketing/screenshots")] + output_dir: PathBuf, + + /// How many snapshots to replay before capturing. Higher = fuller + /// sparkline history. Capped by file length. + #[arg(long, default_value_t = 90)] + snapshots: usize, + + /// Terminal grid (cols × rows) the screenshots are rendered at. + #[arg(long, default_value_t = 160)] + cols: u16, + #[arg(long, default_value_t = 44)] + rows: u16, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + fs::create_dir_all(&args.output_dir)?; + + let entries = read_entries(&args.input)?; + eprintln!( + "loaded {} entries from {}", + entries.len(), + args.input.display() + ); + + // Build a baseline state by replaying the first N snapshots' worth of + // events. We count "snapshots applied" to control the sparkline depth. + let _base_state = build_state("default-dark", &entries, args.snapshots); + + // Each screenshot is (filename, mutator) — the mutator fork-edits a + // clone of base_state for that view. Theme variants get a fresh state + // with a different theme so the picker and gradients re-render. + type BuildFn = Box AppState>; + let mut tasks: Vec<(&str, BuildFn)> = Vec::new(); + let make = |theme: &'static str| { + let entries = entries.clone(); + let n = args.snapshots; + move || build_state(theme, &entries, n) + }; + + // Default theme: every tab + every modal. + let mk_dark = make("default-dark"); + tasks.push(( + "overview", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Overview; + s + } + }), + )); + tasks.push(( + "hardware", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Hardware; + s + } + }), + )); + tasks.push(( + "hardware-detail", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Hardware; + s.gpu_sel = 2; + s.modal = Modal::Detail; + s + } + }), + )); + tasks.push(( + "instances", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Instances; + s + } + }), + )); + tasks.push(( + "instances-detail", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Instances; + s.instance_sel = 0; + s.modal = Modal::Detail; + s + } + }), + )); + tasks.push(( + "bench", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Bench; + s + } + }), + )); + tasks.push(( + "bench-detail", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.active_tab = ActiveTab::Bench; + s.bench_sel = s.bench_rows.len().saturating_sub(1); + s.modal = Modal::Detail; + s + } + }), + )); + tasks.push(( + "help", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.modal = Modal::Help; + s + } + }), + )); + tasks.push(( + "theme-picker", + Box::new({ + let m = mk_dark.clone(); + move || { + let mut s = m(); + s.open_theme_picker(); + // Highlight a non-default theme so the preview pane shows something different. + let names = ui::theme::theme_names(); + if let Some(pos) = names.iter().position(|n| *n == "tokyo-night") { + s.theme_picker_sel = pos; + } + s + } + }), + )); + + // Theme variants of the Overview tab — proof that the gradient + // primitives retint correctly under arbitrary palettes. + for theme in ["dracula", "gruvbox-dark", "nord", "catppuccin-mocha"] { + let m = make(theme); + let label = format!("overview-{theme}"); + tasks.push(( + Box::leak(label.into_boxed_str()), + Box::new({ + move || { + let mut s = m(); + s.active_tab = ActiveTab::Overview; + s + } + }), + )); + } + + let mut total_bytes: usize = 0; + for (name, build) in tasks { + let mut state = build(); + let svg = render_to_svg(&mut state, args.cols, args.rows)?; + let path = args.output_dir.join(format!("{name}.svg")); + fs::write(&path, &svg)?; + total_bytes += svg.len(); + eprintln!( + " wrote {} ({:.1} KB)", + path.display(), + svg.len() as f64 / 1024.0 + ); + } + eprintln!("done — {:.1} KB total", total_bytes as f64 / 1024.0); + Ok(()) +} + +fn read_entries(path: &std::path::Path) -> anyhow::Result> { + let raw = fs::read_to_string(path)?; + raw.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).map_err(Into::into)) + .collect() +} + +/// Replay entries into a fresh `AppState` until `snapshots_target` snapshot +/// events have been consumed (or the file runs out). +fn build_state(theme: &str, entries: &[PersistedEntry], snapshots_target: usize) -> AppState { + let mut state = AppState::new("demo-mi355x-01".into(), theme.into()); + state.conn = ConnState::Connected { + host: "demo-mi355x-01".into(), + version: "0.1.0".into(), + }; + let mut snap_count = 0usize; + for entry in entries { + state.apply_event(entry.event.clone()); + if matches!(entry.event, Event::Snapshot(_)) { + snap_count += 1; + if snap_count >= snapshots_target { + break; + } + } + } + state +} + +fn render_to_svg(state: &mut AppState, cols: u16, rows: u16) -> anyhow::Result { + let backend = TestBackend::new(cols, rows); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| ui::draw(f, state))?; + let buf = terminal.backend().buffer().clone(); + Ok(buffer_to_svg(&buf, state.theme.bg)) +} + +// --------------------------------------------------------------------------- +// Buffer → SVG +// --------------------------------------------------------------------------- + +/// Cell box dimensions in SVG user-space. 14 px font with a tighter width +/// ratio gives a believable terminal look (~0.6 advance × 1.2 leading). +const CELL_W: f64 = 8.4; +const CELL_H: f64 = 17.0; +const FONT_PX: f64 = 14.0; +const FONT_FAMILY: &str = "ui-monospace, 'JetBrains Mono', 'Cascadia Code', \ + 'Fira Code', Menlo, 'DejaVu Sans Mono', monospace"; + +fn buffer_to_svg(buf: &Buffer, default_bg: Color) -> String { + let cols = buf.area.width; + let rows = buf.area.height; + let w_px = cols as f64 * CELL_W; + let h_px = rows as f64 * CELL_H; + let bg_hex = color_to_hex(default_bg).unwrap_or_else(|| "#13141a".into()); + + let mut out = String::with_capacity(rows as usize * cols as usize * 16); + out.push_str(&format!( + concat!( + r#""#, + ), + w_px = w_px, + h_px = h_px, + ff = FONT_FAMILY, + fs = FONT_PX, + )); + out.push_str(&format!( + r#""# + )); + + // Per-row background runs. + for y in 0..rows { + let mut x = 0u16; + while x < cols { + let cell = buf.cell((x, y)).unwrap(); + let bg = cell_bg_or_default(cell); + if bg == default_bg || matches!(bg, Color::Reset) { + x += 1; + continue; + } + let bg_hex_run = match color_to_hex(bg) { + Some(h) => h, + None => { + x += 1; + continue; + } + }; + let start = x; + let mut end = x + 1; + while end < cols { + let next = buf.cell((end, y)).unwrap(); + if cell_bg_or_default(next) == bg { + end += 1; + } else { + break; + } + } + let rx = start as f64 * CELL_W; + let ry = y as f64 * CELL_H; + let rw = (end - start) as f64 * CELL_W; + out.push_str(&format!( + r#""# + )); + x = end; + } + } + + // Per-row foreground runs. Group by (fg, bold) and emit one per run. + for y in 0..rows { + let mut x = 0u16; + let baseline = y as f64 * CELL_H + FONT_PX * 0.85; + while x < cols { + let cell = buf.cell((x, y)).unwrap(); + let sym = cell.symbol(); + if sym == " " || sym.is_empty() { + x += 1; + continue; + } + let fg = cell.style().fg.and_then(color_to_hex); + let bold = cell.style().add_modifier.contains(Modifier::BOLD); + let start = x; + let mut text = String::new(); + text.push_str(&xml_escape(sym)); + let mut end = x + 1; + while end < cols { + let next = buf.cell((end, y)).unwrap(); + let nsym = next.symbol(); + if nsym == " " || nsym.is_empty() { + break; + } + let nfg = next.style().fg.and_then(color_to_hex); + let nbold = next.style().add_modifier.contains(Modifier::BOLD); + if nfg != fg || nbold != bold { + break; + } + text.push_str(&xml_escape(nsym)); + end += 1; + } + let tx = start as f64 * CELL_W; + let fg_hex = fg.unwrap_or_else(|| "#eaebec".into()); + let weight = if bold { "700" } else { "400" }; + // Render each glyph at its own x via `textLength` so coalesced + // runs preserve monospace alignment regardless of font metrics. + let span_w = (end - start) as f64 * CELL_W; + out.push_str(&format!( + concat!( + r#"{text}"#, + ), + tx = tx, + baseline = baseline, + fg_hex = fg_hex, + weight = weight, + span_w = span_w, + text = text, + )); + x = end; + } + } + + out.push_str(""); + out +} + +fn cell_bg_or_default(cell: &ratatui::buffer::Cell) -> Color { + cell.style().bg.unwrap_or(Color::Reset) +} + +fn color_to_hex(c: Color) -> Option { + match c { + Color::Rgb(r, g, b) => Some(format!("#{r:02x}{g:02x}{b:02x}")), + Color::Reset => None, + // Named ANSI colors — map to sensible defaults for the rare cases + // where a widget reaches for them directly. None of the dashboard's + // widgets do; this is purely defensive. + Color::Black => Some("#000000".into()), + Color::Red => Some("#cc0000".into()), + Color::Green => Some("#4e9a06".into()), + Color::Yellow => Some("#c4a000".into()), + Color::Blue => Some("#3465a4".into()), + Color::Magenta => Some("#75507b".into()), + Color::Cyan => Some("#06989a".into()), + Color::Gray => Some("#d3d7cf".into()), + Color::White => Some("#eeeeec".into()), + Color::DarkGray => Some("#555753".into()), + Color::LightRed => Some("#ef2929".into()), + Color::LightGreen => Some("#8ae234".into()), + Color::LightYellow => Some("#fce94f".into()), + Color::LightBlue => Some("#729fcf".into()), + Color::LightMagenta => Some("#ad7fa8".into()), + Color::LightCyan => Some("#34e2e2".into()), + Color::Indexed(_) => None, + } +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} diff --git a/crates/rocm-dash-tui/src/agent.rs b/crates/rocm-dash-tui/src/agent.rs new file mode 100644 index 00000000..2b51fdf7 --- /dev/null +++ b/crates/rocm-dash-tui/src/agent.rs @@ -0,0 +1,1108 @@ +//! The chat agent backend, built on **Rig**, plus the read-only "Skills" +//! (Rig Tools) the agent calls over cached telemetry. +//! +//! THE ONLY FILE THAT NAMES `rig` TYPES. Everything else talks to the +//! [`AgentClient`] trait, so the Rig dependency is a single swappable seam: +//! tests and the offline demo use [`MockAgentClient`]; the live path uses +//! [`RigAgentClient`] against an OpenAI-compatible endpoint. +//! +//! Rig API verified against `rig-core = "=0.38.1"` (Context7 `/websites/rig_rs` +//! and vendored source). The local-endpoint seam is the Chat Completions API +//! (not the default Responses API), reached via `CompletionsClient`. Tool +//! calling uses `agent.prompt(text).max_turns(N).with_history(history)` so the +//! model can call read-only tools and then answer — one final reply to the UI. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +use rig::completion::ToolDefinition; +use rig::tool::Tool; + +use rocm_dash_core::bench_schema::BenchmarkRow; +use rocm_dash_core::metrics::{GpuMetrics, Instance, Snapshot}; + +use crate::app::{ChatRole, ChatTurn}; +use crate::llm::LlmConfig; + +/// One-shot request budget. A hung backend becomes a timeout error turn, never +/// a frozen pane. +pub const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); + +/// Max tool-calling turns the model may take before producing a final answer. +const MAX_TOOL_TURNS: usize = 5; + +/// Default system preamble for the dashboard assistant. +const DEFAULT_PREAMBLE: &str = "You are the rocm-dash assistant, embedded in a terminal dashboard for AMD \ + Instinct GPU telemetry and benchmarks. Use the provided tools (gpu_status, \ + list_instances, bench_summary, tokens_per_watt) to answer questions about \ + live GPU, serving instance, and benchmark state. Prefer short, direct answers."; + +/// Errors from a chat completion. Public form is string-only so no `rig` type +/// leaks past this file. Messages never include the api_key (it is a header, +/// never part of base_url or the request path). +#[derive(Debug, thiserror::Error)] +pub enum AgentError { + #[error("no message to send")] + Empty, + #[error("failed to build chat client: {0}")] + Build(String), + #[error("chat sign-in failed: {0}")] + Auth(String), + #[error("request timed out after {}s", REQUEST_TIMEOUT.as_secs())] + Timeout, + #[error("chat request failed: {0}")] + Request(String), +} + +/// A plain, cloneable read-only view of the cached telemetry the tools read. +/// Captured at spawn time so tools never touch the pure reducer or `&AppState`. +#[derive(Debug, Clone, Default)] +pub struct StateSnapshot { + pub latest: Option, + pub instances: Vec, + pub bench_rows: Vec, +} + +/// The swappable chat backend seam. +#[async_trait] +pub trait AgentClient: Send + Sync { + /// Complete a conversation. `history` ends with the current user turn; + /// `snapshot` is the read-only telemetry view the tools may query. + async fn complete( + &self, + history: &[ChatTurn], + snapshot: StateSnapshot, + ) -> Result; +} + +/// Map our TUI-local turns to Rig messages, preserving role + order. `Error` +/// turns are UI-local annotations and are never sent to the model. Pure: no I/O. +pub fn build_messages(turns: &[ChatTurn]) -> Vec { + turns + .iter() + .filter_map(|t| match t.role { + ChatRole::User => Some(rig::completion::Message::user(t.content.clone())), + ChatRole::Agent => Some(rig::completion::Message::assistant(t.content.clone())), + ChatRole::Error => None, + }) + .collect() +} + +/// Append a "via: tool, tool" annotation so the operator can see which Skills +/// fired. Deduplicates, preserving first-seen order. Pure. +pub fn annotate_reply(reply: String, skills: &[String]) -> String { + if skills.is_empty() { + return reply; + } + let mut seen: Vec = Vec::new(); + for s in skills { + if !seen.contains(s) { + seen.push(s.clone()); + } + } + format!("{reply}\n⚙ via: {}", seen.join(", ")) +} + +// --------------------------------------------------------------------------- +// Pure tool computations over the snapshot (testable without Rig / async). +// --------------------------------------------------------------------------- + +fn gpus_of(snap: &StateSnapshot) -> &[GpuMetrics] { + snap.latest + .as_ref() + .map(|s| s.gpus.as_slice()) + .unwrap_or(&[]) +} + +fn gpu_json(g: &GpuMetrics) -> Value { + json!({ + "device_id": g.device_id, + "gpu_utilization_pct": g.gpu_utilization_pct, + "temperature_c": g.temperature_c, + "power_w": g.power_w, + "vram_used_mb": g.vram_used_mb, + "vram_total_mb": g.vram_total_mb, + }) +} + +/// Per-GPU util/temp/power/VRAM from the latest snapshot. `gpu_index` selects +/// one GPU; `None` returns all. +pub fn gpu_status_json(snap: &StateSnapshot, gpu_index: Option) -> Value { + let gpus = gpus_of(snap); + match gpu_index { + Some(i) => match gpus.get(i) { + Some(g) => json!({ "gpu_index": i, "gpu": gpu_json(g) }), + None => json!({ "error": format!("no GPU at index {i}"), "gpu_count": gpus.len() }), + }, + None => json!({ "gpus": gpus.iter().map(gpu_json).collect::>() }), + } +} + +/// Discovered serving instances: name, model, status, KV-cache %, req counts. +pub fn list_instances_json(snap: &StateSnapshot) -> Value { + let arr: Vec = snap + .instances + .iter() + .map(|i| { + json!({ + "name": i.container_name, + "model": i.model_name, + "status": format!("{:?}", i.status), + "kv_cache_usage_pct": i.kv_cache_usage_pct, + "running_reqs": i.running_reqs, + "waiting_reqs": i.waiting_reqs, + }) + }) + .collect(); + json!({ "instances": arr, "instance_count": arr.len() }) +} + +/// Per-instance tokens-per-watt: gen tok/s ÷ summed power of its GPUs. Reuses +/// the core efficiency derivation so it matches the reducer exactly. +pub fn tokens_per_watt_json(snap: &StateSnapshot) -> Value { + let gpus = gpus_of(snap); + let arr: Vec = snap + .instances + .iter() + .map(|i| { + let tpw = rocm_dash_core::efficiency::tokens_per_watt(i.gen_tps, &i.gpu_ids, gpus); + json!({ + "name": i.container_name, + "gen_tps": i.gen_tps, + "tokens_per_watt": tpw, + }) + }) + .collect(); + json!({ "instances": arr }) +} + +/// Pass^N / Pass@N rollup over the cached bench rows, reusing the core rollup. +pub fn bench_summary_json(snap: &StateSnapshot) -> Value { + let rollups = rocm_dash_core::bench_rollup::rollup_pass_n(snap.bench_rows.iter()); + let arr: Vec = rollups + .iter() + .map(|r| { + json!({ + "cell": r.cell, + "model": r.model, + "n_trials": r.n_trials, + "n_passed": r.n_passed, + "pass_n_of_n": r.pass_n_of_n, + "pass_at_n": r.pass_at_n, + }) + }) + .collect(); + json!({ "groups": arr, "group_count": rollups.len() }) +} + +// --------------------------------------------------------------------------- +// Rig Tool ("Skill") wrappers. Each holds an `Arc` (read-only) +// and a shared `fired` log so the reply can cite which Skills ran. `call` only +// reads the snapshot — no mutation, no network, no file I/O. +// --------------------------------------------------------------------------- + +type FiredLog = Arc>>; + +fn record(fired: &FiredLog, name: &str) { + if let Ok(mut g) = fired.lock() { + g.push(name.to_string()); + } +} + +/// Error type for all tools. Tools are read-only and effectively infallible, +/// but the trait requires an error type. +#[derive(Debug, thiserror::Error)] +#[error("tool error: {0}")] +pub struct ToolError(String); + +/// Empty argument payload for tools that take no parameters. +#[derive(Debug, Deserialize, Default)] +pub struct NoArgs {} + +pub struct GpuStatusTool { + pub snap: Arc, + pub fired: FiredLog, +} + +#[derive(Debug, Deserialize, Default)] +pub struct GpuStatusArgs { + #[serde(default)] + pub gpu_index: Option, +} + +impl Tool for GpuStatusTool { + const NAME: &'static str = "gpu_status"; + type Error = ToolError; + type Args = GpuStatusArgs; + type Output = Value; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Per-GPU utilization %, temperature °C, power W and VRAM MB \ + from the latest telemetry snapshot. Optional gpu_index \ + selects a single GPU." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "gpu_index": { + "type": "integer", + "description": "Zero-based GPU index; omit for all GPUs." + } + } + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + record(&self.fired, Self::NAME); + Ok(gpu_status_json(&self.snap, args.gpu_index)) + } +} + +pub struct ListInstancesTool { + pub snap: Arc, + pub fired: FiredLog, +} + +impl Tool for ListInstancesTool { + const NAME: &'static str = "list_instances"; + type Error = ToolError; + type Args = NoArgs; + type Output = Value; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "List discovered serving instances: name, model, status, \ + KV-cache usage %, and running/waiting request counts." + .to_string(), + parameters: json!({ "type": "object", "properties": {} }), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + record(&self.fired, Self::NAME); + Ok(list_instances_json(&self.snap)) + } +} + +pub struct BenchSummaryTool { + pub snap: Arc, + pub fired: FiredLog, +} + +impl Tool for BenchSummaryTool { + const NAME: &'static str = "bench_summary"; + type Error = ToolError; + type Args = NoArgs; + type Output = Value; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Pass^N / Pass@N benchmark rollup grouped by cell/model/\ + engine/tp/dtype/concurrency over the cached bench rows." + .to_string(), + parameters: json!({ "type": "object", "properties": {} }), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + record(&self.fired, Self::NAME); + Ok(bench_summary_json(&self.snap)) + } +} + +pub struct TokensPerWattTool { + pub snap: Arc, + pub fired: FiredLog, +} + +impl Tool for TokensPerWattTool { + const NAME: &'static str = "tokens_per_watt"; + type Error = ToolError; + type Args = NoArgs; + type Output = Value; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Per-instance efficiency: generation tokens/sec divided by \ + the summed power (W) of the GPUs each instance occupies." + .to_string(), + parameters: json!({ "type": "object", "properties": {} }), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + record(&self.fired, Self::NAME); + Ok(tokens_per_watt_json(&self.snap)) + } +} + +/// Read-only tool exposing the rocm-dash **skills** registry (auto-config / +/// auto-install). The agent can list skills and fetch a skill's dry-run plan; +/// it never executes a skill (execution is `--apply`-gated in the CLI). +pub struct ListSkillsTool { + pub fired: FiredLog, +} + +impl Tool for ListSkillsTool { + const NAME: &'static str = "list_skills"; + type Error = ToolError; + type Args = NoArgs; + type Output = Value; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "List the available rocm-dash skills (auto-config / \ + auto-install helpers like install-lemonade and \ + auto-config-endpoint) the user can run." + .to_string(), + parameters: json!({ "type": "object", "properties": {} }), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + record(&self.fired, Self::NAME); + let skills: Vec = crate::skills::builtin_skills() + .iter() + .map(|s| json!({ "name": s.name, "description": s.description })) + .collect(); + Ok(json!({ "skills": skills, "skill_count": skills.len() })) + } +} + +/// Read-only tool returning a skill's ordered dry-run plan (no execution). +pub struct SkillPlanTool { + pub fired: FiredLog, +} + +#[derive(Debug, Deserialize, Default)] +pub struct SkillPlanArgs { + pub name: String, +} + +impl Tool for SkillPlanTool { + const NAME: &'static str = "skill_plan"; + type Error = ToolError; + type Args = SkillPlanArgs; + type Output = Value; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Show the ordered dry-run step plan for a named skill \ + WITHOUT executing it. Use after list_skills." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Skill name, e.g. install-lemonade." } + }, + "required": ["name"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + record(&self.fired, Self::NAME); + match crate::skills::builtin_skill(&args.name) { + Some(m) => Ok(json!({ "name": m.name, "plan": crate::skills::build_plan(&m) })), + None => Ok(json!({ "error": format!("unknown skill: {}", args.name) })), + } + } +} + +/// All Skill names, for definition/uniqueness checks and docs. +pub const SKILL_NAMES: [&str; 6] = [ + GpuStatusTool::NAME, + ListInstancesTool::NAME, + BenchSummaryTool::NAME, + TokensPerWattTool::NAME, + ListSkillsTool::NAME, + SkillPlanTool::NAME, +]; + +/// Live Rig-backed client for an OpenAI-compatible endpoint. The Rig client is +/// constructed once; the agent + tools are rebuilt per request from the +/// captured snapshot. +pub struct RigAgentClient { + client: rig::providers::openai::CompletionsClient, + model: String, + preamble: String, +} + +impl RigAgentClient { + pub fn new(cfg: LlmConfig) -> Result { + // Custom-auth gateway (e.g. Azure APIM `Ocp-Apim-Subscription-Key`): + // the key goes in a custom header, NOT `Authorization: Bearer`. Rig + // still requires an api_key, so pass a dummy Bearer the gateway ignores. + let custom_headers = match (cfg.auth_header.as_deref(), cfg.api_key.as_deref()) { + (Some(name), Some(key)) => Some(auth_header_map(name, key)?), + _ => None, + }; + // Bearer carries the real key ONLY in the standard (no custom header) + // case; otherwise a dummy (local endpoints / custom-header gateways). + let bearer = match (&custom_headers, cfg.api_key.as_deref()) { + (None, Some(key)) => key.to_string(), + _ => "sk-no-key".to_string(), + }; + + // `.api_key()` sets the builder typestate, so it must be in the chain; + // `.base_url()` / `.http_headers()` return Self and can follow. + let mut builder = rig::providers::openai::CompletionsClient::builder() + .api_key(&bearer) + .base_url(&cfg.base_url); + if let Some(headers) = custom_headers { + builder = builder.http_headers(headers); + } + + let client = builder + .build() + .map_err(|e| AgentError::Build(e.to_string()))?; + Ok(Self { + client, + model: cfg.model, + preamble: DEFAULT_PREAMBLE.to_string(), + }) + } +} + +/// Build a single-entry `HeaderMap` carrying the gateway's custom auth header. +/// The value is marked sensitive so the HTTP stack won't log it; errors never +/// embed the key value. +fn auth_header_map(name: &str, value: &str) -> Result { + let header_name = http::HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| AgentError::Build(format!("invalid chat_auth_header name: {e}")))?; + let mut header_value = http::HeaderValue::from_str(value) + .map_err(|_| AgentError::Build("invalid auth header value".to_string()))?; + header_value.set_sensitive(true); + let mut map = http::HeaderMap::new(); + map.insert(header_name, header_value); + Ok(map) +} + +#[async_trait] +impl AgentClient for RigAgentClient { + async fn complete( + &self, + history: &[ChatTurn], + snapshot: StateSnapshot, + ) -> Result { + use rig::client::CompletionClient; + use rig::completion::Prompt; + use std::future::IntoFuture; + + let Some((last, prior)) = history.split_last() else { + return Err(AgentError::Empty); + }; + let snap = Arc::new(snapshot); + let fired: FiredLog = Arc::new(Mutex::new(Vec::new())); + + let agent = self + .client + .agent(&self.model) + .preamble(&self.preamble) + .max_tokens(1024) + .tool(GpuStatusTool { + snap: snap.clone(), + fired: fired.clone(), + }) + .tool(ListInstancesTool { + snap: snap.clone(), + fired: fired.clone(), + }) + .tool(BenchSummaryTool { + snap: snap.clone(), + fired: fired.clone(), + }) + .tool(TokensPerWattTool { + snap: snap.clone(), + fired: fired.clone(), + }) + // Skills registry tools (read-only: list + dry-run plan; never execute). + .tool(ListSkillsTool { + fired: fired.clone(), + }) + .tool(SkillPlanTool { + fired: fired.clone(), + }) + .build(); + + let req = agent + .prompt(last.content.clone()) + .max_turns(MAX_TOOL_TURNS) + .with_history(build_messages(prior)); + + let reply = match tokio::time::timeout(REQUEST_TIMEOUT, req.into_future()).await { + Err(_) => return Err(AgentError::Timeout), + Ok(Ok(reply)) => reply, + Ok(Err(e)) => return Err(AgentError::Request(e.to_string())), + }; + + let skills = fired.lock().map(|g| g.clone()).unwrap_or_default(); + Ok(annotate_reply(reply, &skills)) + } +} + +/// No-key ChatGPT backend over Rig's native `chatgpt` OAuth provider — the +/// no-key default that restores the ChatGPT device-login the vendored Codex +/// path provided. It takes NO api_key (the env-only key invariant is untouched: +/// this path authenticates with an OAuth device-code flow, not a key). The +/// `on_device_code` callback surfaces the verification URL + user code so the +/// chat tab can show the operator how to sign in; the resulting token is +/// persisted by the provider so re-launches don't re-prompt. +pub struct ChatGptAgentClient { + client: rig::providers::chatgpt::Client, + model: String, + preamble: String, +} + +impl ChatGptAgentClient { + /// Build the OAuth client. `model` defaults to the provider's Codex model. + /// `on_device_code(verification_uri, user_code)` is invoked during the first + /// `authorize()` (device-code flow). No network I/O happens here — login is + /// deferred to the first `complete()`. + pub fn new(model: Option, on_device_code: F) -> Result + where + F: Fn(String, String) + Send + Sync + 'static, + { + use rig::providers::chatgpt; + // The closure param is the provider's `DeviceCodePrompt` (its `auth` + // module is private, so we let inference name it); its `verification_uri` + // and `user_code` fields are public. + let client = chatgpt::Client::builder() + .oauth() + .on_device_code(move |p| on_device_code(p.verification_uri, p.user_code)) + .build() + .map_err(|e| AgentError::Build(e.to_string()))?; + Ok(Self { + client, + model: model.unwrap_or_else(|| chatgpt::GPT_5_3_CODEX.to_string()), + preamble: DEFAULT_PREAMBLE.to_string(), + }) + } +} + +#[async_trait] +impl AgentClient for ChatGptAgentClient { + async fn complete( + &self, + history: &[ChatTurn], + snapshot: StateSnapshot, + ) -> Result { + use rig::agent::AgentBuilder; + use rig::completion::Prompt; + use rig::providers::chatgpt::ResponsesCompletionModel; + use std::future::IntoFuture; + + let Some((last, prior)) = history.split_last() else { + return Err(AgentError::Empty); + }; + + // Device-code login on first use; the provider caches the token after. + // A failed/declined sign-in is an Auth error (distinct from a build + // failure), surfaced as a clear error turn — never leaks the token. + self.client + .authorize() + .await + .map_err(|e| AgentError::Auth(e.to_string()))?; + + let snap = Arc::new(snapshot); + let fired: FiredLog = Arc::new(Mutex::new(Vec::new())); + let model = ResponsesCompletionModel::new(self.client.clone(), self.model.clone()); + let agent = AgentBuilder::new(model) + .preamble(&self.preamble) + .max_tokens(1024) + .tool(GpuStatusTool { + snap: snap.clone(), + fired: fired.clone(), + }) + .tool(ListInstancesTool { + snap: snap.clone(), + fired: fired.clone(), + }) + .tool(BenchSummaryTool { + snap: snap.clone(), + fired: fired.clone(), + }) + .tool(TokensPerWattTool { + snap: snap.clone(), + fired: fired.clone(), + }) + // Read-only Skills registry tools (list + dry-run plan; never execute). + .tool(ListSkillsTool { + fired: fired.clone(), + }) + .tool(SkillPlanTool { + fired: fired.clone(), + }) + .build(); + + let req = agent + .prompt(last.content.clone()) + .max_turns(MAX_TOOL_TURNS) + .with_history(build_messages(prior)); + + let reply = match tokio::time::timeout(REQUEST_TIMEOUT, req.into_future()).await { + Err(_) => return Err(AgentError::Timeout), + Ok(Ok(reply)) => reply, + Ok(Err(e)) => return Err(AgentError::Request(e.to_string())), + }; + + let skills = fired.lock().map(|g| g.clone()).unwrap_or_default(); + Ok(annotate_reply(reply, &skills)) + } +} + +/// Deterministic in-memory client for tests and the offline demo. Never touches +/// the network. Can emit a canned tool-calling-style answer (cites a Skill). +pub struct MockAgentClient { + reply: String, + fail: bool, + cited: Vec, +} + +impl MockAgentClient { + /// A mock that returns a fixed canned reply. + pub fn new(reply: impl Into) -> Self { + Self { + reply: reply.into(), + fail: false, + cited: Vec::new(), + } + } + + /// A mock that returns a canned reply annotated as if `tool_name` fired — + /// drives the offline tool-calling demo deterministically. + pub fn with_tool_call(reply: impl Into, tool_name: impl Into) -> Self { + Self { + reply: reply.into(), + fail: false, + cited: vec![tool_name.into()], + } + } + + /// A mock whose `complete` always fails (to exercise the error path). + pub fn failing() -> Self { + Self { + reply: String::new(), + fail: true, + cited: Vec::new(), + } + } +} + +#[async_trait] +impl AgentClient for MockAgentClient { + async fn complete( + &self, + history: &[ChatTurn], + _snapshot: StateSnapshot, + ) -> Result { + if self.fail { + return Err(AgentError::Request("mock failure".to_string())); + } + if history.is_empty() { + return Err(AgentError::Empty); + } + Ok(annotate_reply(self.reply.clone(), &self.cited)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rocm_dash_core::bench_schema::{BenchmarkRow, PassFail}; + use rocm_dash_core::metrics::{GpuMetrics, Instance, Snapshot}; + + fn fixture_snapshot() -> StateSnapshot { + let snap = Snapshot { + gpus: vec![ + GpuMetrics { + device_id: "gpu-0".into(), + gpu_utilization_pct: 12.0, + temperature_c: 40.0, + power_w: 100.0, + vram_used_mb: 1000, + vram_total_mb: 192000, + ..Default::default() + }, + GpuMetrics { + device_id: "gpu-1".into(), + gpu_utilization_pct: 55.0, + temperature_c: 60.0, + power_w: 200.0, + vram_used_mb: 50000, + vram_total_mb: 192000, + ..Default::default() + }, + GpuMetrics { + device_id: "gpu-2".into(), + gpu_utilization_pct: 87.0, + temperature_c: 71.0, + power_w: 250.0, + vram_used_mb: 90000, + vram_total_mb: 192000, + ..Default::default() + }, + ], + ..Default::default() + }; + let inst = Instance { + container_name: "vllm-a".into(), + model_name: "deepseek-r1".into(), + gpu_ids: vec!["2".into()], + kv_cache_usage_pct: Some(42.0), + running_reqs: Some(3), + waiting_reqs: Some(1), + gen_tps: Some(500.0), + ..Default::default() + }; + let row = BenchmarkRow { + cell: "c1".into(), + model: Some("deepseek-r1".into()), + pass_fail: PassFail::Pass, + ..Default::default() + }; + StateSnapshot { + latest: Some(snap), + instances: vec![inst], + bench_rows: vec![row], + } + } + + #[test] + fn build_messages_preserves_role_and_order_and_drops_errors() { + let turns = vec![ + ChatTurn::user("first user"), + ChatTurn::agent("first agent"), + ChatTurn::error("local error annotation"), + ChatTurn::user("second user"), + ]; + let msgs = build_messages(&turns); + assert_eq!(msgs.len(), 3); + let dbg = format!("{msgs:?}"); + let i_first_user = dbg.find("first user").expect("first user present"); + let i_first_agent = dbg.find("first agent").expect("first agent present"); + let i_second_user = dbg.find("second user").expect("second user present"); + assert!(i_first_user < i_first_agent); + assert!(i_first_agent < i_second_user); + assert!(!dbg.contains("local error annotation")); + } + + #[test] + fn gpu_status_json_returns_known_gpu_metrics() { + let snap = fixture_snapshot(); + let v = gpu_status_json(&snap, Some(2)); + let g = &v["gpu"]; + assert_eq!(g["device_id"], "gpu-2"); + assert_eq!(g["gpu_utilization_pct"], 87.0); + assert_eq!(g["temperature_c"], 71.0); + assert_eq!(g["power_w"], 250.0); + assert_eq!(g["vram_used_mb"], 90000); + // All-GPU form lists every GPU. + let all = gpu_status_json(&snap, None); + assert_eq!(all["gpus"].as_array().unwrap().len(), 3); + // Out-of-range index → graceful error object, not a panic. + let oob = gpu_status_json(&snap, Some(9)); + assert!(oob["error"].is_string()); + } + + #[test] + fn list_instances_json_reports_instance_fields() { + let v = list_instances_json(&fixture_snapshot()); + assert_eq!(v["instance_count"], 1); + let i = &v["instances"][0]; + assert_eq!(i["name"], "vllm-a"); + assert_eq!(i["model"], "deepseek-r1"); + assert_eq!(i["kv_cache_usage_pct"], 42.0); + assert_eq!(i["running_reqs"], 3); + } + + #[test] + fn tokens_per_watt_json_matches_core_efficiency() { + // gen_tps 500 / power 250 (gpu-2) = 2.0, matching the reducer. + let v = tokens_per_watt_json(&fixture_snapshot()); + assert_eq!(v["instances"][0]["tokens_per_watt"], 2.0); + } + + #[test] + fn bench_summary_json_rolls_up_groups() { + let v = bench_summary_json(&fixture_snapshot()); + assert_eq!(v["group_count"], 1); + let g = &v["groups"][0]; + assert_eq!(g["cell"], "c1"); + assert_eq!(g["n_trials"], 1); + assert_eq!(g["pass_at_n"], true); + } + + #[test] + fn skill_names_are_unique_and_non_empty() { + for n in SKILL_NAMES { + assert!(!n.is_empty(), "skill name must be non-empty"); + } + let mut sorted = SKILL_NAMES.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + SKILL_NAMES.len(), + "skill names must be unique" + ); + } + + #[tokio::test] + async fn gpu_status_tool_call_returns_typed_output() { + let tool = GpuStatusTool { + snap: Arc::new(fixture_snapshot()), + fired: Arc::new(Mutex::new(Vec::new())), + }; + // ToolDefinition is valid: name matches, parameters is an object. + let def = tool.definition(String::new()).await; + assert_eq!(def.name, "gpu_status"); + assert!(def.parameters.is_object()); + // call() returns the expected GPU output and records that it fired. + let out = tool + .call(GpuStatusArgs { gpu_index: Some(2) }) + .await + .expect("tool call ok"); + assert_eq!(out["gpu"]["temperature_c"], 71.0); + assert_eq!(tool.fired.lock().unwrap().as_slice(), ["gpu_status"]); + } + + #[tokio::test] + async fn all_tools_expose_valid_definitions() { + let snap = Arc::new(fixture_snapshot()); + let fired: FiredLog = Arc::new(Mutex::new(Vec::new())); + let g = GpuStatusTool { + snap: snap.clone(), + fired: fired.clone(), + } + .definition(String::new()) + .await; + let l = ListInstancesTool { + snap: snap.clone(), + fired: fired.clone(), + } + .definition(String::new()) + .await; + let b = BenchSummaryTool { + snap: snap.clone(), + fired: fired.clone(), + } + .definition(String::new()) + .await; + let t = TokensPerWattTool { + snap: snap.clone(), + fired: fired.clone(), + } + .definition(String::new()) + .await; + for def in [g, l, b, t] { + assert!(!def.name.is_empty()); + assert!(def.parameters.is_object()); + } + } + + #[tokio::test] + async fn skill_tools_expose_both_demo_skills() { + // list_skills returns both demo skills (the agent can see them). + let list = ListSkillsTool { + fired: Arc::new(Mutex::new(Vec::new())), + }; + let def = list.definition(String::new()).await; + assert_eq!(def.name, "list_skills"); + let out = list.call(NoArgs::default()).await.expect("list ok"); + let names: Vec = out["skills"] + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"install-lemonade".to_string())); + assert!(names.contains(&"auto-config-endpoint".to_string())); + + // skill_plan returns the ordered dry-run plan for a named skill. + let plan_tool = SkillPlanTool { + fired: Arc::new(Mutex::new(Vec::new())), + }; + let out = plan_tool + .call(SkillPlanArgs { + name: "install-lemonade".to_string(), + }) + .await + .expect("plan ok"); + let plan = out["plan"].as_array().unwrap(); + assert!( + plan.iter() + .any(|l| l.as_str().unwrap().contains("lemonade-sdk")) + ); + // Unknown skill → graceful error object, not a panic. + let miss = plan_tool + .call(SkillPlanArgs { + name: "nope".to_string(), + }) + .await + .unwrap(); + assert!(miss["error"].is_string()); + } + + #[test] + fn skill_tool_names_registered_in_skill_names() { + assert!(SKILL_NAMES.contains(&"list_skills")); + assert!(SKILL_NAMES.contains(&"skill_plan")); + } + + #[test] + fn annotate_reply_appends_deduped_skills() { + let r = annotate_reply("hi".into(), &["gpu_status".into(), "gpu_status".into()]); + assert!(r.contains("hi")); + assert!(r.contains("via: gpu_status")); + // No skills → unchanged. + assert_eq!(annotate_reply("hi".into(), &[]), "hi"); + } + + #[tokio::test] + async fn mock_tool_calling_answer_cites_skill() { + // The offline "what's GPU-2 doing?" demo path — no live LLM. + let agent = + MockAgentClient::with_tool_call("GPU-2 is at 87% util, 71°C, 250 W.", "gpu_status"); + let history = vec![ChatTurn::user("what's GPU-2 doing?")]; + let reply = agent + .complete(&history, fixture_snapshot()) + .await + .expect("mock reply"); + assert!(reply.contains("87% util")); + assert!( + reply.contains("gpu_status"), + "reply cites the Skill that fired" + ); + } + + #[tokio::test] + async fn mock_error_path_is_err_not_panic() { + let agent = MockAgentClient::failing(); + let history = vec![ChatTurn::user("hi")]; + let err = agent + .complete(&history, StateSnapshot::default()) + .await + .unwrap_err(); + assert!(matches!(err, AgentError::Request(_))); + } + + #[tokio::test] + async fn mock_empty_history_is_empty_error() { + let agent = MockAgentClient::new("x"); + let err = agent + .complete(&[], StateSnapshot::default()) + .await + .unwrap_err(); + assert!(matches!(err, AgentError::Empty)); + } + + /// Manual-demo verification of the live Rig path (tool-calling) against a + /// local OpenAI-compatible endpoint. NOT run in CI (no live LLM). Run with: + /// `cargo test -p rocm-dash-tui --lib rig_round_trip -- --ignored` + /// after starting a local endpoint (e.g. vLLM/Ollama at 127.0.0.1:8000/v1). + #[tokio::test] + #[ignore = "requires a live local OpenAI-compatible endpoint"] + async fn rig_round_trip_against_local_endpoint() { + let cfg = LlmConfig { + base_url: "http://127.0.0.1:8000/v1".to_string(), + model: "local-model".to_string(), + api_key: None, + auth_header: None, + }; + let client = RigAgentClient::new(cfg).expect("build rig client"); + let history = vec![ChatTurn::user("What's GPU-2 doing? Use the tools.")]; + let reply = client + .complete(&history, fixture_snapshot()) + .await + .expect("live reply"); + assert!(!reply.is_empty()); + } + + /// Live round-trip against the AMD LLM gateway (Azure APIM, custom auth + /// header). NOT run in CI. Requires `AMD_LLM_API_KEY` (the APIM subscription + /// key) in the environment. Run with: + /// `cargo test -p rocm-dash-tui --lib rig_round_trip_against_amd_gateway -- --ignored --nocapture` + #[tokio::test] + #[ignore = "requires AMD_LLM_API_KEY and network access to llm-api.amd.com"] + async fn rig_round_trip_against_amd_gateway() { + let key = std::env::var("AMD_LLM_API_KEY") + .or_else(|_| std::env::var("AZURE_OPENAI_API_KEY")) + .expect("set AMD_LLM_API_KEY"); + let cfg = LlmConfig { + base_url: "https://llm-api.amd.com/OpenAI".to_string(), + model: "gpt-4o-mini".to_string(), + api_key: Some(key), + auth_header: Some("Ocp-Apim-Subscription-Key".to_string()), + }; + let client = RigAgentClient::new(cfg).expect("build rig client"); + let history = vec![ChatTurn::user("Reply with exactly: gateway ok")]; + let reply = client + .complete(&history, fixture_snapshot()) + .await + .expect("gateway reply"); + eprintln!("AMD gateway reply: {reply}"); + assert!(!reply.is_empty()); + } + + #[test] + fn chatgpt_oauth_client_builds_offline_without_taking_a_key() { + // Construction is offline (login is deferred to authorize() in + // complete()); the device-code callback is wired but not yet invoked. + // Crucially, the constructor signature takes NO api_key — the env-only + // key invariant is structurally preserved on the no-key path. + let fired = Arc::new(Mutex::new(Vec::::new())); + let sink = fired.clone(); + let client = + ChatGptAgentClient::new(Some("gpt-5.3-codex".to_string()), move |url, code| { + // Would surface in the chat tab during a real device-code login. + sink.lock().unwrap().push(format!("{url}|{code}")); + }) + .expect("build chatgpt oauth client"); + assert_eq!(client.model, "gpt-5.3-codex"); + // No network happened, so the handler has not fired yet. + assert!(fired.lock().unwrap().is_empty()); + } + + #[test] + fn chatgpt_oauth_client_defaults_model_when_none() { + let client = + ChatGptAgentClient::new(None, |_url, _code| {}).expect("build chatgpt oauth client"); + assert_eq!( + client.model, + rig::providers::chatgpt::GPT_5_3_CODEX, + "the no-key default uses the provider's Codex model" + ); + } + + /// Live no-key device-code round-trip against ChatGPT. NOT run in CI + /// (interactive OAuth + network). Run with: + /// `cargo test -p rocm-dash-tui --lib chatgpt_oauth_round_trip -- --ignored --nocapture` + /// then complete the device login in a browser. + #[tokio::test] + #[ignore = "interactive ChatGPT OAuth device-code login + network"] + async fn chatgpt_oauth_round_trip() { + let client = ChatGptAgentClient::new(None, |url, code| { + eprintln!("Sign in: open {url} and enter code {code}"); + }) + .expect("build chatgpt oauth client"); + let history = vec![ChatTurn::user("Reply with exactly: oauth ok")]; + let reply = client + .complete(&history, fixture_snapshot()) + .await + .expect("oauth reply"); + eprintln!("ChatGPT OAuth reply: {reply}"); + assert!(!reply.is_empty()); + } +} diff --git a/crates/rocm-dash-tui/src/app.rs b/crates/rocm-dash-tui/src/app.rs new file mode 100644 index 00000000..493fb7bf --- /dev/null +++ b/crates/rocm-dash-tui/src/app.rs @@ -0,0 +1,2579 @@ +//! Event loop. Sets up the terminal, spawns the client task, drives renders. + +use std::collections::VecDeque; +use std::io; +use std::time::Duration; + +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream, KeyCode, KeyEvent, + KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures::StreamExt; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use std::collections::HashMap; + +use rocm_dash_core::bench_schema::BenchmarkRow; +use rocm_dash_core::metrics::{Instance, Snapshot}; +use rocm_dash_core::protocol::Event; +use tokio::sync::mpsc; +use tokio::time::interval; + +use crate::client::{self, ClientMsg}; +use crate::ui; +use crate::ui::theme::Theme; + +/// Args after CLI + config resolution. Consumed by `run`. +#[derive(Debug, Clone)] +pub struct ResolvedArgs { + pub connect: String, + pub token: Option, + pub theme: String, + /// When `Some`, replay events from a file instead of connecting to a + /// live daemon. Mutually exclusive with `connect` (enforced by clap). + pub replay: Option, + /// Which tab is active when the TUI opens. `Chat` for the chat-first launch + /// (bare `rocm` / `rocm chat`); `Overview` for the dashboard (`rocm dash`). + pub initial_tab: ActiveTab, + /// Chat endpoint base URL, CLI-flag value already merged over config. + pub chat_url: Option, + /// Chat model, CLI-flag value already merged over config. + pub chat_model: Option, + /// Custom auth header NAME (CLI-flag value merged over config), e.g. + /// `Ocp-Apim-Subscription-Key` for Azure APIM gateways. + pub chat_auth_header: Option, + /// Chat endpoint base URL from the environment (`OPENAI_BASE_URL`). + /// A separate, lower-precedence tier than `chat_url`. + pub chat_env_url: Option, + /// Chat api key, sourced from the environment ONLY (never TOML/CLI/source). + pub chat_api_key: Option, + /// Pre-consent to using the detected endpoint (`--chat-yes`), skipping the + /// one-time in-TUI prompt for the demo. + pub chat_auto_consent: bool, + /// Use the offline `MockAgentClient` for chat (`--chat-mock`) — a + /// deterministic, fully-offline demo with no live LLM. + pub chat_mock: bool, + /// Built-in model recipes for the serve wizard's picker (Phase 3 Wave 1). + /// Adapted by the bin (`apps/rocm`, which has `rocm-core`) so this crate + /// needs no `rocm-core` dep. Empty when none are available. + pub model_recipes: Vec, + /// Registered ROCm runtimes for the runtime manager (Phase 3 Wave 2). + /// Adapted by the bin (`apps/rocm`, which has `rocm-core`) so this crate + /// needs no `rocm-core` dep. Empty when none are available. + pub runtimes: Vec, + /// Background checks for the automations manager (Phase 3 Wave 3). Adapted + /// by the bin. Empty when none are available. + pub automations: Vec, +} + +type Tui = Terminal>; + +/// How many snapshots to keep for sparklines. +pub const HISTORY_CAP: usize = 240; + +/// How many benchmark rows to keep client-side for the bench panel. +pub const BENCH_CAP: usize = 200; + +/// Lines per PageUp/PageDown step in the chat transcript. +const CHAT_SCROLL_STEP: i16 = 5; + +#[derive(Debug, Clone, Default)] +pub enum ConnState { + #[default] + Initial, + Connecting, + Connected { + host: String, + version: String, + }, + Disconnected { + reason: String, + }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ActiveTab { + #[default] + Overview, + Hardware, + Instances, + Bench, + Chat, +} + +impl ActiveTab { + pub fn next(self) -> Self { + match self { + ActiveTab::Overview => ActiveTab::Hardware, + ActiveTab::Hardware => ActiveTab::Instances, + ActiveTab::Instances => ActiveTab::Bench, + ActiveTab::Bench => ActiveTab::Chat, + ActiveTab::Chat => ActiveTab::Overview, + } + } + pub fn prev(self) -> Self { + match self { + ActiveTab::Overview => ActiveTab::Chat, + ActiveTab::Hardware => ActiveTab::Overview, + ActiveTab::Instances => ActiveTab::Hardware, + ActiveTab::Bench => ActiveTab::Instances, + ActiveTab::Chat => ActiveTab::Bench, + } + } + pub fn from_digit(d: char) -> Option { + match d { + '1' => Some(ActiveTab::Overview), + '2' => Some(ActiveTab::Hardware), + '3' => Some(ActiveTab::Instances), + '4' => Some(ActiveTab::Bench), + '5' => Some(ActiveTab::Chat), + _ => None, + } + } +} + +/// Who authored a chat turn. Plain TUI-local data — `rocm-dash-core` carries +/// no chat types (invariant `LRN-20260405-004`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatRole { + User, + Agent, + Error, +} + +/// One line in the chat transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatTurn { + pub role: ChatRole, + pub content: String, +} + +impl ChatTurn { + pub fn user(content: impl Into) -> Self { + Self { + role: ChatRole::User, + content: content.into(), + } + } + pub fn agent(content: impl Into) -> Self { + Self { + role: ChatRole::Agent, + content: content.into(), + } + } + pub fn error(content: impl Into) -> Self { + Self { + role: ChatRole::Error, + content: content.into(), + } + } +} + +/// Consent state for using the auto-detected LLM endpoint. The chat surface +/// asks once before any request leaves the machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChatConsent { + /// No endpoint detected from any source — actionable empty-state. + #[default] + Unavailable, + /// Endpoint detected; awaiting the user's one-time accept/decline. + Pending, + /// User accepted — chat is enabled. + Accepted, + /// User declined — chat stays off until re-enabled. + Declined, +} + +/// Inputs `handle_key` needs to interpret keys on the Chat tab without holding +/// `&AppState` (keeps the function pure and unit-testable). +#[derive(Debug, Clone, Copy)] +pub struct ChatKeyCtx { + pub focused: bool, + pub consent: ChatConsent, + /// A locally-detected endpoint is awaiting use/dismiss — its keys take + /// precedence over the normal consent prompt. + pub offer_pending: bool, +} + +impl Default for ChatKeyCtx { + fn default() -> Self { + // Default to a usable, unfocused surface for tests that don't exercise + // consent/insert specifics. + Self { + focused: false, + consent: ChatConsent::Accepted, + offer_pending: false, + } + } +} + +/// Replay scrubber state. Only present when `--replay` was given. +#[derive(Debug, Clone)] +pub struct ReplayState { + pub controller: crate::replay::ReplayController, + pub paused: bool, + pub speed: f64, + /// Current playhead in seconds since the start of the recording. + pub elapsed_s: u64, + /// Total length of the recording in seconds. + pub total_s: u64, +} + +impl ReplayState { + pub fn new(controller: crate::replay::ReplayController) -> Self { + Self { + controller, + paused: false, + speed: 1.0, + elapsed_s: 0, + total_s: 0, + } + } +} + +/// Format a duration in seconds as `M:SS` (or `H:MM:SS` past an hour). +pub fn format_mmss(secs: u64) -> String { + if secs >= 3600 { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + format!("{h}:{m:02}:{s:02}") + } else { + let m = secs / 60; + let s = secs % 60; + format!("{m}:{s:02}") + } +} + +/// Modal overlays. Only one is shown at a time, on top of the active tab body. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Modal { + #[default] + None, + Help, + Detail, + ThemePicker, +} + +pub struct AppState { + pub connect: String, + pub conn: ConnState, + pub latest: Option, + pub history: VecDeque, + pub bench_rows: VecDeque, + pub instances: HashMap, + pub active_tab: ActiveTab, + pub modal: Modal, + /// Cursor into the sorted Instances grid. + pub instance_sel: usize, + /// Cursor into the bench_rows VecDeque (0 = oldest, len-1 = newest). + pub bench_sel: usize, + /// Cursor into the Hardware tab's per-GPU panel list. + pub gpu_sel: usize, + /// Scroll offset (first visible GPU index) for the Hardware tab when the + /// GPU list renders as a scrolled window of compact rows. Kept in sync with + /// `gpu_sel` so the selection stays visible. + pub gpu_scroll: usize, + pub theme_name: String, + pub theme: Theme, + pub theme_picker_sel: usize, + /// Scroll offset (in lines) inside the Bench Detail modal. Reset on Open. + pub bench_detail_scroll: u16, + /// Chat transcript (TUI-local; never travels over the daemon protocol). + pub chat: Vec, + /// Pending input buffer for the Chat tab. + pub chat_input: String, + /// True while a chat request is in flight (drives a spinner / disables send). + pub chat_sending: bool, + /// Edge flag: set by `submit_chat`, consumed once by `event_loop` to spawn + /// the agent round-trip. Keeps `apply_action` I/O-free (it only mutates). + pub chat_dispatch: bool, + /// True while the Chat tab has text-entry focus: keys go to `chat_input` + /// instead of firing global hotkeys. + pub chat_focused: bool, + /// Scroll offset (lines from top) into the chat transcript. Clamped at 0; + /// the renderer clamps the upper bound against the actual line count. + pub chat_scroll: u16, + /// Resolved chat endpoint (base_url + model + env api_key). `None` when no + /// endpoint was detected. `api_key` is never rendered or logged. + pub chat_llm: Option, + /// One-time consent gate for using the detected endpoint. + pub chat_consent: ChatConsent, + /// A locally-detected chat endpoint awaiting the user's use/save/dismiss + /// choice (the in-TUI "detect a local engine" flow). `None` normally. + pub chat_detect_offer: Option, + /// True while a local-engine probe is in flight (drives a "detecting…" hint). + pub chat_detecting: bool, + /// Edge flag: set by `request_detect`, consumed once by `event_loop` to run + /// the probe + `/v1/models` query off the reducer. Keeps `apply_action` + /// I/O-free (it only mutates). + pub chat_detect_dispatch: bool, + /// Transient message from the last detect attempt (e.g. "no local engine + /// found"), shown on the gate. Cleared when a new detect starts. + pub chat_detect_msg: Option, + /// Edge flag: set by `save_detect_offer`, consumed once by `event_loop` to + /// persist the accepted endpoint to `config.toml`. Keeps `apply_action` + /// I/O-free. + pub chat_persist_dispatch: bool, + /// Replay scrubber state. `None` when running against a live daemon. + pub replay: Option, + /// Last body area used by the most recent draw. Mouse hit-tests resolve + /// pointer coordinates against this rect (filled by `ui::draw`). + pub last_body_area: Option, + /// Same for the tab bar, used for click-to-switch-tab. + pub last_tab_bar_area: Option, + /// Background-job model for operational screens (Phase 3 Wave 1). The + /// job-bridge runtime streams `StateEvent`s into this from the event loop. + pub jobs: rocm_dash_core::state::State, + /// Services manager overlay (Phase 3 Wave 1). `None` = closed. + pub services: Option, + /// Serve wizard overlay (Phase 3 Wave 1). `None` = closed. + pub serve_wizard: Option, + /// Engine manager overlay (Phase 3 Wave 1). `None` = closed. + pub engine_manager: Option, + /// Doctor overlay (Phase 3 Wave 2). `None` = closed. + pub doctor_manager: Option, + /// Update overlay (Phase 3 Wave 2). `None` = closed. + pub update_manager: Option, + /// Install overlay (Phase 3 Wave 2). `None` = closed. + pub install_manager: Option, + /// Logs overlay (Phase 3 Wave 3). `None` = closed. + pub logs_view: Option, + /// Runtime manager overlay (Phase 3 Wave 2). `None` = closed. + pub runtime_manager: Option, + /// Onboarding wizard overlay (Phase 3 Wave 2). `None` = closed. + pub onboarding: Option, + /// Automations manager overlay (Phase 3 Wave 3). `None` = closed. + pub automations_manager: Option, + /// Command runner overlay (Phase 3 Wave 3). `None` = closed. + pub command_screen: Option, + /// Config & provider manager overlay (Phase 3 Wave 3). `None` = closed. + pub config_manager: Option, + /// Built-in model recipes for the serve wizard's picker. Set from + /// `ResolvedArgs` in the event loop; empty by default. + pub model_recipes: Vec, + /// Registered ROCm runtimes for the runtime manager. Set from + /// `ResolvedArgs` in the event loop; empty by default. + pub runtimes: Vec, + /// Background checks for the automations manager. Set from `ResolvedArgs` + /// in the event loop; empty by default. + pub automations: Vec, +} + +impl AppState { + pub fn new(connect: String, theme_name: String) -> Self { + let theme = Theme::from_name(&theme_name); + let names = crate::ui::theme::theme_names(); + let theme_picker_sel = names.iter().position(|n| *n == theme_name).unwrap_or(0); + Self { + connect, + conn: ConnState::Initial, + latest: None, + history: VecDeque::with_capacity(HISTORY_CAP), + bench_rows: VecDeque::with_capacity(BENCH_CAP), + instances: HashMap::new(), + active_tab: ActiveTab::default(), + modal: Modal::None, + instance_sel: 0, + bench_sel: 0, + gpu_sel: 0, + gpu_scroll: 0, + theme_name, + theme, + theme_picker_sel, + bench_detail_scroll: 0, + chat: Vec::new(), + chat_input: String::new(), + chat_sending: false, + chat_dispatch: false, + chat_focused: false, + chat_scroll: 0, + chat_llm: None, + chat_consent: ChatConsent::Unavailable, + chat_detect_offer: None, + chat_detecting: false, + chat_detect_dispatch: false, + chat_detect_msg: None, + chat_persist_dispatch: false, + replay: None, + last_body_area: None, + last_tab_bar_area: None, + jobs: rocm_dash_core::state::State::default(), + services: None, + serve_wizard: None, + engine_manager: None, + doctor_manager: None, + update_manager: None, + install_manager: None, + logs_view: None, + runtime_manager: None, + onboarding: None, + automations_manager: None, + command_screen: None, + config_manager: None, + model_recipes: Vec::new(), + runtimes: Vec::new(), + automations: Vec::new(), + } + } + + /// Close every operational overlay. The overlays are mutually exclusive + /// (only one is routed/drawn at a time), so opening any one first clears the + /// rest — no open path can leave two `Some` at once. + fn close_overlays(&mut self) { + self.services = None; + self.serve_wizard = None; + self.engine_manager = None; + self.doctor_manager = None; + self.update_manager = None; + self.install_manager = None; + self.logs_view = None; + self.runtime_manager = None; + self.onboarding = None; + self.automations_manager = None; + self.command_screen = None; + self.config_manager = None; + } + + /// Open the theme picker modal, positioning the cursor on the active theme. + pub fn open_theme_picker(&mut self) { + let names = crate::ui::theme::theme_names(); + self.theme_picker_sel = names + .iter() + .position(|n| *n == self.theme_name) + .unwrap_or(0); + self.modal = Modal::ThemePicker; + } + + /// Move the theme picker cursor. Clamped to the theme registry length. + pub fn theme_picker_move(&mut self, delta: isize) { + let len = crate::ui::theme::theme_names().len(); + if len == 0 { + return; + } + let next = (self.theme_picker_sel as isize + delta).clamp(0, len as isize - 1) as usize; + self.theme_picker_sel = next; + } + + pub fn theme_picker_first(&mut self) { + self.theme_picker_sel = 0; + } + + pub fn theme_picker_last(&mut self) { + let len = crate::ui::theme::theme_names().len(); + if len > 0 { + self.theme_picker_sel = len - 1; + } + } + + /// Reset the bench-detail scroll offset (called when opening the modal). + pub fn reset_bench_detail_scroll(&mut self) { + self.bench_detail_scroll = 0; + } + + /// Adjust the bench-detail scroll. `delta` is in lines; clamped at 0 + /// (no upper bound — the renderer clamps against the actual line count). + pub fn scroll_bench_detail(&mut self, delta: i16) { + let cur = self.bench_detail_scroll as i32; + let next = (cur + delta as i32).max(0) as u16; + self.bench_detail_scroll = next; + } + + /// Install the resolved chat endpoint and set the initial consent state. + /// `None` → `Unavailable`; `Some` → `Accepted` when pre-consented (e.g. + /// `--chat-yes`), otherwise `Pending` (the one-time in-TUI prompt). + pub fn set_chat_config(&mut self, llm: Option, pre_consent: bool) { + self.chat_consent = match (&llm, pre_consent) { + (None, _) => ChatConsent::Unavailable, + (Some(_), true) => ChatConsent::Accepted, + (Some(_), false) => ChatConsent::Pending, + }; + self.chat_llm = llm; + } + + /// Accept the detected endpoint and enable chat. No-op when no endpoint is + /// available. Focuses the input so the user can type immediately. + pub fn accept_chat_consent(&mut self) { + if self.chat_llm.is_some() { + self.chat_consent = ChatConsent::Accepted; + self.chat_focused = true; + } + } + + /// Decline the detected endpoint. Chat stays off (re-enable with `y`). + pub fn decline_chat_consent(&mut self) { + if self.chat_llm.is_some() { + self.chat_consent = ChatConsent::Declined; + self.chat_focused = false; + } + } + + /// Request an in-TUI local-engine probe. Raises the one-shot + /// `chat_detect_dispatch` edge so `event_loop` runs the probe + `/v1/models` + /// query off the reducer. No-op while a probe is already in flight or an + /// offer is awaiting a decision. I/O-free. + pub fn request_detect(&mut self) { + if self.chat_detecting || self.chat_detect_offer.is_some() { + return; + } + self.chat_detecting = true; + self.chat_detect_msg = None; + self.chat_detect_dispatch = true; + } + + /// Record the result of a detect attempt: `Some(cfg)` raises the offer + /// prompt; `None` records a "nothing found" message. Clears the in-flight + /// flag either way. + pub fn set_detect_result(&mut self, offer: Option) { + self.chat_detecting = false; + match offer { + Some(cfg) => { + self.chat_detect_msg = None; + self.chat_detect_offer = Some(cfg); + } + None => { + self.chat_detect_offer = None; + self.chat_detect_msg = + Some("no local engine found (Lemonade :13305 / vLLM :8000)".into()); + } + } + } + + /// Accept the detected local endpoint for this session: switch `chat_llm` + /// to the offer and enable chat. No-op when no offer is pending. + pub fn accept_detect_offer(&mut self) { + if let Some(cfg) = self.chat_detect_offer.take() { + self.chat_llm = Some(cfg); + self.chat_consent = ChatConsent::Accepted; + self.chat_focused = true; + } + } + + /// Dismiss the detected-endpoint offer, leaving the prior chat config and + /// consent untouched. + pub fn dismiss_detect_offer(&mut self) { + self.chat_detect_offer = None; + } + + /// Accept the detected endpoint **and** persist it: same as + /// [`accept_detect_offer`](Self::accept_detect_offer), then raise the + /// one-shot `chat_persist_dispatch` edge so `event_loop` writes + /// `tui.chat_url`/`tui.chat_model` to the config file. No-op when no offer + /// is pending. + pub fn save_detect_offer(&mut self) { + let had_offer = self.chat_detect_offer.is_some(); + self.accept_detect_offer(); + if had_offer { + self.chat_persist_dispatch = true; + } + } + + /// Submit the current chat input. Empty / whitespace-only input is + /// ignored. Pushes the user turn, marks the request in-flight, and raises + /// the one-shot `chat_dispatch` edge so `event_loop` spawns the agent + /// round-trip. Stays I/O-free (the spawn happens outside the reducer). + pub fn submit_chat(&mut self) { + // Ignore submits while a request is in flight — prevents a double-Enter + // from spawning two racing agent tasks with desynced history. + if self.chat_sending { + return; + } + let text = self.chat_input.trim().to_string(); + if text.is_empty() { + return; + } + self.chat.push(ChatTurn::user(text)); + self.chat_input.clear(); + self.chat_sending = true; + self.chat_dispatch = true; + } + + /// Capture a read-only telemetry snapshot for the chat tools. Plain owned + /// clones — tools read this without touching the reducer or `&AppState`. + pub fn state_snapshot(&self) -> crate::agent::StateSnapshot { + crate::agent::StateSnapshot { + latest: self.latest.clone(), + instances: self.instances.values().cloned().collect(), + bench_rows: self.bench_rows.iter().cloned().collect(), + } + } + + /// Handle a successful agent reply: append an `Agent` turn, clear the + /// in-flight flag. Called from `event_loop` on `ClientMsg::ChatReply`. + pub fn on_chat_reply(&mut self, text: String) { + self.chat.push(ChatTurn::agent(text)); + self.chat_sending = false; + } + + /// Handle an agent failure: append an `Error` turn, clear the in-flight + /// flag. Called on `ClientMsg::ChatError` — never panics. + pub fn on_chat_error(&mut self, message: String) { + self.chat.push(ChatTurn::error(message)); + self.chat_sending = false; + } + + /// Apply the currently-highlighted picker entry and close the modal. + pub fn apply_theme_pick(&mut self) { + let names = crate::ui::theme::theme_names(); + if let Some(name) = names.get(self.theme_picker_sel) { + self.theme_name = (*name).to_string(); + self.theme = Theme::from_name(name); + } + self.modal = Modal::None; + } + + /// Number of selectable items for the current tab. Returns 0 when the + /// active tab has no selection model. + pub fn selection_len(&self) -> usize { + match self.active_tab { + ActiveTab::Instances => self.instances.len(), + ActiveTab::Bench => self.bench_rows.len(), + ActiveTab::Hardware => self.latest.as_ref().map(|s| s.gpus.len()).unwrap_or(0), + _ => 0, + } + } + + /// Move the selection cursor for the active tab. Clamped to [0, len-1]. + pub fn move_selection(&mut self, delta: isize) { + let len = self.selection_len(); + if len == 0 { + return; + } + let sel = self.selection_for(self.active_tab); + let next = (sel as isize + delta).clamp(0, len as isize - 1) as usize; + self.set_selection(self.active_tab, next); + } + + pub fn select_first(&mut self) { + self.set_selection(self.active_tab, 0); + } + + pub fn select_last(&mut self) { + let len = self.selection_len(); + if len > 0 { + self.set_selection(self.active_tab, len - 1); + } + } + + fn selection_for(&self, tab: ActiveTab) -> usize { + match tab { + ActiveTab::Instances => self.instance_sel, + ActiveTab::Bench => self.bench_sel, + ActiveTab::Hardware => self.gpu_sel, + _ => 0, + } + } + + fn set_selection(&mut self, tab: ActiveTab, idx: usize) { + match tab { + ActiveTab::Instances => self.instance_sel = idx, + ActiveTab::Bench => self.bench_sel = idx, + ActiveTab::Hardware => { + self.gpu_sel = idx; + self.sync_gpu_scroll(); + } + _ => {} + } + } + + /// Advance `gpu_scroll` so the selected GPU stays within the visible window. + /// Derives the visible row count from the last rendered body area; with no + /// prior draw it is a no-op (the renderer self-corrects on the next frame). + fn sync_gpu_scroll(&mut self) { + let n = self.latest.as_ref().map(|s| s.gpus.len()).unwrap_or(0); + let body_h = self.last_body_area.map(|r| r.height).unwrap_or(0); + let visible = crate::ui::tabs::hardware::gpu_visible_count(body_h, n); + self.gpu_scroll = + crate::ui::tabs::hardware::scroll_to_show(self.gpu_sel, self.gpu_scroll, visible); + } + + /// Clamp both selectors after a state update that may have shrunk the + /// underlying collection. Call after push_snapshot / instance changes. + fn clamp_selectors(&mut self) { + if !self.instances.is_empty() { + self.instance_sel = self.instance_sel.min(self.instances.len() - 1); + } else { + self.instance_sel = 0; + } + if !self.bench_rows.is_empty() { + self.bench_sel = self.bench_sel.min(self.bench_rows.len() - 1); + } else { + self.bench_sel = 0; + } + let gpu_count = self.latest.as_ref().map(|s| s.gpus.len()).unwrap_or(0); + if gpu_count > 0 { + self.gpu_sel = self.gpu_sel.min(gpu_count - 1); + } else { + self.gpu_sel = 0; + } + // Keep the scroll offset from running past the (possibly shrunk) list. + self.gpu_scroll = self.gpu_scroll.min(gpu_count.saturating_sub(1)); + } + + fn push_snapshot(&mut self, snap: Snapshot) { + // Snapshots carry the daemon's current instance set — treat them as truth. + self.instances.clear(); + for inst in &snap.instances { + self.instances + .insert(inst.container_id.clone(), inst.clone()); + } + if self.history.len() == HISTORY_CAP { + self.history.pop_front(); + } + self.history.push_back(snap.clone()); + self.latest = Some(snap); + self.clamp_selectors(); + } + + fn upsert_instance(&mut self, inst: Instance) { + self.instances.insert(inst.container_id.clone(), inst); + self.clamp_selectors(); + } + + fn remove_instance(&mut self, id: &str) { + self.instances.remove(id); + self.clamp_selectors(); + } + + fn push_bench_rows(&mut self, rows: Vec) { + for r in rows { + if self.bench_rows.len() == BENCH_CAP { + self.bench_rows.pop_front(); + } + self.bench_rows.push_back(r); + } + self.clamp_selectors(); + } + + /// Wipe state derived from past events so a backward replay seek can + /// repopulate from scratch. Preserves UI scaffolding (theme, tabs, + /// selectors, modal) so the user's frame of reference doesn't jump. + pub fn reset_for_seek(&mut self) { + self.latest = None; + self.history.clear(); + self.instances.clear(); + self.bench_rows.clear(); + self.clamp_selectors(); + } + + /// Apply a wire `Event` to local state. Single source of truth for the + /// event-to-state transition — used by both the live event loop and by + /// out-of-process drivers (replay, screenshot generation, future test + /// harnesses). + pub fn apply_event(&mut self, event: Event) { + match event { + Event::Snapshot(snap) => self.push_snapshot(snap), + Event::BenchmarkRowsAppended { rows } => self.push_bench_rows(rows), + Event::InstanceDiscovered(inst) => self.upsert_instance(inst), + Event::InstanceGone { container_id } => self.remove_instance(&container_id), + // Welcome / Warning / Error / Bye don't mutate AppState directly. + _ => {} + } + } +} + +pub async fn run(args: ResolvedArgs) -> color_eyre::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let res = event_loop(&mut terminal, &args).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + res +} + +async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + // Job-bridge channel (Phase 3 Wave 1): the async runtime streams + // `StateEvent`s (JobLine/JobDone/JobErr) for operational screens here. + let (job_tx, mut job_rx) = mpsc::unbounded_channel::(); + // Retain a sender for chat replies BEFORE `tx` is moved into the client / + // replay task below — the spawned agent task feeds replies back through the + // same `rx.recv()` arm the daemon events already use (no new plumbing). + let chat_tx = tx.clone(); + let replay_controller = match args.replay.clone() { + Some(path) => Some(crate::replay::spawn(path, tx)), + None => { + client::spawn(args.connect.clone(), tx); + None + } + }; + + let mut events = EventStream::new(); + let mut tick = interval(Duration::from_millis(250)); + let connect_label = match &args.replay { + Some(p) => format!( + "replay:{}", + p.file_name().and_then(|n| n.to_str()).unwrap_or("?") + ), + None => args.connect.clone(), + }; + let mut state = AppState::new(connect_label, args.theme.clone()); + // Honor the chat-first vs dashboard launch choice (rocm-cli semantics). + state.active_tab = args.initial_tab; + // Serve-wizard recipe picker source (Phase 3 Wave 1), adapted by the bin. + state.model_recipes = args.model_recipes.clone(); + // Runtime manager source (Phase 3 Wave 2), adapted by the bin. + state.runtimes = args.runtimes.clone(); + // Automations manager source (Phase 3 Wave 3), adapted by the bin. + state.automations = args.automations.clone(); + state.replay = replay_controller.map(ReplayState::new); + + // Resolve the chat backend. `--chat-mock` short-circuits detection with a + // deterministic offline MockAgentClient (no live LLM, no network); otherwise + // we auto-detect the endpoint (the std-TCP probe runs once on a blocking + // thread before the first frame) and build the Rig backend. + let agent: Option> = if args.chat_mock { + state.set_chat_config( + Some(crate::llm::LlmConfig { + base_url: "mock://offline-demo".to_string(), + model: "mock-agent".to_string(), + api_key: None, + auth_header: None, + }), + true, + ); + Some( + std::sync::Arc::new(crate::agent::MockAgentClient::with_tool_call( + "GPU-2 is running hot: 87% util, 71°C, drawing 250 W (90 GB/192 GB VRAM).", + "gpu_status", + )) as std::sync::Arc, + ) + } else { + let probe_target = args + .chat_url + .clone() + .or_else(|| args.chat_env_url.clone()) + .unwrap_or_else(|| crate::llm::DEFAULT_CHAT_BASE_URL.to_string()); + let probe_ok = tokio::task::spawn_blocking(move || { + crate::llm::probe_endpoint(&probe_target, crate::llm::PROBE_TIMEOUT) + }) + .await + .unwrap_or(false); + let llm = crate::llm::resolve_llm_config( + args.chat_url.as_deref(), + args.chat_model.as_deref(), + None, + None, + args.chat_api_key.as_deref(), + args.chat_env_url.as_deref(), + args.chat_auth_header.as_deref(), + probe_ok, + ); + state.set_chat_config(llm, args.chat_auto_consent); + // No reachable local endpoint AND no key/url configured → the no-key + // ChatGPT OAuth default (device-code login surfaced in the chat tab). + // This restores the no-key login the vendored Codex path provided; it + // takes NO api_key (env-only invariant untouched — OAuth, not a key). + let no_key_no_endpoint = !probe_ok + && args.chat_api_key.is_none() + && args.chat_url.is_none() + && args.chat_env_url.is_none(); + if no_key_no_endpoint { + let oauth_tx = chat_tx.clone(); + crate::agent::ChatGptAgentClient::new(args.chat_model.clone(), move |url, code| { + let _ = oauth_tx.send(ClientMsg::ChatReply { + text: format!( + "To enable chat, sign in to ChatGPT: open {url} and enter the code {code}" + ), + }); + }) + .ok() + .map(|c| std::sync::Arc::new(c) as std::sync::Arc) + } else { + // A build failure leaves `agent` None; a submit surfaces an error turn. + match &state.chat_llm { + Some(cfg) => crate::agent::RigAgentClient::new(cfg.clone()) + .ok() + .map(|c| { + std::sync::Arc::new(c) as std::sync::Arc + }), + None => None, + } + } + }; + + loop { + terminal.draw(|f| ui::draw(f, &mut state))?; + tokio::select! { + _ = tick.tick() => { /* repaint */ } + maybe_msg = rx.recv() => { + match maybe_msg { + Some(ClientMsg::Connecting) => state.conn = ConnState::Connecting, + Some(ClientMsg::Connected { host, daemon_version }) => { + state.conn = ConnState::Connected { host, version: daemon_version }; + } + Some(ClientMsg::Disconnected { reason }) => { + state.conn = ConnState::Disconnected { reason }; + state.latest = None; + } + Some(ClientMsg::Event(ev)) => state.apply_event(*ev), + Some(ClientMsg::ReplaySeek) => state.reset_for_seek(), + Some(ClientMsg::ReplayPosition { elapsed_s, total_s }) => { + if let Some(r) = state.replay.as_mut() { + r.elapsed_s = elapsed_s; + r.total_s = total_s; + } + } + Some(ClientMsg::ChatReply { text }) => state.on_chat_reply(text), + Some(ClientMsg::ChatError { message }) => state.on_chat_error(message), + Some(ClientMsg::ChatDetectResult { offer }) => state.set_detect_result(offer), + None => break, + } + } + // Job-bridge events feed the operational-screen job model (Wave 1). + maybe_job = job_rx.recv() => { + if let Some(ev) = maybe_job { + let fx = state.jobs.apply(ev); + crate::jobs::run_effects(fx, &job_tx); + } + } + maybe_ev = events.next() => { + match maybe_ev { + // The services-manager overlay, when open, owns all keys + // (and may spawn lifecycle jobs through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.services.is_some() => { + let fx = crate::ui::services_manager::on_key( + &mut state.services, + &mut state.jobs, + &state.instances, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The serve-wizard overlay, when open, owns all keys (and may + // spawn a launch job through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.serve_wizard.is_some() => { + let fx = crate::ui::serve_wizard::on_key( + &mut state.serve_wizard, + &mut state.jobs, + &state.model_recipes, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The engine-manager overlay, when open, owns all keys (and + // may stream an install job through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.engine_manager.is_some() => { + let fx = crate::ui::engine_manager::on_key( + &mut state.engine_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The doctor overlay, when open, owns all keys (read-only + // `rocm doctor` job through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.doctor_manager.is_some() => { + let fx = crate::ui::doctor_manager::on_key( + &mut state.doctor_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The update overlay, when open, owns all keys (check/preview + // read-only; apply gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.update_manager.is_some() => { + let fx = crate::ui::update_manager::on_key( + &mut state.update_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The install overlay, when open, owns all keys (dry-run + // read-only; install gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.install_manager.is_some() => { + let fx = crate::ui::install_manager::on_key( + &mut state.install_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The logs overlay, when open, owns all keys (read-only + // `rocm logs` through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.logs_view.is_some() => { + let fx = crate::ui::logs_view::on_key( + &mut state.logs_view, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The runtime manager, when open, owns all keys (refresh + // read-only; activate/rollback/uninstall/adopt/import gated). + Some(Ok(CtEvent::Key(k))) if state.runtime_manager.is_some() => { + let fx = crate::ui::runtime_manager::on_key( + &mut state.runtime_manager, + &state.runtimes, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The onboarding wizard, when open, owns all keys (install / + // adopt gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.onboarding.is_some() => { + let fx = crate::ui::onboarding::on_key( + &mut state.onboarding, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The automations manager, when open, owns all keys (refresh + // read-only; enable/disable gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.automations_manager.is_some() => { + let fx = crate::ui::automations_manager::on_key( + &mut state.automations_manager, + &state.automations, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The command runner, when open, owns all keys (every + // command gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.command_screen.is_some() => { + let fx = crate::ui::command_screen::on_key( + &mut state.command_screen, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The config & provider manager, when open, owns all keys + // (show read-only; provider toggles gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.config_manager.is_some() => { + let fx = crate::ui::config_manager::on_key( + &mut state.config_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + Some(Ok(CtEvent::Key(k))) => { + let chat_ctx = ChatKeyCtx { + focused: state.chat_focused, + consent: state.chat_consent, + offer_pending: state.chat_detect_offer.is_some(), + }; + let action = handle_key(k, state.active_tab, &state.modal, chat_ctx); + if apply_action(&mut state, action) { + break; + } + } + Some(Ok(CtEvent::Mouse(me))) => { + let action = resolve_mouse(me, &state); + if apply_action(&mut state, action) { + break; + } + } + Some(Ok(CtEvent::Resize(_, _))) => { /* repaint */ } + Some(Err(e)) => return Err(e.into()), + None => break, + _ => {} + } + } + } + + // Spawn the agent round-trip on the submit edge — keeps `apply_action` + // I/O-free. `chat_dispatch` is raised once by `submit_chat`; consume it + // so the in-flight request is spawned exactly once (not every tick). + if state.chat_dispatch { + state.chat_dispatch = false; + match agent.clone() { + Some(agent) => { + let history = state.chat.clone(); + let snapshot = state.state_snapshot(); + let reply_tx = chat_tx.clone(); + tokio::spawn(async move { + let msg = match agent.complete(&history, snapshot).await { + Ok(text) => ClientMsg::ChatReply { text }, + Err(e) => ClientMsg::ChatError { + message: e.to_string(), + }, + }; + let _ = reply_tx.send(msg); + }); + } + None => state.on_chat_error("chat backend unavailable".to_string()), + } + } + + // Run the local-engine probe + `/v1/models` query on the detect edge, + // off the reducer. Raised once by `request_detect`; result returns via + // `ClientMsg::ChatDetectResult`. + if state.chat_detect_dispatch { + state.chat_detect_dispatch = false; + let reply_tx = chat_tx.clone(); + tokio::spawn(async move { + let offer = detect_local_chat().await; + let _ = reply_tx.send(ClientMsg::ChatDetectResult { offer }); + }); + } + + // Persist the accepted endpoint on the save edge (a small synchronous + // file write; the message surfaces success/failure on the gate is not + // shown once Accepted, so we keep it terse via tracing + chat_detect_msg). + if state.chat_persist_dispatch { + state.chat_persist_dispatch = false; + if let Some(cfg) = state.chat_llm.clone() { + match persist_chat_endpoint(&cfg.base_url, &cfg.model) { + Ok(path) => { + tracing::info!(?path, "saved chat endpoint to config"); + } + Err(e) => { + tracing::warn!(error = %e, "failed to save chat endpoint"); + state.chat_detect_msg = Some(format!("could not save config: {e}")); + } + } + } + } + } + Ok(()) +} + +/// Probe for a local engine (TCP), then query its `/v1/models` to choose a +/// model. Returns a ready-to-use [`LlmConfig`], or `None` when nothing is +/// reachable. All I/O lives here, outside the reducer. +async fn detect_local_chat() -> Option { + // TCP probe is blocking; keep it off the async reactor. + let base = tokio::task::spawn_blocking(crate::llm::detect_local_endpoint) + .await + .ok() + .flatten()?; + + // Best-effort model query; fall back to the neutral default on any failure. + let model = fetch_first_model(base) + .await + .unwrap_or_else(|| crate::llm::DEFAULT_CHAT_MODEL.to_string()); + Some(crate::llm::detected_llm_config(base, &model)) +} + +/// Persist an accepted local endpoint to the user's `config.toml`: load the +/// existing config (or defaults), set `tui.chat_url`/`tui.chat_model`, and write +/// it back. Best-effort — returns a human error string on failure. +/// +/// Uses [`default_config_path`] (a `--config` override is not honored by this +/// in-TUI save; that's a documented limitation). All I/O lives here. +fn persist_chat_endpoint(base_url: &str, model: &str) -> Result { + use rocm_dash_core::config::{Config, default_config_path}; + let path = default_config_path().ok_or_else(|| "no config path available".to_string())?; + let cfg = Config::load(&path).unwrap_or_default(); + let next = config_with_chat(cfg, base_url, model); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let toml = toml::to_string_pretty(&next).map_err(|e| e.to_string())?; + std::fs::write(&path, toml).map_err(|e| e.to_string())?; + Ok(path) +} + +/// Pure immutable transform: return a copy of `cfg` with the chat endpoint set +/// to a local engine (base_url + model), clearing any gateway auth header since +/// local engines need none. +fn config_with_chat( + mut cfg: rocm_dash_core::config::Config, + base_url: &str, + model: &str, +) -> rocm_dash_core::config::Config { + cfg.tui.chat_url = Some(base_url.to_string()); + cfg.tui.chat_model = Some(model.to_string()); + cfg.tui.chat_auth_header = None; + cfg +} + +/// GET `{base}/models` and return the first served model id, or `None`. +async fn fetch_first_model(base_url: &str) -> Option { + let url = format!("{}/models", base_url.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(3)) + .build() + .ok()?; + let resp = client.get(&url).send().await.ok()?; + let json: serde_json::Value = resp.json().await.ok()?; + crate::llm::pick_first_model(&json) +} + +/// Apply a `KeyAction` to mutable state. Returns `true` when the action +/// requests application exit (Quit). +fn apply_action(state: &mut AppState, action: KeyAction) -> bool { + match action { + KeyAction::Quit => return true, + KeyAction::SwitchTab(t) => { + state.active_tab = t; + state.modal = Modal::None; + } + KeyAction::Move(d) => { + if state.modal == Modal::ThemePicker { + state.theme_picker_move(d); + } else { + state.move_selection(d); + } + } + KeyAction::SelectFirst => { + if state.modal == Modal::ThemePicker { + state.theme_picker_first(); + } else { + state.select_first(); + } + } + KeyAction::SelectLast => { + if state.modal == Modal::ThemePicker { + state.theme_picker_last(); + } else { + state.select_last(); + } + } + KeyAction::OpenDetail => { + if state.selection_len() > 0 { + state.modal = Modal::Detail; + if state.active_tab == ActiveTab::Bench { + state.reset_bench_detail_scroll(); + } + } + } + KeyAction::ToggleHelp => { + state.modal = if state.modal == Modal::Help { + Modal::None + } else { + Modal::Help + }; + } + KeyAction::CloseModal => state.modal = Modal::None, + // The operational overlays are mutually exclusive: opening any one first + // closes the rest (see `close_overlays`), so no open path — key, mouse, + // or effect — can ever leave two `Some` at once. + KeyAction::OpenServices => { + state.close_overlays(); + state.services = Some(crate::ui::services_manager::ServicesManagerState::default()); + } + KeyAction::OpenServeWizard => { + state.close_overlays(); + state.serve_wizard = Some(crate::ui::serve_wizard::ServeWizardState::default()); + } + KeyAction::OpenEngineManager => { + state.close_overlays(); + state.engine_manager = Some(crate::ui::engine_manager::EngineManagerState::default()); + } + KeyAction::OpenDoctor => { + state.close_overlays(); + state.doctor_manager = Some(crate::ui::doctor_manager::DoctorManagerState::default()); + } + KeyAction::OpenUpdate => { + state.close_overlays(); + state.update_manager = Some(crate::ui::update_manager::UpdateManagerState::default()); + } + KeyAction::OpenInstall => { + state.close_overlays(); + state.install_manager = + Some(crate::ui::install_manager::InstallManagerState::default()); + } + KeyAction::OpenLogs => { + state.close_overlays(); + state.logs_view = Some(crate::ui::logs_view::LogsViewState::default()); + } + KeyAction::OpenRuntimes => { + state.close_overlays(); + state.runtime_manager = + Some(crate::ui::runtime_manager::RuntimeManagerState::default()); + } + KeyAction::OpenOnboarding => { + state.close_overlays(); + state.onboarding = Some(crate::ui::onboarding::OnboardingState::default()); + } + KeyAction::OpenAutomations => { + state.close_overlays(); + state.automations_manager = + Some(crate::ui::automations_manager::AutomationsManagerState::default()); + } + KeyAction::OpenCommand => { + state.close_overlays(); + state.command_screen = Some(crate::ui::command_screen::CommandScreenState::default()); + } + KeyAction::OpenConfig => { + state.close_overlays(); + state.config_manager = Some(crate::ui::config_manager::ConfigManagerState::default()); + } + KeyAction::OpenThemePicker => state.open_theme_picker(), + KeyAction::ApplyThemePick => state.apply_theme_pick(), + KeyAction::ScrollModal(d) => { + if state.active_tab == ActiveTab::Bench && state.modal == Modal::Detail { + if d == i16::MIN { + state.bench_detail_scroll = 0; + } else if d == i16::MAX { + state.bench_detail_scroll = u16::MAX; + } else { + state.scroll_bench_detail(d); + } + } + } + KeyAction::ReplayTogglePause => { + if let Some(r) = state.replay.as_mut() { + r.paused = !r.paused; + if r.paused { + r.controller.pause(); + } else { + r.controller.resume(); + } + } + } + KeyAction::ReplaySpeedUp => { + if let Some(r) = state.replay.as_mut() { + r.speed = crate::replay::next_speed(r.speed); + r.controller.set_speed(r.speed); + } + } + KeyAction::ReplaySpeedDown => { + if let Some(r) = state.replay.as_mut() { + r.speed = crate::replay::prev_speed(r.speed); + r.controller.set_speed(r.speed); + } + } + KeyAction::ReplayJump(delta_s) => { + if let Some(r) = state.replay.as_ref() { + r.controller.jump(delta_s); + } + } + KeyAction::ChatInput(c) => state.chat_input.push(c), + KeyAction::ChatBackspace => { + state.chat_input.pop(); + } + KeyAction::ChatSubmit => state.submit_chat(), + KeyAction::ChatFocus => state.chat_focused = true, + KeyAction::ChatBlur => state.chat_focused = false, + KeyAction::ChatConsentAccept => state.accept_chat_consent(), + KeyAction::ChatConsentDecline => state.decline_chat_consent(), + KeyAction::ChatDetect => state.request_detect(), + KeyAction::ChatDetectAccept => state.accept_detect_offer(), + KeyAction::ChatDetectSave => state.save_detect_offer(), + KeyAction::ChatDetectDismiss => state.dismiss_detect_offer(), + KeyAction::ChatScroll(d) => { + let next = (state.chat_scroll as i32 + d as i32).max(0) as u16; + state.chat_scroll = next; + } + KeyAction::Nothing => {} + } + false +} + +/// Translate a mouse event into a `KeyAction` using whatever state context +/// is needed (last drawn areas, active tab, current modal). +fn resolve_mouse(me: MouseEvent, state: &AppState) -> KeyAction { + if let MouseEventKind::Down(MouseButton::Left) = me.kind { + if let Some(area) = state.last_tab_bar_area + && let Some(tab) = tab_bar_hit(area, me.column, me.row) + { + return KeyAction::SwitchTab(tab); + } + if state.modal == Modal::None + && let Some(area) = state.last_body_area + { + let action = match state.active_tab { + ActiveTab::Instances => { + ui::tabs::instances::hit_test(area, me.column, me.row, state) + } + ActiveTab::Bench => ui::tabs::bench::hit_test(area, me.column, me.row, state), + _ => None, + }; + if let Some(a) = action { + return a; + } + } + return KeyAction::Nothing; + } + handle_mouse(me, &state.modal, state.active_tab) +} + +#[derive(Debug, PartialEq, Eq)] +pub enum KeyAction { + Nothing, + Quit, + SwitchTab(ActiveTab), + Move(isize), + SelectFirst, + SelectLast, + OpenDetail, + ToggleHelp, + CloseModal, + OpenThemePicker, + ApplyThemePick, + /// Vertical scroll inside the active modal body (positive = down). + ScrollModal(i16), + /// Toggle replay pause / resume. No-op when not replaying. + ReplayTogglePause, + /// Step replay speed up or down (clamped). No-op when not replaying. + ReplaySpeedUp, + ReplaySpeedDown, + /// Move the replay playhead by `delta_s` seconds (negative = rewind). + ReplayJump(i64), + /// Chat insert-mode: append a character to `chat_input`. + ChatInput(char), + /// Chat insert-mode: pop the last character from `chat_input`. + ChatBackspace, + /// Chat: submit the current input buffer as a user turn. + ChatSubmit, + /// Chat: enter text-entry focus. + ChatFocus, + /// Chat: leave text-entry focus. + ChatBlur, + /// Chat: accept the detected endpoint (one-time consent). + ChatConsentAccept, + /// Chat: decline the detected endpoint. + ChatConsentDecline, + /// Chat: probe for a local engine and offer it (in-TUI auto-detect). + ChatDetect, + /// Chat: accept the detected local endpoint for this session. + ChatDetectAccept, + /// Chat: accept the detected endpoint and persist it to config. + ChatDetectSave, + /// Chat: dismiss the detected-endpoint offer, keeping the prior config. + ChatDetectDismiss, + /// Chat: scroll the transcript by N lines (positive = down). + ChatScroll(i16), + /// Open the services-manager overlay (Phase 3 Wave 1). + OpenServices, + /// Open the serve-wizard overlay (Phase 3 Wave 1). + OpenServeWizard, + /// Open the engine-manager overlay (Phase 3 Wave 1). + OpenEngineManager, + /// Open the doctor overlay (Phase 3 Wave 2). + OpenDoctor, + /// Open the update overlay (Phase 3 Wave 2). + OpenUpdate, + /// Open the install overlay (Phase 3 Wave 2). + OpenInstall, + /// Open the runtime manager overlay. + OpenRuntimes, + /// Open the onboarding wizard overlay. + OpenOnboarding, + /// Open the automations manager overlay. + OpenAutomations, + /// Open the command runner overlay. + OpenCommand, + /// Open the config & provider manager overlay. + OpenConfig, + /// Open the logs overlay (Phase 3 Wave 3). + OpenLogs, +} + +fn handle_key(k: KeyEvent, current: ActiveTab, modal: &Modal, chat: ChatKeyCtx) -> KeyAction { + if k.kind != KeyEventKind::Press { + return KeyAction::Nothing; + } + // Chat tab key handling, placed BEFORE the global hotkey match so focused + // text entry and the consent prompt absorb keys (the short-circuit that + // stops `q`, `1`–`5`, etc. from firing while typing / deciding consent). + if current == ActiveTab::Chat && *modal == Modal::None { + // A detected-endpoint offer (gate-only) absorbs its decision keys before + // the normal consent prompt: y use now, n/Esc dismiss. ([s] use & save + // is wired with persistence.) Other keys fall through to the globals. + if chat.offer_pending && chat.consent != ChatConsent::Accepted { + match k.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + return KeyAction::ChatDetectAccept; + } + KeyCode::Char('s') | KeyCode::Char('S') => { + return KeyAction::ChatDetectSave; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + return KeyAction::ChatDetectDismiss; + } + _ => {} + } + } + match chat.consent { + ChatConsent::Accepted => { + // History scroll works whether or not the input is focused. + match k.code { + KeyCode::PageUp => return KeyAction::ChatScroll(-CHAT_SCROLL_STEP), + KeyCode::PageDown => return KeyAction::ChatScroll(CHAT_SCROLL_STEP), + _ => {} + } + if chat.focused { + return match k.code { + KeyCode::Esc => KeyAction::ChatBlur, + KeyCode::Enter => KeyAction::ChatSubmit, + KeyCode::Backspace => KeyAction::ChatBackspace, + KeyCode::Char(c) => KeyAction::ChatInput(c), + _ => KeyAction::Nothing, + }; + } + // Not focused: `i`/`Enter` enter insert mode; other keys fall + // through to the global hotkeys below. + if let KeyCode::Char('i') | KeyCode::Enter = k.code { + return KeyAction::ChatFocus; + } + } + ChatConsent::Pending | ChatConsent::Declined => { + // Consent gate: y/Enter accept, n decline, d detect a local + // engine. Other keys (q, digits, Tab, ?) fall through to the + // globals so the user isn't trapped. + match k.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + return KeyAction::ChatConsentAccept; + } + KeyCode::Char('n') | KeyCode::Char('N') => { + return KeyAction::ChatConsentDecline; + } + KeyCode::Char('d') | KeyCode::Char('D') => { + return KeyAction::ChatDetect; + } + _ => {} + } + } + // No endpoint configured: the only gate action is to detect one. + ChatConsent::Unavailable => { + if let KeyCode::Char('d') | KeyCode::Char('D') = k.code { + return KeyAction::ChatDetect; + } + } + } + } + // ThemePicker is a navigable modal — j/k/g/G move the cursor, Enter applies. + if *modal == Modal::ThemePicker { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Char('t') => KeyAction::CloseModal, + KeyCode::Enter => KeyAction::ApplyThemePick, + KeyCode::Char('j') | KeyCode::Down => KeyAction::Move(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::Move(-1), + KeyCode::Char('g') | KeyCode::Home => KeyAction::SelectFirst, + KeyCode::Char('G') | KeyCode::End => KeyAction::SelectLast, + _ => KeyAction::Nothing, + }; + } + // Detail modal: vertical scroll keys, plus quit/close. + if *modal == Modal::Detail { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, + KeyCode::Char('j') | KeyCode::Down => KeyAction::ScrollModal(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::ScrollModal(-1), + KeyCode::PageDown => KeyAction::ScrollModal(10), + KeyCode::PageUp => KeyAction::ScrollModal(-10), + KeyCode::Char('g') | KeyCode::Home => KeyAction::ScrollModal(i16::MIN), + KeyCode::Char('G') | KeyCode::End => KeyAction::ScrollModal(i16::MAX), + _ => KeyAction::Nothing, + }; + } + // Help absorbs everything except quit / close / ? toggle. + if *modal == Modal::Help { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, + _ => KeyAction::Nothing, + }; + } + match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc => KeyAction::Nothing, // Esc no longer quits; it closes modals. + KeyCode::Char('?') => KeyAction::ToggleHelp, + KeyCode::Char('t') => KeyAction::OpenThemePicker, + KeyCode::BackTab => KeyAction::SwitchTab(current.prev()), + KeyCode::Tab => { + if k.modifiers.contains(KeyModifiers::SHIFT) { + KeyAction::SwitchTab(current.prev()) + } else { + KeyAction::SwitchTab(current.next()) + } + } + KeyCode::Char(c @ '1'..='5') => match ActiveTab::from_digit(c) { + Some(t) => KeyAction::SwitchTab(t), + None => KeyAction::Nothing, + }, + KeyCode::PageDown => KeyAction::Move(10), + KeyCode::PageUp => KeyAction::Move(-10), + KeyCode::Char(' ') => KeyAction::ReplayTogglePause, + KeyCode::Char('+') | KeyCode::Char('=') => KeyAction::ReplaySpeedUp, + KeyCode::Char('-') | KeyCode::Char('_') => KeyAction::ReplaySpeedDown, + KeyCode::Char('[') => KeyAction::ReplayJump(-10), + KeyCode::Char(']') => KeyAction::ReplayJump(10), + KeyCode::Char('{') => KeyAction::ReplayJump(-60), + KeyCode::Char('}') => KeyAction::ReplayJump(60), + KeyCode::Char('j') | KeyCode::Down => KeyAction::Move(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::Move(-1), + KeyCode::Char('g') | KeyCode::Home => KeyAction::SelectFirst, + KeyCode::Char('G') | KeyCode::End => KeyAction::SelectLast, + // Services manager: open from the Instances tab (where servers live). + KeyCode::Char('s') if current == ActiveTab::Instances => KeyAction::OpenServices, + // Serve wizard: launch a model from the Overview or Instances tab. + KeyCode::Char('w') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenServeWizard + } + // Engine manager: use/install/reinstall serving engines. + KeyCode::Char('e') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenEngineManager + } + // Doctor: read-only environment check. + KeyCode::Char('d') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenDoctor + } + // Update: check/preview/apply ROCm package updates. + KeyCode::Char('u') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenUpdate + } + // Install: ROCm SDK (TheRock) install / dry-run. + KeyCode::Char('i') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenInstall + } + // Logs: browse recent ROCm CLI logs. + KeyCode::Char('l') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenLogs + } + // Runtimes: list/activate/adopt/import ROCm runtimes. + KeyCode::Char('r') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenRuntimes + } + // Onboarding: first-run setup wizard (install / adopt). + KeyCode::Char('n') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenOnboarding + } + // Automations: list/enable/disable background checks. + KeyCode::Char('a') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenAutomations + } + // Command runner: run any ROCm CLI subcommand (gated). + KeyCode::Char('c') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenCommand + } + // Config & providers. + KeyCode::Char('p') if matches!(current, ActiveTab::Overview | ActiveTab::Instances) => { + KeyAction::OpenConfig + } + KeyCode::Enter => KeyAction::OpenDetail, + _ => KeyAction::Nothing, + } +} + +/// Translate a `MouseEvent` into the existing `KeyAction` vocabulary. +/// +/// The caller is responsible for the surrounding state context: +/// - `last_tab_bar_area` / `last_body_area` are read off `AppState` by the +/// event loop so this function stays pure on the input event. +/// - Per-tab body clicks are dispatched to the active tab module's +/// `hit_test` from the event loop. +/// +/// We only translate the parts of mouse handling that are tab-agnostic: +/// the scroll wheel, and (in the event loop) the tab-bar click. Per-tab +/// click is handled in tab modules. +pub fn handle_mouse(ev: MouseEvent, modal: &Modal, tab: ActiveTab) -> KeyAction { + let delta: i16 = match ev.kind { + MouseEventKind::ScrollDown => 3, + MouseEventKind::ScrollUp => -3, + _ => return KeyAction::Nothing, + }; + if *modal == Modal::Detail { + KeyAction::ScrollModal(delta) + } else if *modal == Modal::ThemePicker + || (*modal == Modal::None && matches!(tab, ActiveTab::Instances | ActiveTab::Bench)) + { + KeyAction::Move(delta as isize) + } else { + KeyAction::Nothing + } +} + +/// Resolve a left-click at `(x, y)` against `tab_bar_area`. Returns the tab +/// to switch to, or `None` if the click is outside or doesn't land on a chip. +/// +/// Uses [`ui::tabs::compute_chip_layout`] so the hit-test geometry exactly +/// mirrors what `draw_tab_bar` rendered. Separator gaps (` · `) between +/// chips are intentional dead zones — clicking the dot does nothing. +pub fn tab_bar_hit(tab_bar_area: ratatui::layout::Rect, x: u16, y: u16) -> Option { + if y != tab_bar_area.y { + return None; + } + let chips = ui::tabs::compute_chip_layout(tab_bar_area.x); + let bar_right = tab_bar_area.x.saturating_add(tab_bar_area.width); + for chip in chips { + if chip.x_end > bar_right { + // Chip overflows the bar — terminal too narrow to show it; skip. + continue; + } + if x >= chip.x_start && x < chip.x_end { + return Some(chip.tab); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + + fn press(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn hk(c: KeyCode, tab: ActiveTab) -> KeyAction { + handle_key(press(c), tab, &Modal::None, ChatKeyCtx::default()) + } + + #[test] + fn q_quits_esc_does_not() { + assert_eq!(hk(KeyCode::Char('q'), ActiveTab::Overview), KeyAction::Quit); + assert_eq!(hk(KeyCode::Esc, ActiveTab::Bench), KeyAction::Nothing); + } + + #[test] + fn tab_cycles_forward_and_wraps() { + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Overview), + KeyAction::SwitchTab(ActiveTab::Hardware) + ); + // Bench now precedes Chat; Chat wraps back to Overview. + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Bench), + KeyAction::SwitchTab(ActiveTab::Chat) + ); + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Chat), + KeyAction::SwitchTab(ActiveTab::Overview) + ); + } + + #[test] + fn back_tab_and_shift_tab_both_cycle_backward() { + assert_eq!( + hk(KeyCode::BackTab, ActiveTab::Hardware), + KeyAction::SwitchTab(ActiveTab::Overview) + ); + let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + // Overview's previous tab is now Chat (the new last tab). + assert_eq!( + handle_key( + shift_tab, + ActiveTab::Overview, + &Modal::None, + ChatKeyCtx::default() + ), + KeyAction::SwitchTab(ActiveTab::Chat) + ); + } + + #[test] + fn number_keys_jump_to_tab() { + assert_eq!( + hk(KeyCode::Char('3'), ActiveTab::Overview), + KeyAction::SwitchTab(ActiveTab::Instances) + ); + // `5` now reaches the Chat tab (digit guard widened to '1'..='5'). + assert_eq!( + hk(KeyCode::Char('5'), ActiveTab::Overview), + KeyAction::SwitchTab(ActiveTab::Chat) + ); + assert_eq!( + hk(KeyCode::Char('6'), ActiveTab::Overview), + KeyAction::Nothing + ); + } + + #[test] + fn from_digit_maps_five_to_chat() { + assert_eq!(ActiveTab::from_digit('5'), Some(ActiveTab::Chat)); + assert_eq!(ActiveTab::from_digit('6'), None); + } + + #[test] + fn release_events_are_ignored() { + let release = KeyEvent::new_with_kind( + KeyCode::Char('q'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + assert_eq!( + handle_key( + release, + ActiveTab::Overview, + &Modal::None, + ChatKeyCtx::default() + ), + KeyAction::Nothing + ); + } + + #[test] + fn jk_arrows_and_g_drive_selection() { + assert_eq!( + hk(KeyCode::Char('j'), ActiveTab::Instances), + KeyAction::Move(1) + ); + assert_eq!( + hk(KeyCode::Char('k'), ActiveTab::Instances), + KeyAction::Move(-1) + ); + assert_eq!(hk(KeyCode::Down, ActiveTab::Bench), KeyAction::Move(1)); + assert_eq!(hk(KeyCode::Up, ActiveTab::Bench), KeyAction::Move(-1)); + assert_eq!( + hk(KeyCode::Char('g'), ActiveTab::Bench), + KeyAction::SelectFirst + ); + assert_eq!( + hk(KeyCode::Char('G'), ActiveTab::Bench), + KeyAction::SelectLast + ); + assert_eq!(hk(KeyCode::Enter, ActiveTab::Bench), KeyAction::OpenDetail); + } + + #[test] + fn operational_open_keys_are_tab_scoped() { + // `s` opens services only on Instances; Nothing elsewhere. + assert_eq!( + hk(KeyCode::Char('s'), ActiveTab::Instances), + KeyAction::OpenServices + ); + assert_eq!( + hk(KeyCode::Char('s'), ActiveTab::Overview), + KeyAction::Nothing + ); + // `w` / `e` open from Overview + Instances; Nothing on other tabs. + assert_eq!( + hk(KeyCode::Char('w'), ActiveTab::Overview), + KeyAction::OpenServeWizard + ); + assert_eq!( + hk(KeyCode::Char('e'), ActiveTab::Instances), + KeyAction::OpenEngineManager + ); + assert_eq!( + hk(KeyCode::Char('w'), ActiveTab::Hardware), + KeyAction::Nothing + ); + assert_eq!(hk(KeyCode::Char('e'), ActiveTab::Bench), KeyAction::Nothing); + // Doctor / update open from Overview + Instances. + assert_eq!( + hk(KeyCode::Char('d'), ActiveTab::Overview), + KeyAction::OpenDoctor + ); + assert_eq!( + hk(KeyCode::Char('u'), ActiveTab::Instances), + KeyAction::OpenUpdate + ); + assert_eq!(hk(KeyCode::Char('d'), ActiveTab::Bench), KeyAction::Nothing); + // Install / logs open from Overview + Instances. + assert_eq!( + hk(KeyCode::Char('i'), ActiveTab::Overview), + KeyAction::OpenInstall + ); + assert_eq!( + hk(KeyCode::Char('l'), ActiveTab::Instances), + KeyAction::OpenLogs + ); + // On the Chat tab none of these open an overlay (the operational keys + // are guarded to Overview/Instances). `i` is the one that means + // something else on Chat — insert mode — never OpenInstall. + for c in ['w', 'e', 'd', 'u', 'l'] { + assert_eq!( + hk(KeyCode::Char(c), ActiveTab::Chat), + KeyAction::Nothing, + "key {c} must not open an overlay from Chat" + ); + } + assert_eq!( + hk(KeyCode::Char('i'), ActiveTab::Chat), + KeyAction::ChatFocus, + "i is chat-insert on Chat, never OpenInstall" + ); + } + + #[test] + fn opening_an_overlay_closes_the_others() { + let mut s = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s, KeyAction::OpenServices); + assert!(s.services.is_some() && s.serve_wizard.is_none() && s.engine_manager.is_none()); + // Opening another overlay (defensive path) clears the prior one. + apply_action(&mut s, KeyAction::OpenServeWizard); + assert!(s.serve_wizard.is_some() && s.services.is_none() && s.engine_manager.is_none()); + apply_action(&mut s, KeyAction::OpenEngineManager); + assert!(s.engine_manager.is_some() && s.services.is_none() && s.serve_wizard.is_none()); + // Wave 2/3 overlays join the mutual-exclusion set. + apply_action(&mut s, KeyAction::OpenDoctor); + assert!(s.doctor_manager.is_some() && s.engine_manager.is_none()); + apply_action(&mut s, KeyAction::OpenUpdate); + assert!(s.update_manager.is_some() && s.doctor_manager.is_none()); + apply_action(&mut s, KeyAction::OpenInstall); + assert!(s.install_manager.is_some() && s.update_manager.is_none()); + apply_action(&mut s, KeyAction::OpenLogs); + assert!(s.logs_view.is_some() && s.install_manager.is_none()); + apply_action(&mut s, KeyAction::OpenRuntimes); + assert!(s.runtime_manager.is_some() && s.logs_view.is_none()); + apply_action(&mut s, KeyAction::OpenOnboarding); + assert!(s.onboarding.is_some() && s.runtime_manager.is_none()); + apply_action(&mut s, KeyAction::OpenAutomations); + assert!(s.automations_manager.is_some() && s.onboarding.is_none()); + apply_action(&mut s, KeyAction::OpenCommand); + assert!(s.command_screen.is_some() && s.automations_manager.is_none()); + apply_action(&mut s, KeyAction::OpenConfig); + assert!(s.config_manager.is_some() && s.command_screen.is_none()); + } + + #[test] + fn question_mark_toggles_help() { + assert_eq!( + hk(KeyCode::Char('?'), ActiveTab::Overview), + KeyAction::ToggleHelp + ); + } + + #[test] + fn t_opens_theme_picker() { + assert_eq!( + hk(KeyCode::Char('t'), ActiveTab::Overview), + KeyAction::OpenThemePicker + ); + } + + #[test] + fn theme_picker_absorbs_navigation_keys() { + let with_picker = |c| { + handle_key( + press(c), + ActiveTab::Overview, + &Modal::ThemePicker, + ChatKeyCtx::default(), + ) + }; + assert_eq!(with_picker(KeyCode::Char('j')), KeyAction::Move(1)); + assert_eq!(with_picker(KeyCode::Char('k')), KeyAction::Move(-1)); + assert_eq!(with_picker(KeyCode::Enter), KeyAction::ApplyThemePick); + assert_eq!(with_picker(KeyCode::Esc), KeyAction::CloseModal); + assert_eq!(with_picker(KeyCode::Char('t')), KeyAction::CloseModal); + assert_eq!(with_picker(KeyCode::Char('q')), KeyAction::Quit); + assert_eq!(with_picker(KeyCode::Char('1')), KeyAction::Nothing); + } + + #[test] + fn open_theme_picker_places_cursor_on_active_theme() { + let mut s = AppState::new("test".into(), "dracula".into()); + s.open_theme_picker(); + assert_eq!(s.modal, Modal::ThemePicker); + let names = crate::ui::theme::theme_names(); + assert_eq!(names[s.theme_picker_sel], "dracula"); + } + + #[test] + fn theme_picker_move_clamps_to_registry() { + let mut s = AppState::new("test".into(), "default-dark".into()); + s.theme_picker_sel = 0; + s.theme_picker_move(-3); + assert_eq!(s.theme_picker_sel, 0); + s.theme_picker_move(1_000); + let names = crate::ui::theme::theme_names(); + assert_eq!(s.theme_picker_sel, names.len() - 1); + } + + #[test] + fn apply_theme_pick_swaps_theme_and_closes_modal() { + let mut s = AppState::new("test".into(), "default-dark".into()); + s.open_theme_picker(); + let names = crate::ui::theme::theme_names(); + let target = names.iter().position(|n| *n == "nord").unwrap(); + s.theme_picker_sel = target; + s.apply_theme_pick(); + assert_eq!(s.theme_name, "nord"); + assert_eq!(s.modal, Modal::None); + // Theme actually swapped. + let nord = Theme::nord(); + assert_eq!(s.theme.bg, nord.bg); + } + + #[test] + fn tab_bar_hit_matches_per_chip_extents() { + // Bar wide enough to fit every chip (59 chars wide minimum). + let bar = Rect::new(0, 0, 80, 1); + // Inside each chip → its tab. + assert_eq!(tab_bar_hit(bar, 5, 0), Some(ActiveTab::Overview)); + assert_eq!(tab_bar_hit(bar, 20, 0), Some(ActiveTab::Hardware)); + assert_eq!(tab_bar_hit(bar, 40, 0), Some(ActiveTab::Instances)); + assert_eq!(tab_bar_hit(bar, 55, 0), Some(ActiveTab::Bench)); + // Separator " · " between chips 0 and 1 (x in 13..16) is a dead zone. + assert_eq!(tab_bar_hit(bar, 14, 0), None); + // Past the last chip (x >= 59) → None. + assert_eq!(tab_bar_hit(bar, 60, 0), None); + // Wrong row. + assert_eq!(tab_bar_hit(bar, 5, 2), None); + } + + #[test] + fn tab_bar_hit_skips_chips_that_overflow_a_narrow_bar() { + // Bar can only fit the first two chips (29 chars). + let bar = Rect::new(0, 0, 30, 1); + assert_eq!(tab_bar_hit(bar, 5, 0), Some(ActiveTab::Overview)); + assert_eq!(tab_bar_hit(bar, 20, 0), Some(ActiveTab::Hardware)); + // Instances chip would be at 32..46 — outside bar → None. + assert_eq!(tab_bar_hit(bar, 40, 0), None); + } + + #[test] + fn tab_bar_hit_honors_x_offset() { + // Bar offset 10 columns to the right. + let bar = Rect::new(10, 0, 80, 1); + assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Overview)); + // Equivalent absolute x of the previous "inside chip 0" test. + assert_eq!(tab_bar_hit(bar, 5, 0), None); + } + + #[test] + fn handle_mouse_routes_scroll_by_modal_and_tab() { + let scroll_down = MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + // No modal, non-interactive tab → Nothing + assert_eq!( + handle_mouse(scroll_down, &Modal::None, ActiveTab::Overview), + KeyAction::Nothing + ); + // No modal, Instances → Move + assert_eq!( + handle_mouse(scroll_down, &Modal::None, ActiveTab::Instances), + KeyAction::Move(3) + ); + // Detail modal → ScrollModal + assert_eq!( + handle_mouse(scroll_down, &Modal::Detail, ActiveTab::Bench), + KeyAction::ScrollModal(3) + ); + // ThemePicker → Move (drives picker cursor) + assert_eq!( + handle_mouse(scroll_down, &Modal::ThemePicker, ActiveTab::Overview), + KeyAction::Move(3) + ); + } + + #[test] + fn scroll_bench_detail_clamps_at_zero() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.bench_detail_scroll = 5; + s.scroll_bench_detail(-100); + assert_eq!(s.bench_detail_scroll, 0); + s.scroll_bench_detail(7); + assert_eq!(s.bench_detail_scroll, 7); + } + + #[test] + fn detail_modal_j_k_emit_scroll() { + let with_detail = |c| { + handle_key( + press(c), + ActiveTab::Bench, + &Modal::Detail, + ChatKeyCtx::default(), + ) + }; + assert_eq!(with_detail(KeyCode::Char('j')), KeyAction::ScrollModal(1)); + assert_eq!(with_detail(KeyCode::Char('k')), KeyAction::ScrollModal(-1)); + assert_eq!(with_detail(KeyCode::PageDown), KeyAction::ScrollModal(10)); + assert_eq!( + with_detail(KeyCode::Char('g')), + KeyAction::ScrollModal(i16::MIN) + ); + assert_eq!( + with_detail(KeyCode::Char('G')), + KeyAction::ScrollModal(i16::MAX) + ); + assert_eq!(with_detail(KeyCode::Esc), KeyAction::CloseModal); + } + + #[test] + fn unknown_initial_theme_falls_back_to_default_dark() { + let s = AppState::new("test".into(), "nope".into()); + let dark = Theme::default_dark(); + assert_eq!(s.theme.bg, dark.bg); + } + + #[test] + fn help_modal_absorbs_navigation() { + let with_help = |c| { + handle_key( + press(c), + ActiveTab::Bench, + &Modal::Help, + ChatKeyCtx::default(), + ) + }; + // j/k inside Help do nothing (Help has no scrollable body today). + assert_eq!(with_help(KeyCode::Char('j')), KeyAction::Nothing); + assert_eq!(with_help(KeyCode::Tab), KeyAction::Nothing); + assert_eq!(with_help(KeyCode::Esc), KeyAction::CloseModal); + assert_eq!(with_help(KeyCode::Enter), KeyAction::CloseModal); + assert_eq!(with_help(KeyCode::Char('q')), KeyAction::Quit); + } + + #[test] + fn move_selection_clamps_to_bounds() { + let mut s = AppState::new("test".into(), "default-dark".into()); + s.active_tab = ActiveTab::Bench; + for i in 0..5 { + s.bench_rows.push_back(BenchmarkRow { + cell: format!("c{i}"), + ..Default::default() + }); + } + s.bench_sel = 0; + s.move_selection(-3); + assert_eq!(s.bench_sel, 0); + s.move_selection(10); + assert_eq!(s.bench_sel, 4); + s.select_first(); + assert_eq!(s.bench_sel, 0); + s.select_last(); + assert_eq!(s.bench_sel, 4); + } + + #[test] + fn format_mmss_renders_minutes_and_hours() { + assert_eq!(format_mmss(0), "0:00"); + assert_eq!(format_mmss(7), "0:07"); + assert_eq!(format_mmss(65), "1:05"); + assert_eq!(format_mmss(599), "9:59"); + assert_eq!(format_mmss(3600), "1:00:00"); + assert_eq!(format_mmss(3661), "1:01:01"); + } + + #[test] + fn bracket_keys_emit_replay_jump() { + assert_eq!( + hk(KeyCode::Char('['), ActiveTab::Overview), + KeyAction::ReplayJump(-10) + ); + assert_eq!( + hk(KeyCode::Char(']'), ActiveTab::Overview), + KeyAction::ReplayJump(10) + ); + assert_eq!( + hk(KeyCode::Char('{'), ActiveTab::Overview), + KeyAction::ReplayJump(-60) + ); + assert_eq!( + hk(KeyCode::Char('}'), ActiveTab::Overview), + KeyAction::ReplayJump(60) + ); + } + + #[test] + fn reset_for_seek_clears_event_derived_state() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.history + .push_back(rocm_dash_core::metrics::Snapshot::default()); + s.latest = Some(rocm_dash_core::metrics::Snapshot::default()); + s.bench_rows.push_back(BenchmarkRow::default()); + s.reset_for_seek(); + assert!(s.history.is_empty()); + assert!(s.latest.is_none()); + assert!(s.bench_rows.is_empty()); + assert!(s.instances.is_empty()); + } + + #[test] + fn selectors_reclamp_after_pop() { + let mut s = AppState::new("test".into(), "default-dark".into()); + s.active_tab = ActiveTab::Bench; + for i in 0..3 { + s.bench_rows.push_back(BenchmarkRow { + cell: format!("c{i}"), + ..Default::default() + }); + } + s.bench_sel = 2; + s.bench_rows.clear(); + s.clamp_selectors(); + assert_eq!(s.bench_sel, 0); + } + + #[test] + fn chat_insert_mode_captures_text_and_shortcircuits_hotkeys() { + let accepted_focused = ChatKeyCtx { + focused: true, + consent: ChatConsent::Accepted, + offer_pending: false, + }; + let focused = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, accepted_focused); + // Printable chars become input, including ones that are global hotkeys. + assert_eq!(focused(KeyCode::Char('h')), KeyAction::ChatInput('h')); + assert_eq!(focused(KeyCode::Char('q')), KeyAction::ChatInput('q')); + assert_eq!(focused(KeyCode::Char('5')), KeyAction::ChatInput('5')); + assert_eq!(focused(KeyCode::Backspace), KeyAction::ChatBackspace); + assert_eq!(focused(KeyCode::Enter), KeyAction::ChatSubmit); + assert_eq!(focused(KeyCode::Esc), KeyAction::ChatBlur); + + // Accepted but NOT focused: `q` still quits and `i`/Enter enter insert mode. + let accepted = ChatKeyCtx { + focused: false, + consent: ChatConsent::Accepted, + offer_pending: false, + }; + let unfocused = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, accepted); + assert_eq!(unfocused(KeyCode::Char('q')), KeyAction::Quit); + assert_eq!(unfocused(KeyCode::Char('i')), KeyAction::ChatFocus); + assert_eq!(unfocused(KeyCode::Enter), KeyAction::ChatFocus); + assert_eq!( + unfocused(KeyCode::Char('1')), + KeyAction::SwitchTab(ActiveTab::Overview) + ); + } + + #[test] + fn chat_consent_gate_maps_keys_and_lets_globals_through() { + let pending = ChatKeyCtx { + focused: false, + consent: ChatConsent::Pending, + offer_pending: false, + }; + let gate = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, pending); + // y / Y / Enter accept; n / N decline. + assert_eq!(gate(KeyCode::Char('y')), KeyAction::ChatConsentAccept); + assert_eq!(gate(KeyCode::Enter), KeyAction::ChatConsentAccept); + assert_eq!(gate(KeyCode::Char('n')), KeyAction::ChatConsentDecline); + // Globals not trapped by the gate: q quits, digit switches tab. + assert_eq!(gate(KeyCode::Char('q')), KeyAction::Quit); + assert_eq!( + gate(KeyCode::Char('2')), + KeyAction::SwitchTab(ActiveTab::Hardware) + ); + } + + #[test] + fn chat_consent_accept_and_decline_transition_state() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // No endpoint → Unavailable; accept/decline are no-ops. + s.set_chat_config(None, false); + assert_eq!(s.chat_consent, ChatConsent::Unavailable); + apply_action(&mut s, KeyAction::ChatConsentAccept); + assert_eq!(s.chat_consent, ChatConsent::Unavailable); + + // Endpoint present, no pre-consent → Pending. + let llm = crate::llm::LlmConfig { + base_url: "http://127.0.0.1:8000".into(), + model: "m".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(llm.clone()), false); + assert_eq!(s.chat_consent, ChatConsent::Pending); + // Accept → Accepted + focused. + apply_action(&mut s, KeyAction::ChatConsentAccept); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + assert!(s.chat_focused); + // Decline → Declined + unfocused. + apply_action(&mut s, KeyAction::ChatConsentDecline); + assert_eq!(s.chat_consent, ChatConsent::Declined); + assert!(!s.chat_focused); + + // Pre-consent → Accepted immediately. + s.set_chat_config(Some(llm), true); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + } + + #[test] + fn detect_offer_lifecycle_accept_switches_chat() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Chat; + // Gateway-configured chat, pending consent. + let gw = crate::llm::LlmConfig { + base_url: "https://gw/OpenAI".into(), + model: "gpt-4o-mini".into(), + api_key: Some("k".into()), + auth_header: Some("Ocp-Apim-Subscription-Key".into()), + }; + s.set_chat_config(Some(gw), false); + + // request_detect raises the dispatch edge + detecting flag. + apply_action(&mut s, KeyAction::ChatDetect); + assert!(s.chat_detecting && s.chat_detect_dispatch); + + // event_loop reports a detected local engine. + let local = crate::llm::detected_llm_config("http://localhost:13305/v1", "Llama-3.2-3B"); + s.set_detect_result(Some(local.clone())); + assert!(!s.chat_detecting); + assert_eq!(s.chat_detect_offer.as_ref(), Some(&local)); + + // Accept the offer → chat switches to the local endpoint + enabled. + apply_action(&mut s, KeyAction::ChatDetectAccept); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + assert_eq!(s.chat_llm.as_ref(), Some(&local)); + assert!(s.chat_detect_offer.is_none()); + } + + #[test] + fn detect_offer_dismiss_keeps_prior_config() { + let mut s = AppState::new("t".into(), "default-dark".into()); + let gw = crate::llm::LlmConfig { + base_url: "https://gw/OpenAI".into(), + model: "gpt-4o-mini".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(gw.clone()), false); + s.set_detect_result(Some(crate::llm::detected_llm_config( + "http://localhost:8000/v1", + "x", + ))); + // Dismiss → offer gone, gateway config + Pending consent intact. + apply_action(&mut s, KeyAction::ChatDetectDismiss); + assert!(s.chat_detect_offer.is_none()); + assert_eq!(s.chat_llm.as_ref(), Some(&gw)); + assert_eq!(s.chat_consent, ChatConsent::Pending); + } + + #[test] + fn save_detect_offer_accepts_and_raises_persist_edge() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.set_detect_result(Some(crate::llm::detected_llm_config( + "http://localhost:13305/v1", + "Llama-3.2-3B", + ))); + apply_action(&mut s, KeyAction::ChatDetectSave); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + assert!(s.chat_persist_dispatch, "save raises the persist edge"); + assert_eq!( + s.chat_llm.as_ref().map(|c| c.base_url.as_str()), + Some("http://localhost:13305/v1") + ); + // No offer → save is a no-op (no edge). + let mut s2 = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s2, KeyAction::ChatDetectSave); + assert!(!s2.chat_persist_dispatch); + } + + #[test] + fn config_with_chat_sets_local_endpoint_and_clears_auth() { + let mut cfg = rocm_dash_core::config::Config::default(); + cfg.tui.chat_auth_header = Some("Ocp-Apim-Subscription-Key".into()); + let next = config_with_chat(cfg, "http://localhost:8000/v1", "qwen"); + assert_eq!( + next.tui.chat_url.as_deref(), + Some("http://localhost:8000/v1") + ); + assert_eq!(next.tui.chat_model.as_deref(), Some("qwen")); + assert_eq!( + next.tui.chat_auth_header, None, + "local needs no gateway auth" + ); + } + + #[test] + fn detect_none_sets_message() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.request_detect(); + s.set_detect_result(None); + assert!(!s.chat_detecting); + assert!(s.chat_detect_offer.is_none()); + assert!(s.chat_detect_msg.is_some()); + } + + #[test] + fn detect_key_available_on_gate_and_offer_keys_take_precedence() { + // `d` triggers detect from the Unavailable empty-state. + let unavail = ChatKeyCtx { + focused: false, + consent: ChatConsent::Unavailable, + offer_pending: false, + }; + assert_eq!( + handle_key( + press(KeyCode::Char('d')), + ActiveTab::Chat, + &Modal::None, + unavail + ), + KeyAction::ChatDetect + ); + // With an offer pending, y/n map to the offer (not consent). + let offering = ChatKeyCtx { + focused: false, + consent: ChatConsent::Pending, + offer_pending: true, + }; + assert_eq!( + handle_key( + press(KeyCode::Char('y')), + ActiveTab::Chat, + &Modal::None, + offering + ), + KeyAction::ChatDetectAccept + ); + assert_eq!( + handle_key( + press(KeyCode::Char('n')), + ActiveTab::Chat, + &Modal::None, + offering + ), + KeyAction::ChatDetectDismiss + ); + } + + #[test] + fn chat_input_actions_mutate_buffer() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Chat; + s.chat_focused = true; + apply_action(&mut s, KeyAction::ChatInput('h')); + apply_action(&mut s, KeyAction::ChatInput('i')); + assert_eq!(s.chat_input, "hi"); + apply_action(&mut s, KeyAction::ChatBackspace); + assert_eq!(s.chat_input, "h"); + apply_action(&mut s, KeyAction::ChatBlur); + assert!(!s.chat_focused); + apply_action(&mut s, KeyAction::ChatFocus); + assert!(s.chat_focused); + } + + #[test] + fn chat_submit_pushes_user_turn_and_raises_dispatch() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "what's GPU-2 doing?".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + // Only the user turn is pushed; the agent reply arrives async. + assert_eq!(s.chat.len(), 1); + assert_eq!(s.chat[0].role, ChatRole::User); + assert_eq!(s.chat[0].content, "what's GPU-2 doing?"); + assert!(s.chat_input.is_empty()); + assert!(s.chat_sending, "submit marks the request in flight"); + assert!(s.chat_dispatch, "submit raises the spawn edge"); + } + + #[test] + fn chat_submit_ignores_empty_input() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = " ".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat.is_empty()); + assert!(!s.chat_sending); + assert!(!s.chat_dispatch); + } + + #[test] + fn chat_submit_ignored_while_request_in_flight() { + // A second submit before the first reply lands must be a no-op — no + // second user turn, no second spawn (prevents a racing double request). + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "first".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat_sending); + assert_eq!(s.chat.len(), 1); + s.chat_dispatch = false; // simulate event_loop consuming the edge + s.chat_input = "second".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert_eq!(s.chat.len(), 1, "second submit ignored while in flight"); + assert!(!s.chat_dispatch, "no second dispatch edge raised"); + // After the reply clears the flag, submits work again. + s.on_chat_reply("done".into()); + assert!(!s.chat_sending); + s.chat_input = "third".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat_dispatch); + } + + #[tokio::test] + async fn chat_reply_path_appends_agent_turn_and_clears_sending() { + // The wired ChatSubmit→reply path using the MockAgentClient (no LLM). + let agent = crate::agent::MockAgentClient::new("GPU-2: 87% util, 71°C"); + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "what's GPU-2 doing?".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat_sending); + // Simulate event_loop: run the agent over the history, deliver the reply. + let snapshot = s.state_snapshot(); + let reply = crate::agent::AgentClient::complete(&agent, &s.chat, snapshot) + .await + .expect("mock reply"); + s.on_chat_reply(reply); + assert_eq!(s.chat.last().unwrap().role, ChatRole::Agent); + assert_eq!(s.chat.last().unwrap().content, "GPU-2: 87% util, 71°C"); + assert!(!s.chat_sending); + } + + #[test] + fn chat_input_handles_unicode_and_long_text() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_focused = true; + // Multi-byte / emoji chars push as single chars, no panic. + for c in "héllo 🚀 café ∑".chars() { + apply_action(&mut s, KeyAction::ChatInput(c)); + } + assert_eq!(s.chat_input, "héllo 🚀 café ∑"); + // Backspace removes the trailing multi-byte char correctly. + apply_action(&mut s, KeyAction::ChatBackspace); + assert_eq!(s.chat_input, "héllo 🚀 café "); + // Very long input is accepted. + for _ in 0..5000 { + apply_action(&mut s, KeyAction::ChatInput('x')); + } + assert!(s.chat_input.len() > 5000); + // Submitting unicode pushes one user turn, no panic. + s.chat_input = "什么是 GPU-2?".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert_eq!(s.chat[0].content, "什么是 GPU-2?"); + } + + #[test] + fn chat_scroll_clamps_at_zero_and_pages() { + let mut s = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s, KeyAction::ChatScroll(-100)); + assert_eq!(s.chat_scroll, 0, "scroll clamps at top"); + apply_action(&mut s, KeyAction::ChatScroll(7)); + assert_eq!(s.chat_scroll, 7); + // PageUp/PageDown map to ChatScroll on the Chat tab when accepted. + let accepted = ChatKeyCtx { + focused: false, + consent: ChatConsent::Accepted, + offer_pending: false, + }; + assert_eq!( + handle_key( + press(KeyCode::PageDown), + ActiveTab::Chat, + &Modal::None, + accepted + ), + KeyAction::ChatScroll(CHAT_SCROLL_STEP) + ); + assert_eq!( + handle_key( + press(KeyCode::PageUp), + ActiveTab::Chat, + &Modal::None, + accepted + ), + KeyAction::ChatScroll(-CHAT_SCROLL_STEP) + ); + } + + #[tokio::test] + async fn chat_error_path_appends_error_turn_no_panic() { + let agent = crate::agent::MockAgentClient::failing(); + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "hi".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + let snapshot = s.state_snapshot(); + let err = crate::agent::AgentClient::complete(&agent, &s.chat, snapshot) + .await + .unwrap_err(); + s.on_chat_error(err.to_string()); + assert_eq!(s.chat.last().unwrap().role, ChatRole::Error); + assert!(!s.chat_sending); + } +} diff --git a/crates/rocm-dash-tui/src/client.rs b/crates/rocm-dash-tui/src/client.rs new file mode 100644 index 00000000..5cb07fd5 --- /dev/null +++ b/crates/rocm-dash-tui/src/client.rs @@ -0,0 +1,141 @@ +//! NDJSON client task. Owns the connection, handshakes, reads events, and +//! forwards everything (including connect/disconnect transitions) to the app loop +//! via an mpsc channel. + +use std::path::PathBuf; + +use anyhow::{Context, anyhow}; +use rocm_dash_core::protocol::{Command, Event, PROTOCOL_VERSION}; +use tokio::io::{AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{info, warn}; + +use crate::reconnect::Backoff; +use crate::transport::{read_line, write_line}; + +/// Messages the client task posts to the app loop. +#[derive(Debug, Clone)] +pub enum ClientMsg { + Connecting, + Connected { + host: String, + daemon_version: String, + }, + Disconnected { + reason: String, + }, + Event(Box), + /// Replay-only: the scrubber jumped backward. The TUI must wipe any + /// state derived from past events before the burst-emitted events for + /// the new playhead position arrive. + ReplaySeek, + /// Replay-only: current playhead and total recording length in seconds. + /// Drives the `M:SS / M:SS` header readout. + ReplayPosition { + elapsed_s: u64, + total_s: u64, + }, + /// Chat agent produced a reply (TUI-internal — NOT a `core::protocol::Event`). + ChatReply { + text: String, + }, + /// Chat agent failed; rendered as an `Error`-role turn (never a panic). + ChatError { + message: String, + }, + /// Result of an in-TUI local-engine detect: `Some` base_url+model when an + /// endpoint was reachable and queried, `None` when nothing was found. + ChatDetectResult { + offer: Option, + }, +} + +pub fn spawn(connect: String, tx: UnboundedSender) { + tokio::spawn(async move { + let mut backoff = Backoff::default(); + loop { + let _ = tx.send(ClientMsg::Connecting); + match connect_and_run(&connect, tx.clone()).await { + Ok(()) => { + let _ = tx.send(ClientMsg::Disconnected { + reason: "closed".into(), + }); + backoff = Backoff::default(); + } + Err(e) => { + let _ = tx.send(ClientMsg::Disconnected { + reason: e.to_string(), + }); + } + } + let delay = backoff.next_delay(); + tokio::time::sleep(delay).await; + } + }); +} + +async fn connect_and_run(connect: &str, tx: UnboundedSender) -> anyhow::Result<()> { + let (scheme, target) = connect + .split_once(':') + .ok_or_else(|| anyhow!("connect must be `unix:/path/to.sock`"))?; + if scheme != "unix" { + return Err(anyhow!( + "only unix sockets supported in scaffold (got `{scheme}`)" + )); + } + let stream = UnixStream::connect(PathBuf::from(target)) + .await + .with_context(|| format!("connecting unix:{target}"))?; + + let (rd, mut wr) = stream.into_split(); + let mut rd = BufReader::new(rd); + + let welcome: Option = read_line(&mut rd).await?; + let (host, daemon_version) = match welcome { + Some(Event::Welcome { + protocol_version, + daemon_version, + host, + }) => { + if protocol_version != PROTOCOL_VERSION { + warn!( + daemon = protocol_version, + client = PROTOCOL_VERSION, + "protocol version mismatch (continuing)" + ); + } + (host, daemon_version) + } + Some(other) => return Err(anyhow!("expected Welcome, got {:?}", other)), + None => return Err(anyhow!("connection closed before Welcome")), + }; + + info!(host = %host, version = %daemon_version, "connected"); + let _ = tx.send(ClientMsg::Connected { + host: host.clone(), + daemon_version: daemon_version.clone(), + }); + + let hello = Command::Hello { + protocol_version: PROTOCOL_VERSION, + client: format!("rocmdash/{}", env!("CARGO_PKG_VERSION")), + token: None, + }; + write_line(&mut wr, &hello).await?; + write_line(&mut wr, &Command::Subscribe).await?; + wr.flush().await?; + + loop { + let ev: Option = read_line(&mut rd).await?; + let Some(ev) = ev else { break }; + let is_bye = matches!(ev, Event::Bye); + if tx.send(ClientMsg::Event(Box::new(ev))).is_err() { + break; + } + if is_bye { + break; + } + } + Ok(()) +} diff --git a/crates/rocm-dash-tui/src/jobs.rs b/crates/rocm-dash-tui/src/jobs.rs new file mode 100644 index 00000000..25665ef1 --- /dev/null +++ b/crates/rocm-dash-tui/src/jobs.rs @@ -0,0 +1,163 @@ +//! Async job-bridge runtime (Phase 3 Wave 0). +//! +//! Interprets [`SideEffect::SpawnJob`]: launches the child process, streams its +//! stdout+stderr back as [`StateEvent::JobLine`], finishes with +//! [`StateEvent::JobDone`] / [`StateEvent::JobErr`], and kills the child when +//! the shared `cancel` flag flips. This is the single async primitive every +//! operational screen reuses instead of the legacy rocm-cli +//! `std::thread::spawn` + mpsc + `try_recv` pattern (56 sites). +//! +//! The reducer (`rocm-dash-core`) stays pure and `tokio`-free; all process I/O +//! lives here, on the surviving side of the boundary. +//! +//! Events are posted on an [`UnboundedSender`] (the same pattern as +//! `client.rs` / `replay.rs`). Unbounded is deliberate: the reducer is pure and +//! synchronous, so it cannot exert backpressure on the async child; a bounded +//! channel would risk deadlock or dropped output on a burst. The output is +//! bounded instead in the reducer, by `JOB_OUTPUT_RING_CAP`. + +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use rocm_dash_core::state::{SideEffect, StateEvent}; +use tokio::io::{AsyncBufReadExt, BufReader, Lines}; +use tokio::process::Command; +use tokio::sync::mpsc::UnboundedSender; + +/// How often the runtime wakes to re-check the cancel flag while idle. +const CANCEL_POLL: Duration = Duration::from_millis(100); + +/// Drive a batch of reducer side effects, spawning a job task for each +/// [`SideEffect::SpawnJob`]. Non-job effects are ignored here — the daemon owns +/// broadcast/persist; in the TUI the job-bridge only cares about `SpawnJob`. +pub fn run_effects(effects: Vec, tx: &UnboundedSender) { + for fx in effects { + if let SideEffect::SpawnJob { + id, + cmd, + args, + cancel, + } = fx + { + spawn_job(id, cmd, args, cancel, tx.clone()); + } + } +} + +/// Spawn one background job. Returns immediately; the spawned task posts +/// [`StateEvent`]s back through `tx` until the job is terminal. +pub fn spawn_job( + id: String, + cmd: String, + args: Vec, + cancel: Arc, + tx: UnboundedSender, +) { + tokio::spawn(async move { run_job(id, cmd, args, cancel, tx).await }); +} + +async fn run_job( + id: String, + cmd: String, + args: Vec, + cancel: Arc, + tx: UnboundedSender, +) { + let mut child = match Command::new(&cmd) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + { + Ok(c) => c, + Err(e) => { + let _ = tx.send(StateEvent::JobErr { + id, + message: format!("spawn failed: {e}"), + }); + return; + } + }; + + // `take()` moves the pipe handles out of `child` so only `child.wait()` + // borrows it later — the readers own their streams. + let mut out = child.stdout.take().map(|s| BufReader::new(s).lines()); + let mut err = child.stderr.take().map(|s| BufReader::new(s).lines()); + + loop { + if cancel.load(Ordering::SeqCst) { + // Reducer already marked the job `Cancelled`; tear the child down. + let _ = child.start_kill(); + let _ = child.wait().await; + return; + } + + let out_open = out.is_some(); + let err_open = err.is_some(); + + // `biased`: output and completion arms are checked before the idle + // poll, so streaming throughput is never sacrificed to the wake-up + // timer. The poll is last — it only matters when the pipes are idle. + tokio::select! { + biased; + + line = next_line(&mut out), if out_open => { + match line { + Some(l) => emit(&tx, &id, l), + None => out = None, // EOF on stdout + } + } + + line = next_line(&mut err), if err_open => { + match line { + Some(l) => emit(&tx, &id, l), + None => err = None, // EOF on stderr + } + } + + // Only reap once both pipes have closed, so all output is drained + // before we report completion. + status = child.wait(), if !out_open && !err_open => { + let code = match status { + Ok(s) => s.code().unwrap_or(-1), + Err(e) => { + let _ = tx.send(StateEvent::JobErr { + id, + message: format!("wait failed: {e}"), + }); + return; + } + }; + let _ = tx.send(StateEvent::JobDone { id, code }); + return; + } + + // Idle wake-up so cancellation is observed within CANCEL_POLL even + // when the child produces no output. + _ = tokio::time::sleep(CANCEL_POLL) => {} + } + } +} + +/// Read the next line from an optional reader. `Some(line)` on data, `None` on +/// EOF or read error (treated as stream close). +async fn next_line(reader: &mut Option>>) -> Option +where + R: tokio::io::AsyncRead + Unpin, +{ + match reader { + Some(lines) => lines.next_line().await.ok().flatten(), + None => std::future::pending().await, + } +} + +fn emit(tx: &UnboundedSender, id: &str, line: String) { + let _ = tx.send(StateEvent::JobLine { + id: id.to_string(), + line, + }); +} diff --git a/crates/rocm-dash-tui/src/lib.rs b/crates/rocm-dash-tui/src/lib.rs new file mode 100644 index 00000000..9e20e0a5 --- /dev/null +++ b/crates/rocm-dash-tui/src/lib.rs @@ -0,0 +1,18 @@ +//! rocm-dash TUI client library. +//! +//! The composition-root binary (`rocm`) is a thin wrapper over `app::run`; +//! the same modules are reachable as a library so examples (e.g. screenshot +//! generation) can drive the UI in-process. + +#![allow(dead_code)] + +pub mod agent; +pub mod app; +pub mod client; +pub mod jobs; +pub mod llm; +pub mod reconnect; +pub mod replay; +pub mod skills; +pub mod transport; +pub mod ui; diff --git a/crates/rocm-dash-tui/src/llm.rs b/crates/rocm-dash-tui/src/llm.rs new file mode 100644 index 00000000..c0e9ca8d --- /dev/null +++ b/crates/rocm-dash-tui/src/llm.rs @@ -0,0 +1,293 @@ +//! Chat LLM configuration: a pure precedence resolver plus a std-only TCP +//! liveness probe. No HTTP client here — the Rig-built `AgentClient` (Phase 3) +//! lives in `agent.rs`. Keeping detection in the TUI crate preserves the +//! core's render/async-free boundary (`LRN-20260405-004`). + +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +/// Probed-default endpoint: the local OpenAI-compatible serving endpoint the +/// dashboard already watches. Used only when no URL is configured anywhere. +pub const DEFAULT_CHAT_BASE_URL: &str = "http://127.0.0.1:8000"; + +/// Fallback model name when none is configured. Many local endpoints ignore +/// the model field or expose a single model, so a neutral default is safe. +pub const DEFAULT_CHAT_MODEL: &str = "local-model"; + +/// Short, one-shot probe budget so detection never stalls TUI startup. +pub const PROBE_TIMEOUT: Duration = Duration::from_millis(300); + +/// Fully-resolved chat endpoint configuration. `api_key` is sourced from the +/// environment only — never from TOML/CLI/source (see `main.rs`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LlmConfig { + pub base_url: String, + pub model: String, + pub api_key: Option, + /// Custom auth header NAME (e.g. `Ocp-Apim-Subscription-Key`). When set, the + /// `api_key` value is sent in this header instead of `Authorization: Bearer`. + pub auth_header: Option, +} + +/// Resolve the chat endpoint by precedence: **CLI > config > env > probed +/// default**, returning `None` when nothing is available (no URL from any tier +/// and the default endpoint is not reachable). +/// +/// Pure — all I/O (env reads, the TCP probe) happens at the call site and is +/// passed in. This is the single source of precedence truth and the unit-test +/// anchor. `cli_*` carries the CLI value already merged over config in the +/// `rocm` binary (so `cfg_*` is typically `None` at runtime); the separate +/// `cfg_*` params keep every tier independently testable. +#[allow(clippy::too_many_arguments)] +pub fn resolve_llm_config( + cli_url: Option<&str>, + cli_model: Option<&str>, + cfg_url: Option<&str>, + cfg_model: Option<&str>, + env_key: Option<&str>, + env_url: Option<&str>, + auth_header: Option<&str>, + probe_ok: bool, +) -> Option { + // base_url precedence: CLI > config > env > probed-default (gated by probe). + let base_url = cli_url + .or(cfg_url) + .or(env_url) + .map(str::to_string) + .or_else(|| probe_ok.then(|| DEFAULT_CHAT_BASE_URL.to_string()))?; + + // model precedence: CLI > config > built-in default. + let model = cli_model + .or(cfg_model) + .map(str::to_string) + .unwrap_or_else(|| DEFAULT_CHAT_MODEL.to_string()); + + Some(LlmConfig { + base_url, + model, + api_key: env_key.map(str::to_string), + auth_header: auth_header.map(str::to_string), + }) +} + +/// Parse `host` and `port` out of an OpenAI-style base URL. Tolerates a +/// missing scheme and trailing path; defaults the port from the scheme +/// (`https` → 443, otherwise 80). Pure — no DNS, no connection. +pub fn parse_host_port(base_url: &str) -> Option<(String, u16)> { + let trimmed = base_url.trim(); + let (scheme, rest) = match trimmed.split_once("://") { + Some((s, r)) => (s, r), + None => ("http", trimmed), + }; + // Drop any path/query after the authority. + let authority = rest.split(['/', '?']).next().unwrap_or(rest); + if authority.is_empty() { + return None; + } + let default_port = if scheme.eq_ignore_ascii_case("https") { + 443 + } else { + 80 + }; + match authority.rsplit_once(':') { + Some((host, port_str)) if !host.is_empty() => { + let port = port_str.parse::().ok()?; + Some((host.to_string(), port)) + } + _ => Some((authority.to_string(), default_port)), + } +} + +/// Best-effort TCP liveness probe for a candidate endpoint. Returns `true` +/// only if a connection to the parsed host:port succeeds within `timeout`. +/// Never panics; any parse/DNS/connect failure yields `false`. +pub fn probe_endpoint(base_url: &str, timeout: Duration) -> bool { + let Some((host, port)) = parse_host_port(base_url) else { + return false; + }; + let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { + return false; + }; + for addr in addrs { + if TcpStream::connect_timeout(&addr, timeout).is_ok() { + return true; + } + } + false +} + +/// Probe the known local serving endpoints in priority order (Lemonade, then +/// vLLM) and return the first reachable one. Used by the TUI's in-app +/// "detect a local engine" action so the user need not run the CLI skill first. +/// TCP-only (no HTTP); never blocks longer than [`PROBE_TIMEOUT`] per candidate. +pub fn detect_local_endpoint() -> Option<&'static str> { + [ + crate::skills::LEMONADE_ENDPOINT, + crate::skills::VLLM_ENDPOINT, + ] + .into_iter() + .find(|ep| probe_endpoint(ep, PROBE_TIMEOUT)) +} + +/// Pick the first model id from an OpenAI-compatible `/v1/models` response +/// (`{"data":[{"id":"…"}]}`). Pure — no HTTP. `None` when the shape is missing, +/// empty, or malformed, so the caller can fall back to [`DEFAULT_CHAT_MODEL`]. +pub fn pick_first_model(models_json: &serde_json::Value) -> Option { + models_json + .get("data")? + .as_array()? + .iter() + .find_map(|m| m.get("id").and_then(|id| id.as_str())) + .map(str::to_string) +} + +/// Build an [`LlmConfig`] for a locally-detected engine. Local OpenAI-compatible +/// servers (Lemonade / vLLM) need no gateway auth, so `api_key`/`auth_header` +/// are `None`. Pure. +pub fn detected_llm_config(base_url: &str, model: &str) -> LlmConfig { + LlmConfig { + base_url: base_url.to_string(), + model: model.to_string(), + api_key: None, + auth_header: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pick_first_model_reads_data_array() { + let j = serde_json::json!({"object":"list","data":[{"id":"Llama-3.2-3B"},{"id":"qwen"}]}); + assert_eq!(pick_first_model(&j).as_deref(), Some("Llama-3.2-3B")); + } + + #[test] + fn pick_first_model_none_on_empty_or_malformed() { + assert_eq!(pick_first_model(&serde_json::json!({"data":[]})), None); + assert_eq!(pick_first_model(&serde_json::json!({})), None); + assert_eq!(pick_first_model(&serde_json::json!({"data":"nope"})), None); + // entries without an "id" string are skipped + assert_eq!( + pick_first_model(&serde_json::json!({"data":[{"x":1}]})), + None + ); + } + + #[test] + fn detected_llm_config_carries_no_auth() { + let c = detected_llm_config("http://localhost:13305/v1", "m"); + assert_eq!(c.base_url, "http://localhost:13305/v1"); + assert_eq!(c.model, "m"); + assert_eq!(c.api_key, None); + assert_eq!(c.auth_header, None); + } + + #[test] + fn cli_wins_over_every_other_tier() { + let r = resolve_llm_config( + Some("http://cli:1"), + Some("cli-model"), + Some("http://cfg:2"), + Some("cfg-model"), + Some("k"), + Some("http://env:3"), + Some("Ocp-Apim-Subscription-Key"), + true, + ) + .unwrap(); + assert_eq!(r.base_url, "http://cli:1"); + assert_eq!(r.model, "cli-model"); + assert_eq!(r.api_key.as_deref(), Some("k")); + assert_eq!(r.auth_header.as_deref(), Some("Ocp-Apim-Subscription-Key")); + } + + #[test] + fn config_wins_when_no_cli() { + let r = resolve_llm_config( + None, + None, + Some("http://cfg:2"), + Some("cfg-model"), + None, + Some("http://env:3"), + None, + true, + ) + .unwrap(); + assert_eq!(r.base_url, "http://cfg:2"); + assert_eq!(r.model, "cfg-model"); + assert_eq!(r.api_key, None); + } + + #[test] + fn env_url_wins_when_no_cli_or_config() { + let r = resolve_llm_config( + None, + None, + None, + None, + Some("k"), + Some("http://env:3"), + None, + false, + ) + .unwrap(); + assert_eq!(r.base_url, "http://env:3"); + // No model anywhere → built-in default. + assert_eq!(r.model, DEFAULT_CHAT_MODEL); + assert_eq!(r.api_key.as_deref(), Some("k")); + } + + #[test] + fn probed_default_used_when_nothing_configured_but_reachable() { + let r = resolve_llm_config(None, None, None, None, None, None, None, true).unwrap(); + assert_eq!(r.base_url, DEFAULT_CHAT_BASE_URL); + assert_eq!(r.model, DEFAULT_CHAT_MODEL); + assert_eq!(r.api_key, None); + } + + #[test] + fn none_when_nothing_available() { + assert_eq!( + resolve_llm_config(None, None, None, None, None, None, None, false), + None + ); + } + + #[test] + fn parse_host_port_handles_scheme_path_and_defaults() { + assert_eq!( + parse_host_port("http://127.0.0.1:8000"), + Some(("127.0.0.1".into(), 8000)) + ); + assert_eq!( + parse_host_port("http://127.0.0.1:8000/v1"), + Some(("127.0.0.1".into(), 8000)) + ); + assert_eq!( + parse_host_port("localhost:1234"), + Some(("localhost".into(), 1234)) + ); + assert_eq!( + parse_host_port("https://api.example.com"), + Some(("api.example.com".into(), 443)) + ); + assert_eq!( + parse_host_port("http://example.com"), + Some(("example.com".into(), 80)) + ); + assert_eq!(parse_host_port(""), None); + } + + #[test] + fn probe_unreachable_port_is_false_not_panic() { + // Port 1 on localhost is essentially never open; must return false fast. + assert!(!probe_endpoint( + "http://127.0.0.1:1", + Duration::from_millis(100) + )); + assert!(!probe_endpoint("not a url", Duration::from_millis(100))); + } +} diff --git a/crates/rocm-dash-tui/src/reconnect.rs b/crates/rocm-dash-tui/src/reconnect.rs new file mode 100644 index 00000000..2b3dc296 --- /dev/null +++ b/crates/rocm-dash-tui/src/reconnect.rs @@ -0,0 +1,50 @@ +//! Exponential backoff helper for reconnect loops. Ported from ctux pattern. + +use std::time::Duration; + +pub struct Backoff { + current: Duration, + max: Duration, + factor: u32, +} + +impl Default for Backoff { + fn default() -> Self { + Self::new(Duration::from_millis(250), Duration::from_secs(10), 2) + } +} + +impl Backoff { + pub fn new(initial: Duration, max: Duration, factor: u32) -> Self { + Self { + current: initial, + max, + factor, + } + } + + pub fn next_delay(&mut self) -> Duration { + let d = self.current; + self.current = (self.current * self.factor).min(self.max); + d + } + + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doubles_until_cap() { + let mut b = Backoff::new(Duration::from_millis(100), Duration::from_millis(800), 2); + assert_eq!(b.next_delay(), Duration::from_millis(100)); + assert_eq!(b.next_delay(), Duration::from_millis(200)); + assert_eq!(b.next_delay(), Duration::from_millis(400)); + assert_eq!(b.next_delay(), Duration::from_millis(800)); + assert_eq!(b.next_delay(), Duration::from_millis(800)); + } +} diff --git a/crates/rocm-dash-tui/src/replay.rs b/crates/rocm-dash-tui/src/replay.rs new file mode 100644 index 00000000..919a1051 --- /dev/null +++ b/crates/rocm-dash-tui/src/replay.rs @@ -0,0 +1,623 @@ +//! Offline replay source. +//! +//! Reads a daemon-produced session file (NDJSON of `PersistedEntry`) into +//! memory upfront and emits the recorded Events through the same +//! `ClientMsg` mpsc channel used by the live client task — so the rest of +//! the TUI doesn't know or care whether it's connected to a live daemon or +//! replaying from disk. +//! +//! Pacing: replay sleeps between events by the real-time delta between +//! their `ts_us` values (scaled by `speed`), capped at `MAX_GAP_MS` so a +//! long pause in the recording doesn't stall the TUI for minutes. +//! +//! Scrubbing: holding the entries in a `Vec` lets us seek. Forward jumps +//! burst-emit any events between the current cursor and the target +//! timestamp; backward jumps send `ClientMsg::ReplaySeek` (so the TUI can +//! wipe its derived state) and then burst-emit from the start of the +//! recording up to the target. The header gets `ReplayPosition` updates so +//! it can display elapsed/total. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Context; +use rocm_dash_core::persist::PersistedEntry; +use rocm_dash_core::protocol::PROTOCOL_VERSION; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tracing::{info, warn}; + +use crate::client::ClientMsg; + +/// Cap on inter-event sleep — keeps replay responsive even if the recording +/// has long quiet stretches. +const MAX_GAP_MS: u64 = 2_000; + +/// Scrubber commands from the TUI to the replay task. +#[derive(Debug, Clone)] +pub enum ReplayControl { + Pause, + Resume, + SetSpeed(f64), + /// Move the playhead by `delta_s` seconds (negative = rewind). + Jump { + delta_s: i64, + }, +} + +#[derive(Debug, Clone)] +pub struct ReplayController { + tx: UnboundedSender, +} + +impl ReplayController { + pub fn pause(&self) { + let _ = self.tx.send(ReplayControl::Pause); + } + pub fn resume(&self) { + let _ = self.tx.send(ReplayControl::Resume); + } + pub fn set_speed(&self, speed: f64) { + let _ = self + .tx + .send(ReplayControl::SetSpeed(speed.clamp(0.25, 8.0))); + } + pub fn jump(&self, delta_s: i64) { + let _ = self.tx.send(ReplayControl::Jump { delta_s }); + } +} + +pub const SPEED_STEPS: &[f64] = &[0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; + +pub fn next_speed(current: f64) -> f64 { + for &s in SPEED_STEPS { + if s > current + f64::EPSILON { + return s; + } + } + *SPEED_STEPS.last().unwrap() +} + +pub fn prev_speed(current: f64) -> f64 { + let mut best = SPEED_STEPS[0]; + for &s in SPEED_STEPS { + if s + f64::EPSILON < current { + best = s; + } else { + break; + } + } + best +} + +pub fn spawn(path: PathBuf, tx: UnboundedSender) -> ReplayController { + let (ctl_tx, ctl_rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let _ = tx.send(ClientMsg::Connecting); + match run(&path, &tx, ctl_rx).await { + Ok(()) => { + info!(path = %path.display(), "replay complete"); + let _ = tx.send(ClientMsg::Disconnected { + reason: "end of recording".into(), + }); + } + Err(e) => { + warn!(path = %path.display(), error = %e, "replay failed"); + let _ = tx.send(ClientMsg::Disconnected { + reason: e.to_string(), + }); + } + } + }); + ReplayController { tx: ctl_tx } +} + +fn read_entries(path: &Path) -> anyhow::Result> { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("opening replay file {}", path.display()))?; + let mut out = Vec::new(); + for (lineno, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(e) => out.push(e), + Err(e) => warn!(line = lineno + 1, error = %e, "skipping malformed replay line"), + } + } + Ok(out) +} + +async fn run( + path: &Path, + tx: &UnboundedSender, + mut ctl: UnboundedReceiver, +) -> anyhow::Result<()> { + let entries = read_entries(path)?; + + let _ = tx.send(ClientMsg::Connected { + host: format!( + "replay:{}", + path.file_name().and_then(|n| n.to_str()).unwrap_or("file") + ), + daemon_version: format!("replay/v{PROTOCOL_VERSION}"), + }); + + if entries.is_empty() { + return Ok(()); + } + let first_ts_us = entries.first().unwrap().ts_us; + let last_ts_us = entries.last().unwrap().ts_us; + let total_s = last_ts_us.saturating_sub(first_ts_us) / 1_000_000; + + let mut paused = false; + let mut speed: f64 = 1.0; + let mut last_emit = tokio::time::Instant::now(); + + // Emit the first event immediately so the UI lights up. + emit_at(tx, &entries[0], first_ts_us, total_s); + let mut cursor: usize = 1; + + while cursor < entries.len() { + let entry = &entries[cursor]; + let prev_ts = entries[cursor - 1].ts_us; + let raw_gap = Duration::from_micros(entry.ts_us.saturating_sub(prev_ts)) + .min(Duration::from_millis(MAX_GAP_MS)); + let mut target = last_emit + scale_gap(raw_gap, speed); + + // Sleep until `target`, but respond to control messages along the way. + loop { + if paused { + match ctl.recv().await { + Some(c) => { + if handle_control( + c, + &mut paused, + &mut speed, + &mut cursor, + &entries, + tx, + first_ts_us, + total_s, + ) { + // Cursor moved (Jump) — break so the outer loop resets pacing. + break; + } + if !paused { + target = tokio::time::Instant::now(); + } + continue; + } + None => return Ok(()), + } + } + let now = tokio::time::Instant::now(); + if now >= target { + break; + } + tokio::select! { + _ = tokio::time::sleep_until(target) => break, + maybe = ctl.recv() => match maybe { + Some(c) => { + let jumped = handle_control(c, &mut paused, &mut speed, &mut cursor, + &entries, tx, first_ts_us, total_s); + if jumped { + break; + } + if !paused { + target = tokio::time::Instant::now() + scale_gap(raw_gap, speed); + } + } + None => return Ok(()), + }, + } + } + + // Re-check cursor after possible jump (handle_control may have moved it). + if cursor >= entries.len() { + break; + } + let entry = &entries[cursor]; + emit_at(tx, entry, first_ts_us, total_s); + last_emit = tokio::time::Instant::now(); + cursor += 1; + } + Ok(()) +} + +/// Send the event + position update for a single entry. Returns `false` if +/// the receiver is gone (caller should bail). +fn emit_at( + tx: &UnboundedSender, + entry: &PersistedEntry, + first_ts_us: u64, + total_s: u64, +) -> bool { + let elapsed_s = entry.ts_us.saturating_sub(first_ts_us) / 1_000_000; + if tx + .send(ClientMsg::Event(Box::new(entry.event.clone()))) + .is_err() + { + return false; + } + let _ = tx.send(ClientMsg::ReplayPosition { elapsed_s, total_s }); + true +} + +/// Apply a control message. Returns `true` when the cursor was moved by a +/// jump (so the outer loop should reset its pacing baseline). +#[allow(clippy::too_many_arguments)] +fn handle_control( + c: ReplayControl, + paused: &mut bool, + speed: &mut f64, + cursor: &mut usize, + entries: &[PersistedEntry], + tx: &UnboundedSender, + first_ts_us: u64, + total_s: u64, +) -> bool { + match c { + ReplayControl::Pause => { + *paused = true; + false + } + ReplayControl::Resume => { + *paused = false; + false + } + ReplayControl::SetSpeed(s) => { + *speed = s.clamp(0.25, 8.0); + false + } + ReplayControl::Jump { delta_s } => { + do_jump(delta_s, cursor, entries, tx, first_ts_us, total_s); + true + } + } +} + +/// Move the playhead by `delta_s` seconds and burst-emit any events the +/// jump crosses. Backward jumps additionally send `ReplaySeek` so the TUI +/// can wipe its derived state before the burst arrives. +fn do_jump( + delta_s: i64, + cursor: &mut usize, + entries: &[PersistedEntry], + tx: &UnboundedSender, + first_ts_us: u64, + total_s: u64, +) { + // Anchor: where are we now in recording time? + let now_idx = (*cursor).min(entries.len().saturating_sub(1)); + let now_ts = entries[now_idx].ts_us; + let last_ts = entries.last().unwrap().ts_us; + + let target_ts_us = if delta_s >= 0 { + now_ts.saturating_add((delta_s as u64).saturating_mul(1_000_000)) + } else { + let back = (-delta_s) as u64 * 1_000_000; + now_ts.saturating_sub(back).max(first_ts_us) + } + .min(last_ts); + + if target_ts_us >= now_ts { + // Forward: emit anything we'd otherwise sleep past, then settle on + // the first entry with ts >= target. + while *cursor < entries.len() && entries[*cursor].ts_us < target_ts_us { + if !emit_at(tx, &entries[*cursor], first_ts_us, total_s) { + return; + } + *cursor += 1; + } + } else { + // Backward: reset the TUI, then burst-emit from the start through + // target. Land cursor on the first entry strictly past target so + // the outer loop's next iteration paces normally. + let _ = tx.send(ClientMsg::ReplaySeek); + *cursor = 0; + while *cursor < entries.len() && entries[*cursor].ts_us <= target_ts_us { + if !emit_at(tx, &entries[*cursor], first_ts_us, total_s) { + return; + } + *cursor += 1; + } + } +} + +fn scale_gap(raw: Duration, speed: f64) -> Duration { + if speed <= 0.0 { + return raw; + } + let scaled = raw.as_secs_f64() / speed; + Duration::from_secs_f64(scaled.max(0.001)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rocm_dash_core::metrics::Snapshot; + use rocm_dash_core::protocol::Event; + use std::io::Write; + use tokio::sync::mpsc::unbounded_channel; + + fn tmp_file(label: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "rocm-dash-replay-test-{}-{}-{label}.ndjson", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p + } + + fn write_lines(path: &Path, entries: &[PersistedEntry]) { + let mut f = std::fs::File::create(path).unwrap(); + for e in entries { + let line = serde_json::to_string(e).unwrap(); + f.write_all(line.as_bytes()).unwrap(); + f.write_all(b"\n").unwrap(); + } + f.flush().unwrap(); + } + + #[tokio::test] + async fn emits_events_and_then_disconnect() { + let path = tmp_file("flow"); + let entries = vec![ + PersistedEntry { + ts_us: 0, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 1_000, + event: Event::Bye, + }, + ]; + write_lines(&path, &entries); + + let (tx, mut rx) = unbounded_channel::(); + let _ctl = spawn(path.clone(), tx); + + assert!(matches!(rx.recv().await.unwrap(), ClientMsg::Connecting)); + assert!(matches!( + rx.recv().await.unwrap(), + ClientMsg::Connected { .. } + )); + // Each event is followed by a position update. + match rx.recv().await.unwrap() { + ClientMsg::Event(ev) => assert!(matches!(*ev, Event::Snapshot(_))), + other => panic!("expected event, got {other:?}"), + } + assert!(matches!( + rx.recv().await.unwrap(), + ClientMsg::ReplayPosition { .. } + )); + match rx.recv().await.unwrap() { + ClientMsg::Event(ev) => assert!(matches!(*ev, Event::Bye)), + other => panic!("expected event, got {other:?}"), + } + assert!(matches!( + rx.recv().await.unwrap(), + ClientMsg::ReplayPosition { .. } + )); + assert!(matches!( + rx.recv().await.unwrap(), + ClientMsg::Disconnected { .. } + )); + + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn malformed_lines_are_skipped() { + let path = tmp_file("bad"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"this is not json\n").unwrap(); + let good = PersistedEntry { + ts_us: 0, + event: Event::Bye, + }; + let line = serde_json::to_string(&good).unwrap(); + f.write_all(line.as_bytes()).unwrap(); + f.write_all(b"\n").unwrap(); + drop(f); + + let (tx, mut rx) = unbounded_channel::(); + let _ctl = spawn(path.clone(), tx); + + let _ = rx.recv().await.unwrap(); + let _ = rx.recv().await.unwrap(); + match rx.recv().await.unwrap() { + ClientMsg::Event(ev) => assert!(matches!(*ev, Event::Bye)), + other => panic!("expected event, got {other:?}"), + } + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn missing_file_yields_disconnect_with_error() { + let path = tmp_file("does-not-exist"); + let (tx, mut rx) = unbounded_channel::(); + let _ctl = spawn(path, tx); + let _ = rx.recv().await.unwrap(); + match rx.recv().await.unwrap() { + ClientMsg::Disconnected { reason } => assert!(reason.contains("opening replay file")), + other => panic!("expected Disconnected, got {other:?}"), + } + } + + #[test] + fn next_speed_walks_steps_and_caps_at_max() { + assert_eq!(next_speed(0.25), 0.5); + assert_eq!(next_speed(1.0), 2.0); + assert_eq!(next_speed(8.0), 8.0); + assert_eq!(next_speed(0.3), 0.5); + } + + #[test] + fn prev_speed_walks_steps_and_floors_at_min() { + assert_eq!(prev_speed(8.0), 4.0); + assert_eq!(prev_speed(0.25), 0.25); + assert_eq!(prev_speed(3.0), 2.0); + } + + #[test] + fn scale_gap_speeds_up_inversely() { + let raw = Duration::from_millis(200); + assert_eq!(scale_gap(raw, 1.0), Duration::from_millis(200)); + assert_eq!(scale_gap(raw, 2.0), Duration::from_millis(100)); + assert_eq!(scale_gap(raw, 0.5), Duration::from_millis(400)); + assert_eq!(scale_gap(raw, 0.0), raw); + } + + #[tokio::test] + async fn controller_pauses_and_resumes_emission() { + let path = tmp_file("scrub"); + let entries = vec![ + PersistedEntry { + ts_us: 0, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 200_000, + event: Event::Bye, + }, + ]; + write_lines(&path, &entries); + + let (tx, mut rx) = unbounded_channel::(); + let ctl = spawn(path.clone(), tx); + + let _ = rx.recv().await; // Connecting + let _ = rx.recv().await; // Connected + let first = rx.recv().await.unwrap(); + assert!(matches!(first, ClientMsg::Event(ref ev) if matches!(**ev, Event::Snapshot(_)))); + let _ = rx.recv().await; // ReplayPosition + + ctl.pause(); + let res = tokio::time::timeout(Duration::from_millis(300), rx.recv()).await; + assert!(res.is_err(), "expected timeout while paused, got {res:?}"); + + ctl.resume(); + // Eventually a Bye arrives. Drain until we see it. + let deadline = tokio::time::Instant::now() + Duration::from_millis(800); + loop { + let msg = tokio::time::timeout_at(deadline, rx.recv()) + .await + .expect("timed out waiting for resume") + .unwrap(); + if let ClientMsg::Event(ev) = &msg + && matches!(**ev, Event::Bye) + { + break; + } + } + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn jump_backward_resets_and_replays() { + // Three snapshots 1 second apart, then a Bye. + let path = tmp_file("jump-back"); + let entries = vec![ + PersistedEntry { + ts_us: 0, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 1_000_000, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 2_000_000, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 3_000_000, + event: Event::Bye, + }, + ]; + write_lines(&path, &entries); + + let (tx, mut rx) = unbounded_channel::(); + let ctl = spawn(path.clone(), tx); + + // Drain Connecting + Connected + first event/position. + let _ = rx.recv().await; + let _ = rx.recv().await; + let _ = rx.recv().await; // first Event + let _ = rx.recv().await; // first ReplayPosition + + // Pause so we don't race the pacer, then jump back. + ctl.pause(); + tokio::time::sleep(Duration::from_millis(50)).await; + ctl.jump(-10); // way before 0 → clamps to first_ts_us + // Expect a ReplaySeek somewhere in the next few messages. + let mut saw_seek = false; + for _ in 0..6 { + match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await { + Ok(Some(ClientMsg::ReplaySeek)) => { + saw_seek = true; + break; + } + Ok(Some(_)) => continue, + _ => break, + } + } + assert!(saw_seek, "expected ReplaySeek after backward jump"); + + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn jump_forward_skips_events_without_seek() { + let path = tmp_file("jump-fwd"); + let entries = vec![ + PersistedEntry { + ts_us: 0, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 1_000_000, + event: Event::Snapshot(Snapshot::default()), + }, + PersistedEntry { + ts_us: 2_000_000, + event: Event::Bye, + }, + ]; + write_lines(&path, &entries); + + let (tx, mut rx) = unbounded_channel::(); + let ctl = spawn(path.clone(), tx); + + let _ = rx.recv().await; // Connecting + let _ = rx.recv().await; // Connected + let _ = rx.recv().await; // first Event + let _ = rx.recv().await; // first ReplayPosition + + ctl.pause(); + tokio::time::sleep(Duration::from_millis(50)).await; + ctl.jump(10); // beyond end → emits both remaining + + // Drain remaining messages and confirm no ReplaySeek appeared. + let mut saw_seek = false; + let deadline = tokio::time::Instant::now() + Duration::from_millis(400); + loop { + match tokio::time::timeout_at(deadline, rx.recv()).await { + Ok(Some(ClientMsg::ReplaySeek)) => { + saw_seek = true; + break; + } + Ok(Some(_)) => continue, + _ => break, + } + } + assert!(!saw_seek, "forward jump must not emit ReplaySeek"); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs new file mode 100644 index 00000000..16b0ef61 --- /dev/null +++ b/crates/rocm-dash-tui/src/skills.rs @@ -0,0 +1,594 @@ +//! Skills — declarative manifest + plan model for rocm-dash's auto-config / +//! auto-install mechanism. +//! +//! A **skill** is a TOML manifest (the external-team contribution contract): +//! `name`, `description`, optional `requires`, optional `detect` (how to tell if +//! it's already satisfied), and an ordered list of typed `steps`. This module is +//! the pure model: parse + validate + build an ordered plan + the +//! detect→config mapping for `auto-config-endpoint`. It has **no rig/reqwest** +//! dependency (the agent-tool wrappers live in `agent.rs`); execution + I/O live +//! in the `rocm` binary's `skills_cmd`. + +use serde::{Deserialize, Serialize}; + +/// Lemonade's default OpenAI-compatible endpoint + port. +pub const LEMONADE_PORT: u16 = 13305; +pub const LEMONADE_ENDPOINT: &str = "http://localhost:13305/v1"; +/// vLLM's conventional OpenAI-compatible endpoint + port. +pub const VLLM_PORT: u16 = 8000; +pub const VLLM_ENDPOINT: &str = "http://localhost:8000/v1"; + +/// One typed step in a skill manifest. Tagged by `type` in TOML. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Step { + /// A read-only idempotency/health check command. + Check { command: String }, + /// Run a command with args (the side-effecting step). + Run { + cmd: String, + #[serde(default)] + args: Vec, + }, + /// Download a file to a destination. + Download { url: String, dest: String }, + /// Write a key into the rocm-dash config. + WriteConfig { key: String, value: String }, +} + +impl Step { + /// One-line, human-readable rendering for a plan. + pub fn describe(&self) -> String { + match self { + Step::Check { command } => format!("check: {command}"), + Step::Run { cmd, args } => format!("run: {cmd} {}", args.join(" ")) + .trim_end() + .to_string(), + Step::Download { url, dest } => format!("download: {url} -> {dest}"), + Step::WriteConfig { key, value } => format!("write-config: {key} = {value}"), + } + } +} + +/// How to detect whether a skill is already satisfied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetectSpec { + pub kind: DetectKind, + pub target: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DetectKind { + /// `target` is a binary expected on PATH. + Binary, + /// `target` is an endpoint expected to be reachable. + Endpoint, +} + +/// A parsed, validated skill manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkillManifest { + pub name: String, + pub description: String, + #[serde(default)] + pub requires: Vec, + #[serde(default)] + pub detect: Option, + pub steps: Vec, +} + +/// Skill loading/validation errors. String-only at the boundary; never panics. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SkillError { + #[error("skill manifest parse error: {0}")] + Parse(String), + #[error("skill manifest invalid: {0}")] + Invalid(String), + #[error("unknown skill: {0}")] + Unknown(String), +} + +/// Parse + validate a TOML skill manifest. Clear errors, never panics. +pub fn parse_manifest(toml_str: &str) -> Result { + let manifest: SkillManifest = + toml::from_str(toml_str).map_err(|e| SkillError::Parse(e.to_string()))?; + validate(&manifest)?; + Ok(manifest) +} + +fn validate(m: &SkillManifest) -> Result<(), SkillError> { + if m.name.trim().is_empty() { + return Err(SkillError::Invalid("name must not be empty".to_string())); + } + if m.description.trim().is_empty() { + return Err(SkillError::Invalid( + "description must not be empty".to_string(), + )); + } + if m.steps.is_empty() { + return Err(SkillError::Invalid("steps must not be empty".to_string())); + } + Ok(()) +} + +/// Build an ordered, human-readable plan (the dry-run output). Pure. +pub fn build_plan(m: &SkillManifest) -> Vec { + m.steps + .iter() + .enumerate() + .map(|(i, s)| format!("{}. {}", i + 1, s.describe())) + .collect() +} + +// --------------------------------------------------------------------------- +// Built-in skills (compiled in). External teams can drop additional `.toml` +// manifests in the user skills dir; discovery merges them in (in the binary). +// --------------------------------------------------------------------------- + +/// install-lemonade: install AMD's Lemonade local LLM server from the official +/// **embeddable SDK** release (the `lemond` server + `lemonade` CLI), not pip. +/// The real apply path is special-cased + `--apply`-gated in the binary +/// (`apply_install_lemonade`): it queries the latest GitHub release, picks the +/// per-OS embeddable archive, downloads + extracts it into a rocm-owned dir, and +/// forces port 13305. `--dry-run` prints this plan only. The steps below are the +/// human-readable preview of that flow (the executor resolves the concrete OS/ +/// version/URL at apply time). No Python, no pip. +pub const INSTALL_LEMONADE_TOML: &str = r#" +name = "install-lemonade" +description = "Install Lemonade — AMD's local OpenAI-compatible LLM server — from the official embeddable SDK release (lemond server, no pip)." + +[detect] +kind = "endpoint" +target = "http://localhost:13305/v1" + +[[steps]] +type = "check" +command = "probe http://localhost:13305/v1 (skip if Lemonade already running)" + +[[steps]] +type = "run" +cmd = "curl" +args = ["-fsSL", "https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest"] + +[[steps]] +type = "run" +cmd = "tar" +args = ["-xf", "lemonade-embeddable--.{tar.gz|zip}", "-C", "~/.local/share/rocm-dash/lemonade//"] + +[[steps]] +type = "write_config" +key = "default_engine" +value = "lemonade" +"#; + +/// auto-config-endpoint: detect a running local LLM endpoint (Lemonade/vLLM) and +/// set `default_engine` for `serve`. Chat keeps the user's configured backend +/// (e.g. the LLM gateway); `tui.chat_url` is filled only when it is unset. +pub const AUTO_CONFIG_ENDPOINT_TOML: &str = r#" +name = "auto-config-endpoint" +description = "Detect a running local LLM endpoint (Lemonade/vLLM) and set default_engine for serve. Chat keeps your configured backend; chat_url is set only when unset." + +[detect] +kind = "endpoint" +target = "http://localhost:13305/v1" + +[[steps]] +type = "check" +command = "probe http://localhost:13305/v1 (Lemonade) then http://localhost:8000/v1 (vLLM)" + +[[steps]] +type = "write_config" +key = "default_engine" +value = "" + +[[steps]] +type = "write_config" +key = "tui.chat_url" +value = " (ONLY if tui.chat_url is unset; an existing chat backend is preserved)" +"#; + +/// The compiled-in skills. Malformed builtins are skipped (never panic) — they +/// are covered by tests so this stays exhaustive in practice. +pub fn builtin_skills() -> Vec { + [INSTALL_LEMONADE_TOML, AUTO_CONFIG_ENDPOINT_TOML] + .iter() + .filter_map(|t| parse_manifest(t).ok()) + .collect() +} + +/// Look up a built-in skill by name. +pub fn builtin_skill(name: &str) -> Option { + builtin_skills().into_iter().find(|s| s.name == name) +} + +// --------------------------------------------------------------------------- +// auto-config-endpoint: pure detect → config mapping. +// --------------------------------------------------------------------------- + +/// The config change `auto-config-endpoint` would apply for a detected endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigChange { + pub chat_url: String, + pub default_engine: String, +} + +/// Map a detected endpoint URL to the exact config change. Pure — no I/O. +/// `None` when nothing was detected. The engine is inferred from the port +/// (Lemonade 13305, vLLM 8000), defaulting to `local`. +pub fn auto_config_change(detected_endpoint: Option<&str>) -> Option { + let endpoint = detected_endpoint?; + let default_engine = if endpoint.contains(&LEMONADE_PORT.to_string()) { + "lemonade" + } else if endpoint.contains(&VLLM_PORT.to_string()) { + "vllm" + } else { + "local" + }; + Some(ConfigChange { + chat_url: endpoint.to_string(), + default_engine: default_engine.to_string(), + }) +} + +// --------------------------------------------------------------------------- +// Embeddable Lemonade SDK — pure asset selection. +// +// Lemonade is installed from the official **embeddable** release archives +// (`lemonade-embeddable--.{tar.gz|zip}`) published on GitHub, not +// from pip. These functions are PURE (no network/disk): the executor in the +// `rocm` binary does the curl/tar I/O and calls these to decide *what* to fetch. +// --------------------------------------------------------------------------- + +/// GitHub repo that publishes the Lemonade embeddable archives. +pub const LEMONADE_GITHUB_REPO: &str = "lemonade-sdk/lemonade"; +/// Pinned embeddable version used as the offline fallback when the GitHub +/// releases API is unreachable. Bump deliberately. (Latest at authoring time.) +pub const LEMONADE_EMBEDDABLE_FALLBACK_VERSION: &str = "10.6.0"; + +/// A selected embeddable archive for a host triple — enough to download, extract, +/// and locate the server binary. Pure data; no I/O. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddableArtifact { + /// Version without a leading `v`, e.g. `10.6.0`. + pub version: String, + /// The archive's `browser_download_url`. + pub url: String, + /// The archive file name, e.g. `lemonade-embeddable-10.6.0-ubuntu-x64.tar.gz`. + pub archive_name: String, + /// The unpacked server executable name (`lemond` / `lemond.exe`). + pub server_bin: String, +} + +/// Map a host (os, arch) — `std::env::consts::{OS, ARCH}` values — to the +/// embeddable asset's `` token + archive extension. `None` for an +/// unsupported triple (the release ships only these three). +fn embeddable_os_arch(os: &str, arch: &str) -> Option<(&'static str, &'static str)> { + match (os, arch) { + ("linux", "x86_64") => Some(("ubuntu-x64", "tar.gz")), + ("windows", "x86_64") => Some(("windows-x64", "zip")), + ("macos", "aarch64") => Some(("macos-arm64", "tar.gz")), + _ => None, + } +} + +/// The unpacked server executable name for an OS. +fn server_bin_for(os: &str) -> &'static str { + if os == "windows" { + "lemond.exe" + } else { + "lemond" + } +} + +/// Strip a leading `v` from a release tag (`v10.6.0` → `10.6.0`). +fn strip_v(tag: &str) -> &str { + tag.strip_prefix('v').unwrap_or(tag) +} + +/// PURE: build the canonical embeddable artifact for `(os, arch, version)` with no +/// network — the offline-fallback path. `None` for an unsupported triple. +pub fn embeddable_artifact(os: &str, arch: &str, version: &str) -> Option { + let (os_arch, ext) = embeddable_os_arch(os, arch)?; + let ver = strip_v(version); + let archive_name = format!("lemonade-embeddable-{ver}-{os_arch}.{ext}"); + let url = format!( + "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{ver}/{archive_name}" + ); + Some(EmbeddableArtifact { + version: ver.to_string(), + url, + archive_name, + server_bin: server_bin_for(os).to_string(), + }) +} + +/// PURE: pick the embeddable asset from a GitHub `releases/latest` JSON body for +/// the host `(os, arch)`. Reads `tag_name` + `assets[].{name, browser_download_url}` +/// and matches the `lemonade-embeddable-*-.` asset, skipping the +/// `.pkg`/`.rpm`/`.msi` decoys. Returns `None` for an unsupported triple, malformed +/// JSON, or a missing asset/url. Never panics. The release publishes no checksum +/// asset, so integrity rests on TLS + the official-org URL (see recon note). +pub fn pick_embeddable_asset( + release_json: &str, + os: &str, + arch: &str, +) -> Option { + let (os_arch, ext) = embeddable_os_arch(os, arch)?; + let value: serde_json::Value = serde_json::from_str(release_json).ok()?; + let obj = value.as_object()?; + let tag = obj.get("tag_name")?.as_str()?; + let version = strip_v(tag).to_string(); + let suffix = format!("-{os_arch}.{ext}"); + let assets = obj.get("assets")?.as_array()?; + for asset in assets { + let Some(name) = asset.get("name").and_then(|n| n.as_str()) else { + continue; + }; + if name.starts_with("lemonade-embeddable-") && name.ends_with(&suffix) { + let url = asset + .get("browser_download_url") + .and_then(|u| u.as_str()) + .unwrap_or_default(); + if url.is_empty() { + continue; + } + return Some(EmbeddableArtifact { + version, + url: url.to_string(), + archive_name: name.to_string(), + server_bin: server_bin_for(os).to_string(), + }); + } + } + None +} + +/// The config `auto-config-endpoint` would actually apply, after the +/// chat-precedence policy is taken into account. Either field being `None` means +/// "leave it unchanged". +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AutoConfigPlan { + /// New `tui.chat_url`, or `None` to leave the existing one untouched. + pub set_chat_url: Option, + /// New `default_engine`, or `None` when nothing was detected. + pub set_default_engine: Option, + /// A local endpoint was detected but `chat_url` was deliberately left alone + /// because a chat backend is already configured (drives user messaging). + pub kept_existing_chat: bool, +} + +impl AutoConfigPlan { + /// True when applying this plan would write nothing. + pub fn is_noop(&self) -> bool { + self.set_chat_url.is_none() && self.set_default_engine.is_none() + } +} + +/// Decide the config change for `auto-config-endpoint`, honoring chat precedence. +/// +/// Policy (see the chat-endpoint design notes): the chat backend defaults to the +/// user's configured endpoint — typically the LLM gateway — and is **never** +/// overwritten by local detection. Running a local engine is a `serve` concern, +/// not a reason to hijack the chatbot. Therefore: +/// - `default_engine` always follows the detected local engine (for `serve`). +/// - `chat_url` is filled **only when no chat backend is configured yet**; an +/// existing `chat_url` is preserved (`kept_existing_chat = true`). +/// +/// To point chat at a local engine, the user sets `tui.chat_url` explicitly — +/// detection will not do it for them. +pub fn auto_config_plan( + detected_endpoint: Option<&str>, + chat_url_configured: bool, +) -> AutoConfigPlan { + match auto_config_change(detected_endpoint) { + None => AutoConfigPlan::default(), + Some(change) => AutoConfigPlan { + set_chat_url: (!chat_url_configured).then_some(change.chat_url), + set_default_engine: Some(change.default_engine), + kept_existing_chat: chat_url_configured, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_manifest_builds_ordered_plan() { + let m = parse_manifest(INSTALL_LEMONADE_TOML).expect("valid manifest"); + assert_eq!(m.name, "install-lemonade"); + // Embeddable install needs no Python/pip toolchain. + assert!(m.requires.is_empty(), "no python3/pip requires"); + // Detection is now endpoint-based (the unpacked server answering on :13305), + // not a `lemonade` binary on PATH. + assert!(matches!( + m.detect, + Some(DetectSpec { + kind: DetectKind::Endpoint, + .. + }) + )); + let plan = build_plan(&m); + assert!(plan[0].starts_with("1. check:")); + // The plan describes the embeddable flow, never pip. + assert!(plan.iter().any(|l| l.contains("releases/latest"))); + assert!(plan.iter().any(|l| l.contains("lemonade-embeddable-"))); + // Guard the pivot: ban the deprecated `pip install lemonade-sdk` command. + // NB: the GitHub *org* is literally `lemonade-sdk/lemonade` (the URL we now + // download from), so a bare `lemonade-sdk` substring is legitimate — only + // the `pip install` invocation is forbidden. + assert!( + !INSTALL_LEMONADE_TOML.contains("pip install") + && !INSTALL_LEMONADE_TOML.contains(r#"cmd = "pip""#), + "install-lemonade TOML must not invoke pip" + ); + } + + // The captured real `releases/latest` body (Phase 1 fixture). + const RELEASE_FIXTURE: &str = include_str!("../tests/fixtures/lemonade_release_latest.json"); + + #[test] + fn pick_embeddable_asset_selects_per_triple() { + let linux = pick_embeddable_asset(RELEASE_FIXTURE, "linux", "x86_64").expect("linux"); + assert_eq!( + linux.archive_name, + "lemonade-embeddable-10.6.0-ubuntu-x64.tar.gz" + ); + assert_eq!(linux.version, "10.6.0"); + assert_eq!(linux.server_bin, "lemond"); + assert!( + linux + .url + .starts_with("https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/") + ); + + let win = pick_embeddable_asset(RELEASE_FIXTURE, "windows", "x86_64").expect("windows"); + assert_eq!( + win.archive_name, + "lemonade-embeddable-10.6.0-windows-x64.zip" + ); + assert_eq!(win.server_bin, "lemond.exe"); + + let mac = pick_embeddable_asset(RELEASE_FIXTURE, "macos", "aarch64").expect("macos"); + assert_eq!( + mac.archive_name, + "lemonade-embeddable-10.6.0-macos-arm64.tar.gz" + ); + assert_eq!(mac.server_bin, "lemond"); + } + + #[test] + fn pick_embeddable_asset_none_for_unsupported_triple() { + // Release ships only ubuntu-x64 / windows-x64 / macos-arm64. + assert!(pick_embeddable_asset(RELEASE_FIXTURE, "linux", "aarch64").is_none()); + assert!(pick_embeddable_asset(RELEASE_FIXTURE, "freebsd", "x86_64").is_none()); + } + + #[test] + fn pick_embeddable_asset_malformed_json_is_none_not_panic() { + for body in ["", "not json", "{}", "[1,2,3]", r#"{"tag_name":"v10.6.0"}"#] { + assert!( + pick_embeddable_asset(body, "linux", "x86_64").is_none(), + "body {body:?} must yield None" + ); + } + } + + #[test] + fn embeddable_artifact_builds_canonical_urls() { + let a = embeddable_artifact("linux", "x86_64", "10.6.0").expect("linux"); + assert_eq!( + a.url, + "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade-embeddable-10.6.0-ubuntu-x64.tar.gz" + ); + // Tolerates a leading `v` on the version input. + let b = embeddable_artifact("windows", "x86_64", "v10.6.0").expect("windows"); + assert_eq!( + b.url, + "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade-embeddable-10.6.0-windows-x64.zip" + ); + assert_eq!(b.server_bin, "lemond.exe"); + // Unsupported triple → None. + assert!(embeddable_artifact("linux", "aarch64", "10.6.0").is_none()); + // The fallback const resolves for the common host. + assert!( + embeddable_artifact("linux", "x86_64", LEMONADE_EMBEDDABLE_FALLBACK_VERSION).is_some() + ); + } + + #[test] + fn malformed_manifest_is_clear_error_not_panic() { + // Not valid TOML. + assert!(matches!( + parse_manifest("name = "), + Err(SkillError::Parse(_)) + )); + // Valid TOML but missing required fields → Invalid. + let err = parse_manifest("name = \"x\"\ndescription = \"y\"\nsteps = []\n").unwrap_err(); + assert_eq!( + err, + SkillError::Invalid("steps must not be empty".to_string()) + ); + // Empty name → Invalid. + assert!(matches!( + parse_manifest( + "name = \"\"\ndescription = \"d\"\n[[steps]]\ntype=\"check\"\ncommand=\"x\"\n" + ), + Err(SkillError::Invalid(_)) + )); + } + + #[test] + fn builtins_include_both_demo_skills() { + let names: Vec = builtin_skills().into_iter().map(|s| s.name).collect(); + assert!(names.contains(&"install-lemonade".to_string())); + assert!(names.contains(&"auto-config-endpoint".to_string())); + assert!(builtin_skill("install-lemonade").is_some()); + assert!(builtin_skill("nope").is_none()); + } + + #[test] + fn auto_config_change_maps_endpoint_to_config_immutably() { + // Lemonade port → lemonade engine. + let c = auto_config_change(Some(LEMONADE_ENDPOINT)).expect("change"); + assert_eq!(c.chat_url, LEMONADE_ENDPOINT); + assert_eq!(c.default_engine, "lemonade"); + // vLLM port → vllm engine. + let c = auto_config_change(Some(VLLM_ENDPOINT)).expect("change"); + assert_eq!(c.default_engine, "vllm"); + // Unknown port → local. + let c = auto_config_change(Some("http://localhost:9999/v1")).expect("change"); + assert_eq!(c.default_engine, "local"); + // Nothing detected → no change. + assert_eq!(auto_config_change(None), None); + } + + #[test] + fn auto_config_plan_never_clobbers_configured_chat() { + // A chat backend is already configured (e.g. the gateway): default_engine + // updates for serve, but chat_url is preserved. + let plan = auto_config_plan(Some(LEMONADE_ENDPOINT), true); + assert_eq!( + plan.set_chat_url, None, + "must not overwrite configured chat" + ); + assert_eq!(plan.set_default_engine.as_deref(), Some("lemonade")); + assert!(plan.kept_existing_chat); + assert!(!plan.is_noop()); + } + + #[test] + fn auto_config_plan_fills_chat_only_when_unset() { + // No chat backend configured → fill chat_url with the detected endpoint. + let plan = auto_config_plan(Some(VLLM_ENDPOINT), false); + assert_eq!(plan.set_chat_url.as_deref(), Some(VLLM_ENDPOINT)); + assert_eq!(plan.set_default_engine.as_deref(), Some("vllm")); + assert!(!plan.kept_existing_chat); + } + + #[test] + fn auto_config_plan_noop_when_nothing_detected() { + // Nothing running → write nothing at all, regardless of chat config. + assert!(auto_config_plan(None, true).is_noop()); + assert!(auto_config_plan(None, false).is_noop()); + } + + #[test] + fn auto_config_endpoint_manifest_parses() { + let m = parse_manifest(AUTO_CONFIG_ENDPOINT_TOML).expect("valid"); + assert_eq!(m.name, "auto-config-endpoint"); + assert!(matches!( + m.detect, + Some(DetectSpec { + kind: DetectKind::Endpoint, + .. + }) + )); + // It writes both config keys. + let plan = build_plan(&m); + assert!(plan.iter().any(|l| l.contains("tui.chat_url"))); + assert!(plan.iter().any(|l| l.contains("default_engine"))); + } +} diff --git a/crates/rocm-dash-tui/src/transport.rs b/crates/rocm-dash-tui/src/transport.rs new file mode 100644 index 00000000..bb68c958 --- /dev/null +++ b/crates/rocm-dash-tui/src/transport.rs @@ -0,0 +1,35 @@ +//! NDJSON helpers. (Mirror of the daemon's transport module — duplicated here so we +//! don't add a circular dep on the daemon crate. Refactor to a shared crate later.) + +use std::io; + +use serde::{Serialize, de::DeserializeOwned}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +pub async fn write_line(w: &mut W, value: &T) -> io::Result<()> +where + W: AsyncWriteExt + Unpin, + T: Serialize, +{ + let mut buf = + serde_json::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + buf.push(b'\n'); + w.write_all(&buf).await?; + w.flush().await?; + Ok(()) +} + +pub async fn read_line(r: &mut BufReader) -> io::Result> +where + R: tokio::io::AsyncRead + Unpin, + T: DeserializeOwned, +{ + let mut line = String::new(); + let n = r.read_line(&mut line).await?; + if n == 0 { + return Ok(None); + } + let value: T = serde_json::from_str(line.trim()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + Ok(Some(value)) +} diff --git a/crates/rocm-dash-tui/src/ui/approval.rs b/crates/rocm-dash-tui/src/ui/approval.rs new file mode 100644 index 00000000..b85024eb --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/approval.rs @@ -0,0 +1,218 @@ +//! Approval-gate seam (Phase 3 Wave 0). +//! +//! This is a **render + event seam ONLY**. The decision *logic* — what an +//! approval actually does (run a CLI command, enable full access, approve a +//! proposal) — stays CLI-side per the working agreement +//! (`rocm-cli-unification-working-agreements.md` §1). This module renders an +//! [`ApprovalRequest`] and reports the user's [`ApprovalVerdict`]; the caller +//! maps the verdict onto a CLI-side action. +//! +//! It must never gain a mutating capability and never touches the read-only +//! chat seam (`agent.rs:59-62`). +//! +//! Keymap mirrors the frozen rocm-cli `pending_approval` screen: Up/Down/Tab +//! move the cursor; `y` approves; `n` denies; Esc cancels; Enter performs the +//! highlighted choice. + +use crossterm::event::KeyCode; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; + +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// A request to approve or deny a CLI-side action. Carries display data only; +/// the actionable payload lives CLI-side, keyed by the caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalRequest { + pub title: String, + /// Lines describing what will run (the command, an explanation, a diff). + pub body: Vec, +} + +impl ApprovalRequest { + pub fn new(title: impl Into, body: Vec) -> Self { + Self { + title: title.into(), + body, + } + } +} + +/// Which button the cursor is on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ApprovalChoice { + #[default] + Approve, + Deny, +} + +impl ApprovalChoice { + pub fn toggle(self) -> Self { + match self { + ApprovalChoice::Approve => ApprovalChoice::Deny, + ApprovalChoice::Deny => ApprovalChoice::Approve, + } + } +} + +/// The user's decision. The caller decides what each verdict *means*. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalVerdict { + Approve, + Deny, + Cancel, +} + +/// Pure key handler. Returns the (possibly moved) cursor and an optional +/// verdict. No side effects — the caller routes the verdict CLI-side. +pub fn approval_key( + key: KeyCode, + choice: ApprovalChoice, +) -> (ApprovalChoice, Option) { + match key { + KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => (choice.toggle(), None), + KeyCode::Char('y') | KeyCode::Char('Y') => (choice, Some(ApprovalVerdict::Approve)), + KeyCode::Char('n') | KeyCode::Char('N') => (choice, Some(ApprovalVerdict::Deny)), + // Esc and `q` both cancel — `q` must never be silently swallowed while a + // modal is up, so the user is never trapped on any screen. + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + (choice, Some(ApprovalVerdict::Cancel)) + } + KeyCode::Enter => { + let verdict = match choice { + ApprovalChoice::Approve => ApprovalVerdict::Approve, + ApprovalChoice::Deny => ApprovalVerdict::Deny, + }; + (choice, Some(verdict)) + } + _ => (choice, None), + } +} + +/// Render the approval modal over `area`. +pub fn draw_approval( + f: &mut Frame, + area: Rect, + req: &ApprovalRequest, + choice: ApprovalChoice, + theme: &Theme, +) { + let popup = centered_rect(72, 70, 96, 24, area); + let inner = draw_popup_frame(f, popup, &format!("Review: {}", req.title), theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(2)]) + .split(inner); + + let body: Vec = req + .body + .iter() + .map(|l| Line::from(Span::styled(l.clone(), Style::default().fg(theme.fg)))) + .collect(); + f.render_widget(Paragraph::new(body).wrap(Wrap { trim: false }), rows[0]); + + f.render_widget(buttons_line(choice, theme), rows[1]); +} + +fn buttons_line<'a>(choice: ApprovalChoice, theme: &Theme) -> Paragraph<'a> { + let approve = button_span( + " Approve (y) ", + choice == ApprovalChoice::Approve, + theme.ok, + theme, + ); + let deny = button_span( + " Deny (n) ", + choice == ApprovalChoice::Deny, + theme.err, + theme, + ); + let help = Span::styled( + " Tab move · Enter confirm · Esc/q cancel", + Style::default().fg(theme.muted), + ); + Paragraph::new(Line::from(vec![approve, Span::raw(" "), deny, help])) +} + +fn button_span<'a>( + label: &'a str, + selected: bool, + accent: ratatui::style::Color, + theme: &Theme, +) -> Span<'a> { + if selected { + Span::styled( + label, + Style::default() + .bg(accent) + .fg(theme.bg) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::styled(label, Style::default().fg(theme.muted)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tab_toggles_choice_without_verdict() { + let (c, v) = approval_key(KeyCode::Tab, ApprovalChoice::Approve); + assert_eq!(c, ApprovalChoice::Deny); + assert!(v.is_none()); + let (c, v) = approval_key(KeyCode::Up, c); + assert_eq!(c, ApprovalChoice::Approve); + assert!(v.is_none()); + } + + #[test] + fn y_and_n_are_direct_verdicts() { + assert_eq!( + approval_key(KeyCode::Char('y'), ApprovalChoice::Deny).1, + Some(ApprovalVerdict::Approve) + ); + assert_eq!( + approval_key(KeyCode::Char('n'), ApprovalChoice::Approve).1, + Some(ApprovalVerdict::Deny) + ); + } + + #[test] + fn enter_confirms_highlighted_choice() { + assert_eq!( + approval_key(KeyCode::Enter, ApprovalChoice::Approve).1, + Some(ApprovalVerdict::Approve) + ); + assert_eq!( + approval_key(KeyCode::Enter, ApprovalChoice::Deny).1, + Some(ApprovalVerdict::Deny) + ); + } + + #[test] + fn esc_and_q_cancel() { + assert_eq!( + approval_key(KeyCode::Esc, ApprovalChoice::Approve).1, + Some(ApprovalVerdict::Cancel) + ); + // `q` must cancel too — never silently swallowed while the modal is up. + assert_eq!( + approval_key(KeyCode::Char('q'), ApprovalChoice::Approve).1, + Some(ApprovalVerdict::Cancel) + ); + assert_eq!( + approval_key(KeyCode::Char('Q'), ApprovalChoice::Deny).1, + Some(ApprovalVerdict::Cancel) + ); + } +} diff --git a/crates/rocm-dash-tui/src/ui/automations_manager.rs b/crates/rocm-dash-tui/src/ui/automations_manager.rs new file mode 100644 index 00000000..be355a94 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/automations_manager.rs @@ -0,0 +1,532 @@ +//! Automations manager overlay (Phase 3 Wave 3). +//! +//! Lists the background checks ("watchers", plumbed in from the bin which owns +//! `rocm-core`) and toggles them via the `rocm automations …` verbs: +//! +//! - **refresh** (`rocm automations list`) — read-only, straight to the job-bridge. +//! - **enable / disable** the selected watcher — mutating, routed through the +//! approval gate first. +//! +//! The Propose-mode proposal review/approval workflow (queued watcher proposals) +//! is a documented fast-follow; this overlay covers list + enable/disable, which +//! is what gates the watchers that generate those proposals. Zero +//! `std::thread::spawn`/`try_recv`. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// A flattened background-check entry for the list. Mirrors the bin's +/// `BuiltinWatcherSpec` (+ enabled/mode from config) with no `rocm-core` dep. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AutomationSummary { + /// Watcher id (what `rocm automations enable|disable ` takes). + pub id: String, + /// Human summary of what the check does. + pub summary: String, + /// Whether the check is currently enabled. + pub enabled: bool, + /// Effective mode label (observe/propose/contained). + pub mode: String, +} + +/// The mutating toggle verbs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutomationAction { + Enable, + Disable, +} + +impl AutomationAction { + fn verb(self) -> &'static str { + match self { + AutomationAction::Enable => "enable", + AutomationAction::Disable => "disable", + } + } + + fn job_id(self) -> &'static str { + match self { + AutomationAction::Enable => "automations-enable", + AutomationAction::Disable => "automations-disable", + } + } +} + +/// An approved-but-not-yet-run toggle. +#[derive(Debug, Clone)] +pub struct PendingAutomation { + pub action: AutomationAction, + pub cmd: String, + pub args: Vec, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. The watcher +/// list lives on `AppState` (plumbed from the bin), not here. +#[derive(Debug, Clone, Default)] +pub struct AutomationsManagerState { + pub selected: usize, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Handle a key while the overlay is open. `automations` is the plumbed list. +pub fn on_key( + am: &mut Option, + automations: &[AutomationSummary], + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(a) = am.as_mut() else { + return Vec::new(); + }; + + // 1) Approval modal has focus. + if let Some(pending) = a.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = a.approval.take() { + return spawn_toggle(a, jobs, pending.action, pending.cmd, pending.args); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => a.approval = None, + None => {} + } + return Vec::new(); + } + + // 2) A job is showing in the console. + if let Some(job_id) = a.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *am = None, + ConsoleOutcome::Dismissed => { + a.active_job = None; + a.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 3) List navigation + actions. + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *am = None, + KeyCode::Up | KeyCode::Char('k') => a.selected = a.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') if !automations.is_empty() => { + a.selected = (a.selected + 1).min(automations.len() - 1); + } + KeyCode::Char('l') => return spawn_refresh(a, jobs), + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(w) = automations.get(a.selected) { + let action = if w.enabled { + AutomationAction::Disable + } else { + AutomationAction::Enable + }; + let args = vec![ + "automations".to_string(), + action.verb().to_string(), + w.id.clone(), + ]; + stage_approval(a, action, &w.id, args); + } else { + a.message = Some("no automation selected".to_string()); + } + } + _ => {} + } + Vec::new() +} + +/// Stage an approval for a toggle (no job yet). +fn stage_approval( + a: &mut AutomationsManagerState, + action: AutomationAction, + watcher_id: &str, + args: Vec, +) { + let cmd = resolve_exe(); + let request = ApprovalRequest::new( + format!("{} background check “{}”", action.verb(), watcher_id), + vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + "This changes which background checks run on this machine.".to_string(), + ], + ); + a.message = None; + a.approval = Some(PendingAutomation { + action, + cmd, + args, + request, + choice: ApprovalChoice::default(), + }); +} + +/// Spawn the read-only `rocm automations list` refresh. +fn spawn_refresh(a: &mut AutomationsManagerState, jobs: &mut State) -> Vec { + let id = "automations-list".to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd: resolve_exe(), + args: vec!["automations".to_string(), "list".to_string()], + }); + if fx.is_empty() { + a.message = Some("an automations refresh is already running".to_string()); + return fx; + } + a.active_job = Some(id); + fx +} + +/// Spawn the approved toggle job. +fn spawn_toggle( + a: &mut AutomationsManagerState, + jobs: &mut State, + action: AutomationAction, + cmd: String, + args: Vec, +) -> Vec { + let id = action.job_id().to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args, + }); + if fx.is_empty() { + a.message = Some(format!("an {} job is already running", action.verb())); + return fx; + } + a.active_job = Some(id); + fx +} + +/// Render the overlay (list, or an approval/console on top). +pub fn draw_automations_manager( + f: &mut Frame, + area: Rect, + a: &AutomationsManagerState, + automations: &[AutomationSummary], + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &a.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(80, 70, 120, 22, area); + let inner = draw_popup_frame(f, popup, "Automations — background checks", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + if automations.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "No background checks available. Press l to refresh.", + Style::default().fg(theme.muted), + ))), + rows[0], + ); + } else { + let items: Vec = automations + .iter() + .map(|w| { + let (badge, badge_color) = if w.enabled { + ("● on ", theme.ok) + } else { + ("○ off", theme.muted) + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{badge} "), Style::default().fg(badge_color)), + Span::styled( + format!("{:<22}", trunc(&w.id, 22)), + Style::default().fg(theme.fg), + ), + Span::styled( + format!("{:<10}", trunc(&w.mode, 10)), + Style::default().fg(theme.accent), + ), + Span::styled(trunc(&w.summary, 54), Style::default().fg(theme.muted)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(a.selected.min(automations.len().saturating_sub(1)))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[0], &mut ls); + } + + let msg = a.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[1], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ select · Enter/Space toggle (needs approval) · l refresh · Esc close", + Style::default().fg(theme.muted), + ))), + rows[2], + ); + + if let Some(pending) = &a.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +fn trunc(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let keep: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{keep}…") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + fn automations() -> Vec { + vec![ + AutomationSummary { + id: "therock-update".into(), + summary: "Emit scheduled TheRock update reminders.".into(), + enabled: false, + mode: "observe".into(), + }, + AutomationSummary { + id: "server-recover".into(), + summary: "Restart failed managed services.".into(), + enabled: true, + mode: "propose".into(), + }, + ] + } + + #[test] + fn refresh_is_read_only_and_spawns_without_approval() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut am, &automations(), &mut jobs, key(KeyCode::Char('l'))); + assert_eq!(fx.len(), 1); + assert!(am.as_ref().unwrap().approval.is_none()); + assert_eq!( + am.as_ref().unwrap().active_job.as_deref(), + Some("automations-list") + ); + } + + #[test] + fn toggle_disabled_watcher_enables_via_gate() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + let ws = automations(); // row 0 is disabled → Enter should ENABLE + let fx = on_key(&mut am, &ws, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + let pending = am.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, AutomationAction::Enable); + assert_eq!( + pending.args, + vec!["automations", "enable", "therock-update"] + ); + let fx = on_key(&mut am, &ws, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!( + am.as_ref().unwrap().active_job.as_deref(), + Some("automations-enable") + ); + } + + #[test] + fn toggle_enabled_watcher_disables() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + let ws = automations(); + on_key(&mut am, &ws, &mut jobs, key(KeyCode::Down)); // row 1 is enabled + on_key(&mut am, &ws, &mut jobs, key(KeyCode::Char(' '))); + let pending = am.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, AutomationAction::Disable); + assert_eq!( + pending.args, + vec!["automations", "disable", "server-recover"] + ); + } + + #[test] + fn toggle_with_no_watchers_surfaces_message() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut am, &[], &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(am.as_ref().unwrap().approval.is_none()); + assert!( + am.as_ref() + .unwrap() + .message + .as_deref() + .unwrap_or("") + .contains("no automation selected") + ); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + let ws = automations(); + on_key(&mut am, &ws, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut am, &ws, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(am.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn navigation_clamps() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + let ws = automations(); + for _ in 0..10 { + on_key(&mut am, &ws, &mut jobs, key(KeyCode::Down)); + } + assert_eq!(am.as_ref().unwrap().selected, ws.len() - 1); + for _ in 0..10 { + on_key(&mut am, &ws, &mut jobs, key(KeyCode::Up)); + } + assert_eq!(am.as_ref().unwrap().selected, 0); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut am = Some(AutomationsManagerState::default()); + let mut jobs = State::default(); + on_key(&mut am, &[], &mut jobs, key(KeyCode::Char('l'))); + on_key(&mut am, &[], &mut jobs, key(KeyCode::Char('q'))); + assert!(am.is_none()); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + let mut jobs = State::default(); + let mut a1 = Some(AutomationsManagerState::default()); + on_key(&mut a1, &[], &mut jobs, key(KeyCode::Char('l'))); + assert_eq!( + a1.as_ref().unwrap().active_job.as_deref(), + Some("automations-list") + ); + let mut a2 = Some(AutomationsManagerState::default()); + let fx = on_key(&mut a2, &[], &mut jobs, key(KeyCode::Char('l'))); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = a2.as_ref().unwrap(); + assert!(s.active_job.is_none()); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn relaunch_toggle_while_running_surfaces_message_not_stale_console() { + // The mutating enable/disable job has the same no-op guard as refresh. + let mut jobs = State::default(); + let ws = automations(); + let mut a1 = Some(AutomationsManagerState::default()); // row 0 disabled → enable + on_key(&mut a1, &ws, &mut jobs, key(KeyCode::Enter)); // stage + on_key(&mut a1, &ws, &mut jobs, key(KeyCode::Char('y'))); // spawn enable + assert_eq!( + a1.as_ref().unwrap().active_job.as_deref(), + Some("automations-enable") + ); + // Fresh overlay, same enable while the prior job still runs. + let mut a2 = Some(AutomationsManagerState::default()); + on_key(&mut a2, &ws, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut a2, &ws, &mut jobs, key(KeyCode::Char('y'))); + assert!(fx.is_empty(), "no double-spawn for a running toggle id"); + let s = a2.as_ref().unwrap(); + assert!(s.active_job.is_none(), "must not point at the stale job"); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_lists_watchers_with_badges() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(120, 20); + let mut term = Terminal::new(backend).unwrap(); + let a = AutomationsManagerState::default(); + let ws = automations(); + let jobs = State::default(); + term.draw(|f| draw_automations_manager(f, f.area(), &a, &ws, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Automations")); + assert!(out.contains("therock-update")); + assert!(out.contains("server-recover")); + // The enabled/disabled badges actually render (per the function name). + assert!(out.contains("on"), "enabled watcher shows an on badge"); + assert!(out.contains("off"), "disabled watcher shows an off badge"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/bench.rs b/crates/rocm-dash-tui/src/ui/bench.rs new file mode 100644 index 00000000..96bf781e --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/bench.rs @@ -0,0 +1 @@ +//! Benchmark result table + filters. Stub. diff --git a/crates/rocm-dash-tui/src/ui/command_screen.rs b/crates/rocm-dash-tui/src/ui/command_screen.rs new file mode 100644 index 00000000..48b0e079 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/command_screen.rs @@ -0,0 +1,424 @@ +//! Command runner overlay (Phase 3 Wave 3). +//! +//! A general escape hatch: type any `rocm …` subcommand and run it through the +//! job-bridge. Because arbitrary subcommands can't be statically classified as +//! read-only, **every** freeform command routes through the approval gate first +//! — the user reviews the exact argv before anything runs. This is the general +//! job-bridge consumer that replaces the frozen `command_screen`. Zero +//! `std::thread::spawn`/`try_recv`. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// One stable console for the runner — a second command waits for the first. +const JOB_ID: &str = "command"; + +/// An approved-but-not-yet-run freeform command. +#[derive(Debug, Clone)] +pub struct PendingCommand { + pub cmd: String, + pub args: Vec, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct CommandScreenState { + pub input: String, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Parse the freeform input into argv (whitespace-split). The approval modal +/// shows the parsed argv so the user verifies it before running; quoting is a +/// documented fast-follow. +fn parse_args(input: &str) -> Vec { + input.split_whitespace().map(str::to_string).collect() +} + +/// Whether the argv looks like it carries (or sets) a secret — so the approval +/// modal can warn that a key passed as an argument would be exposed in the +/// process argv and the job log. Defends the env-only key invariant at the one +/// place the TUI accepts freeform text: the escape hatch never *stores* a key, +/// but it should not let one slip through unflagged. +fn looks_secret_bearing(args: &[String]) -> bool { + args.iter().any(|a| { + let a = a.to_ascii_lowercase(); + a.contains("key") || a.contains("token") || a.contains("secret") || a.contains("password") + }) +} + +/// Handle a key while the overlay is open. +pub fn on_key( + cs: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(c) = cs.as_mut() else { + return Vec::new(); + }; + + // 1) Approval modal has focus. + if let Some(pending) = c.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = c.approval.take() { + return spawn_command(c, jobs, pending.cmd, pending.args); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => c.approval = None, + None => {} + } + return Vec::new(); + } + + // 2) A job is showing in the console. + if let Some(job_id) = c.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *cs = None, + ConsoleOutcome::Dismissed => { + c.active_job = None; + c.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 3) Input editing. Esc closes; printable chars (incl. `q`) are captured + // into the command line — `q` is not a close key in a text field. + match key.code { + KeyCode::Esc => *cs = None, + KeyCode::Backspace => { + c.input.pop(); + } + KeyCode::Enter => return request_run(c), + KeyCode::Char(ch) => c.input.push(ch), + _ => {} + } + Vec::new() +} + +/// Validate the input and stage an approval (no job until approved). +fn request_run(c: &mut CommandScreenState) -> Vec { + let args = parse_args(&c.input); + if args.is_empty() { + c.message = Some("type a rocm subcommand to run".to_string()); + return Vec::new(); + } + let cmd = resolve_exe(); + let mut body = vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + "This runs the command above through ROCm CLI.".to_string(), + ]; + if looks_secret_bearing(&args) { + body.push(String::new()); + body.push( + "Warning: this looks like it carries a secret. Anything passed as an \ + argument is visible in the process list and the job log — prefer the \ + environment for API keys." + .to_string(), + ); + } + let request = ApprovalRequest::new("run command".to_string(), body); + c.message = None; + c.approval = Some(PendingCommand { + cmd, + args, + request, + choice: ApprovalChoice::default(), + }); + Vec::new() +} + +/// Spawn the approved command job. +fn spawn_command( + c: &mut CommandScreenState, + jobs: &mut State, + cmd: String, + args: Vec, +) -> Vec { + let fx = jobs.apply(StateEvent::StartJob { + id: JOB_ID.to_string(), + cmd, + args, + }); + if fx.is_empty() { + c.message = Some("a command is already running".to_string()); + return fx; + } + c.active_job = Some(JOB_ID.to_string()); + fx +} + +/// Render the overlay (input, or an approval/console on top). +pub fn draw_command_screen( + f: &mut Frame, + area: Rect, + c: &CommandScreenState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &c.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(72, 40, 100, 12, area); + let inner = draw_popup_frame(f, popup, "Run a command", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter a ROCm CLI subcommand (it runs through the approval gate):", + Style::default().fg(theme.muted), + ))), + rows[0], + ); + + let shown = if c.input.is_empty() { + "(e.g. doctor · services list · version)".to_string() + } else { + format!("rocm {}", c.input) + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + shown, + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ))), + rows[1], + ); + + let msg = c.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[2], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter run (needs approval) · Esc close", + Style::default().fg(theme.muted), + ))), + rows[4], + ); + + if let Some(pending) = &c.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + fn type_str(cs: &mut Option, jobs: &mut State, s: &str) { + for ch in s.chars() { + on_key(cs, jobs, key(KeyCode::Char(ch))); + } + } + + #[test] + fn empty_input_is_rejected() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut cs, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(cs.as_ref().unwrap().approval.is_none()); + assert!( + cs.as_ref() + .unwrap() + .message + .as_deref() + .unwrap_or("") + .contains("type a rocm subcommand") + ); + } + + #[test] + fn every_command_is_gated_then_spawns() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + // Even a read-only-looking command goes through approval (safe default). + type_str(&mut cs, &mut jobs, "doctor"); + let fx = on_key(&mut cs, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty(), "must not run before approval"); + let pending = cs.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.args, vec!["doctor"]); + assert!(jobs.jobs.is_empty()); + let fx = on_key(&mut cs, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!(cs.as_ref().unwrap().active_job.as_deref(), Some("command")); + } + + #[test] + fn args_are_whitespace_split() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + type_str(&mut cs, &mut jobs, "services list"); + on_key(&mut cs, &mut jobs, key(KeyCode::Enter)); + let pending = cs.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.args, vec!["services", "list"]); + } + + #[test] + fn q_is_typed_into_input_not_a_close_key() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + type_str(&mut cs, &mut jobs, "q"); + assert!(cs.is_some(), "q is a printable char in the input field"); + assert_eq!(cs.as_ref().unwrap().input, "q"); + } + + #[test] + fn esc_closes_when_idle() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + on_key(&mut cs, &mut jobs, key(KeyCode::Esc)); + assert!(cs.is_none()); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + type_str(&mut cs, &mut jobs, "version"); + on_key(&mut cs, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut cs, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(cs.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + type_str(&mut cs, &mut jobs, "doctor"); + on_key(&mut cs, &mut jobs, key(KeyCode::Enter)); // stage + on_key(&mut cs, &mut jobs, key(KeyCode::Char('y'))); // spawn + on_key(&mut cs, &mut jobs, key(KeyCode::Char('q'))); + assert!(cs.is_none(), "q closes the overlay while a job runs"); + } + + #[test] + fn secret_bearing_command_gets_a_warning_in_the_approval() { + let mut cs = Some(CommandScreenState::default()); + let mut jobs = State::default(); + type_str(&mut cs, &mut jobs, "config set-provider-key anthropic"); + on_key(&mut cs, &mut jobs, key(KeyCode::Enter)); + let pending = cs.as_ref().unwrap().approval.as_ref().unwrap(); + assert!( + pending + .request + .body + .iter() + .any(|l| l.contains("carries a secret")), + "secret-shaped argv must surface a warning" + ); + // A plain command does NOT get the warning. + let mut cs2 = Some(CommandScreenState::default()); + type_str(&mut cs2, &mut jobs, "doctor"); + on_key(&mut cs2, &mut jobs, key(KeyCode::Enter)); + let p2 = cs2.as_ref().unwrap().approval.as_ref().unwrap(); + assert!( + !p2.request + .body + .iter() + .any(|l| l.contains("carries a secret")) + ); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + let mut jobs = State::default(); + let mut c1 = Some(CommandScreenState::default()); + type_str(&mut c1, &mut jobs, "doctor"); + on_key(&mut c1, &mut jobs, key(KeyCode::Enter)); + on_key(&mut c1, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(c1.as_ref().unwrap().active_job.as_deref(), Some("command")); + // Fresh runner, another command while the first still runs. + let mut c2 = Some(CommandScreenState::default()); + type_str(&mut c2, &mut jobs, "version"); + on_key(&mut c2, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut c2, &mut jobs, key(KeyCode::Char('y'))); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = c2.as_ref().unwrap(); + assert!(s.active_job.is_none()); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_renders_prompt() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(104, 24); + let mut term = Terminal::new(backend).unwrap(); + let c = CommandScreenState { + input: "doctor".into(), + ..Default::default() + }; + let jobs = State::default(); + term.draw(|f| draw_command_screen(f, f.area(), &c, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Run a command")); + assert!(out.contains("rocm doctor")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/config_manager.rs b/crates/rocm-dash-tui/src/ui/config_manager.rs new file mode 100644 index 00000000..3ebec168 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/config_manager.rs @@ -0,0 +1,474 @@ +//! Config + provider manager overlay (Phase 3 Wave 3). +//! +//! Folds the frozen `config_manager` and `provider_manager` into one screen: +//! +//! - **Show config** (`rocm config show`) — read-only, straight to the job-bridge. +//! - **Enable / Disable** the selected assistant provider — mutating, routed +//! through the approval gate (`rocm config enable-provider|disable-provider`). +//! +//! **API keys are NEVER entered or stored here.** Per the chat invariant + D6, +//! the AMD LLM gateway key (and any provider key the chat surface uses) is +//! sourced from the environment ONLY — this overlay deliberately offers no +//! key-entry field and says so. Other config edits (default engine/runtime, +//! telemetry mode) are documented fast-follows. Zero `std::thread::spawn`/`try_recv`. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// Assistant providers (mirrors the bin's `Provider` value-enum). Fixed set, so +/// no plumbing is needed — these are not runtime-discovered data. +pub const PROVIDERS: &[&str] = &["local", "anthropic", "openai"]; + +/// Menu actions, in display order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigAction { + ShowConfig, + EnableProvider, + DisableProvider, +} + +pub const ACTIONS: &[ConfigAction] = &[ + ConfigAction::ShowConfig, + ConfigAction::EnableProvider, + ConfigAction::DisableProvider, +]; + +impl ConfigAction { + fn label(self) -> &'static str { + match self { + ConfigAction::ShowConfig => "Show saved config", + ConfigAction::EnableProvider => "Enable provider", + ConfigAction::DisableProvider => "Disable provider", + } + } + + fn is_mutating(self) -> bool { + !matches!(self, ConfigAction::ShowConfig) + } +} + +/// An approved-but-not-yet-run mutating config op. +#[derive(Debug, Clone)] +pub struct PendingConfig { + pub cmd: String, + pub args: Vec, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct ConfigManagerState { + pub action_sel: usize, + pub provider_sel: usize, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Handle a key while the overlay is open. +pub fn on_key( + cm: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(c) = cm.as_mut() else { + return Vec::new(); + }; + + // 1) Approval modal has focus. + if let Some(pending) = c.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = c.approval.take() { + return spawn_config(c, jobs, "config-provider", pending.cmd, pending.args); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => c.approval = None, + None => {} + } + return Vec::new(); + } + + // 2) A job is showing in the console. + if let Some(job_id) = c.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *cm = None, + ConsoleOutcome::Dismissed => { + c.active_job = None; + c.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 3) Menu navigation + actions. + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *cm = None, + KeyCode::Up | KeyCode::Char('k') => c.action_sel = c.action_sel.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + c.action_sel = (c.action_sel + 1).min(ACTIONS.len() - 1); + } + KeyCode::Left | KeyCode::Char('h') => c.provider_sel = c.provider_sel.saturating_sub(1), + KeyCode::Right | KeyCode::Char('l') => { + c.provider_sel = (c.provider_sel + 1).min(PROVIDERS.len() - 1); + } + KeyCode::Enter => return activate_selected(c, jobs), + _ => {} + } + Vec::new() +} + +/// Run the selected action: Show spawns immediately (read-only); provider +/// enable/disable stage an approval first. +fn activate_selected(c: &mut ConfigManagerState, jobs: &mut State) -> Vec { + let action = ACTIONS[c.action_sel.min(ACTIONS.len() - 1)]; + let provider = PROVIDERS[c.provider_sel.min(PROVIDERS.len() - 1)]; + let cmd = resolve_exe(); + match action { + ConfigAction::ShowConfig => { + let args = vec!["config".to_string(), "show".to_string()]; + spawn_config(c, jobs, "config-show", cmd, args) + } + ConfigAction::EnableProvider | ConfigAction::DisableProvider => { + let verb = if action == ConfigAction::EnableProvider { + "enable-provider" + } else { + "disable-provider" + }; + let args = vec!["config".to_string(), verb.to_string(), provider.to_string()]; + let request = ApprovalRequest::new( + format!("{} {}", action.label().to_lowercase(), provider), + vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + "This changes which assistant providers are enabled. No API key \ + is entered or stored — keys are read from the environment only." + .to_string(), + ], + ); + c.message = None; + c.approval = Some(PendingConfig { + cmd, + args, + request, + choice: ApprovalChoice::default(), + }); + Vec::new() + } + } +} + +/// Spawn a config job under `id`. +fn spawn_config( + c: &mut ConfigManagerState, + jobs: &mut State, + id: &str, + cmd: String, + args: Vec, +) -> Vec { + let fx = jobs.apply(StateEvent::StartJob { + id: id.to_string(), + cmd, + args, + }); + if fx.is_empty() { + c.message = Some("a config job is already running".to_string()); + return fx; + } + c.active_job = Some(id.to_string()); + fx +} + +/// Render the overlay (menu, or an approval/console on top). +pub fn draw_config_manager( + f: &mut Frame, + area: Rect, + c: &ConfigManagerState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &c.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(72, 64, 100, 18, area); + let inner = draw_popup_frame(f, popup, "Config & providers", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + // Provider selector (drives the enable/disable actions). + let provider = PROVIDERS[c.provider_sel.min(PROVIDERS.len() - 1)]; + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("provider: ", Style::default().fg(theme.muted)), + Span::styled( + provider.to_string(), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" (←→ change)", Style::default().fg(theme.muted)), + ])), + rows[0], + ); + + let items: Vec = ACTIONS + .iter() + .map(|a| { + let tag = if a.is_mutating() { + " (needs approval)" + } else { + "" + }; + ListItem::new(Line::from(vec![ + Span::styled(a.label().to_string(), Style::default().fg(theme.fg)), + Span::styled(tag, Style::default().fg(theme.warn)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(c.action_sel.min(ACTIONS.len() - 1))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[1], &mut ls); + + let msg = c.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[2], + ); + + // The env-only key notice is always visible — this overlay never takes keys. + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "API keys are read from the environment only — never entered here. \ + ↑↓ action · Enter run · Esc close", + Style::default().fg(theme.muted), + ))), + rows[3], + ); + + if let Some(pending) = &c.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn show_is_read_only_and_spawns_without_approval() { + let mut cm = Some(ConfigManagerState::default()); // action 0 = ShowConfig + let mut jobs = State::default(); + let fx = on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); + assert_eq!(fx.len(), 1); + assert!(cm.as_ref().unwrap().approval.is_none()); + assert_eq!( + cm.as_ref().unwrap().active_job.as_deref(), + Some("config-show") + ); + } + + #[test] + fn enable_provider_is_gated_then_spawns() { + let mut cm = Some(ConfigManagerState::default()); + let mut jobs = State::default(); + // action 1 = EnableProvider, provider 0 = local + cm.as_mut().unwrap().action_sel = 1; + let fx = on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + let pending = cm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.args, vec!["config", "enable-provider", "local"]); + assert!(jobs.jobs.is_empty()); + let fx = on_key(&mut cm, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!( + cm.as_ref().unwrap().active_job.as_deref(), + Some("config-provider") + ); + } + + #[test] + fn provider_cursor_selects_the_command_target() { + let mut cm = Some(ConfigManagerState::default()); + let mut jobs = State::default(); + cm.as_mut().unwrap().action_sel = 2; // DisableProvider + on_key(&mut cm, &mut jobs, key(KeyCode::Right)); // local → anthropic + on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); + let pending = cm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!( + pending.args, + vec!["config", "disable-provider", "anthropic"] + ); + } + + #[test] + fn approval_body_states_keys_are_env_only() { + let mut cm = Some(ConfigManagerState::default()); + let mut jobs = State::default(); + cm.as_mut().unwrap().action_sel = 1; + on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); + let pending = cm.as_ref().unwrap().approval.as_ref().unwrap(); + assert!( + pending + .request + .body + .iter() + .any(|l| l.contains("environment only")), + "approval must reaffirm the env-only key invariant" + ); + } + + #[test] + fn no_keycapture_path_exists() { + // Guard the invariant structurally: there is no field on the state that + // could hold an API key, and no action produces a `set-provider-key` + // argv. Drive every action and assert none mentions a key subcommand. + let mut jobs = State::default(); + for action_sel in 0..ACTIONS.len() { + for provider_sel in 0..PROVIDERS.len() { + let mut cm = Some(ConfigManagerState { + action_sel, + provider_sel, + ..Default::default() + }); + on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); + if let Some(p) = cm.as_ref().unwrap().approval.as_ref() { + assert!( + !p.args.iter().any(|a| a.contains("provider-key")), + "the TUI must never invoke set/clear-provider-key" + ); + } + // Reset any spawned read-only job so the next iter isn't blocked. + jobs = State::default(); + } + } + } + + #[test] + fn navigation_clamps() { + let mut cm = Some(ConfigManagerState::default()); + let mut jobs = State::default(); + for _ in 0..10 { + on_key(&mut cm, &mut jobs, key(KeyCode::Down)); + } + assert_eq!(cm.as_ref().unwrap().action_sel, ACTIONS.len() - 1); + for _ in 0..10 { + on_key(&mut cm, &mut jobs, key(KeyCode::Right)); + } + assert_eq!(cm.as_ref().unwrap().provider_sel, PROVIDERS.len() - 1); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut cm = Some(ConfigManagerState::default()); + let mut jobs = State::default(); + cm.as_mut().unwrap().action_sel = 1; + on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut cm, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(cm.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut cm = Some(ConfigManagerState::default()); + let mut jobs = State::default(); + on_key(&mut cm, &mut jobs, key(KeyCode::Enter)); // show spawns + on_key(&mut cm, &mut jobs, key(KeyCode::Char('q'))); + assert!(cm.is_none()); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + let mut jobs = State::default(); + let mut c1 = Some(ConfigManagerState::default()); + on_key(&mut c1, &mut jobs, key(KeyCode::Enter)); // show + assert_eq!( + c1.as_ref().unwrap().active_job.as_deref(), + Some("config-show") + ); + let mut c2 = Some(ConfigManagerState::default()); + let fx = on_key(&mut c2, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = c2.as_ref().unwrap(); + assert!(s.active_job.is_none()); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_lists_actions_and_env_only_notice() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(100, 18); + let mut term = Terminal::new(backend).unwrap(); + let c = ConfigManagerState::default(); + let jobs = State::default(); + term.draw(|f| draw_config_manager(f, f.area(), &c, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Config & providers")); + assert!(out.contains("Show saved config")); + assert!(out.contains("Enable provider")); + assert!(out.contains("environment only")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/core_bars.rs b/crates/rocm-dash-tui/src/ui/core_bars.rs new file mode 100644 index 00000000..465d70d3 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/core_bars.rs @@ -0,0 +1,125 @@ +//! Per-CPU-core vertical braille bars. +//! +//! Each core renders as one character column (2 dots wide) showing its current +//! utilization as a thermometer fill from the bottom. Designed for ≤ ~200 cores +//! visible in a terminal that is at least that wide. Cores that don't fit on +//! screen are dropped from the right edge. +//! +//! Inspired by btop's per-core mini bars. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::Widget; + +use crate::ui::gradient::lerp3_t; + +pub struct CoreBars<'a> { + values: &'a [f32], + max: f32, + style: Style, + gradient: Option<[Color; 3]>, +} + +impl<'a> CoreBars<'a> { + pub fn new(values: &'a [f32]) -> Self { + Self { + values, + max: 100.0, + style: Style::default(), + gradient: None, + } + } + + pub fn max(mut self, m: f32) -> Self { + self.max = if m > 0.0 { m } else { 1.0 }; + self + } + + pub fn style(mut self, s: Style) -> Self { + self.style = s; + self + } + + /// Color each bar by its fill ratio (low → stops[0], full → stops[2]). + /// When set, overrides `style.fg` per column. + pub fn gradient(mut self, start: Color, mid: Color, end: Color) -> Self { + self.gradient = Some([start, mid, end]); + self + } +} + +impl Widget for CoreBars<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 || self.values.is_empty() { + return; + } + let cols = (area.width as usize).min(self.values.len()); + let rows = area.height as usize; + let total_dot_rows = rows * 4; + + for cx in 0..cols { + let v = self.values[cx].clamp(0.0, self.max); + let lit = ((v / self.max) * total_dot_rows as f32).round() as usize; + let first_lit = total_dot_rows.saturating_sub(lit); + let style = match self.gradient { + Some(stops) if v > 0.0 => self.style.fg(lerp3_t(stops, (v / self.max) as f64)), + _ => self.style, + }; + for cy in 0..rows { + let row_top = cy * 4; + let mut mask = 0u8; + for i in 0..4 { + let dot_row = row_top + i; + if dot_row >= first_lit && dot_row < total_dot_rows { + mask |= 1 << i; + } + } + if mask == 0 { + continue; + } + // Use both left and right dot columns for a fuller bar look. + let s = braille_both_cols(mask).to_string(); + buf.set_string(area.x + cx as u16, area.y + cy as u16, &s, style); + } + } + } +} + +fn braille_both_cols(mask: u8) -> char { + let mut code = 0u32; + if mask & 0b0001 != 0 { + code |= 0x01 | 0x08; + } + if mask & 0b0010 != 0 { + code |= 0x02 | 0x10; + } + if mask & 0b0100 != 0 { + code |= 0x04 | 0x20; + } + if mask & 0b1000 != 0 { + code |= 0x40 | 0x80; + } + char::from_u32(0x2800 + code).expect("valid braille code point") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_value_fills_block() { + assert_eq!(braille_both_cols(0b1111), '⣿'); + } + + #[test] + fn zero_is_blank() { + assert_eq!(braille_both_cols(0), '\u{2800}'); + } + + #[test] + fn bottom_only_fills_bottom_dot_row() { + let c = braille_both_cols(0b1000); + assert_eq!(c as u32 - 0x2800, 0x40 | 0x80); + } +} diff --git a/crates/rocm-dash-tui/src/ui/doctor_manager.rs b/crates/rocm-dash-tui/src/ui/doctor_manager.rs new file mode 100644 index 00000000..a5ab0eaf --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/doctor_manager.rs @@ -0,0 +1,241 @@ +//! Doctor overlay (Phase 3 Wave 2). +//! +//! Runs `rocm doctor` — a read-only environment check — through the job-bridge +//! and shows its streamed output in the shared job console. Read-only, so it +//! needs no approval gate (the gate is for mutating actions only). This is the +//! read-only-report archetype every diagnostic screen reuses. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::exec::resolve_exe; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct DoctorManagerState { + /// In-flight (or just-finished) `rocm doctor` job id. + pub active_job: Option, +} + +/// Handle a key while the overlay is open. Mirrors the operational-screen seam. +pub fn on_key( + doctor: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(d) = doctor.as_mut() else { + return Vec::new(); + }; + + if let Some(job_id) = d.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *doctor = None, + ConsoleOutcome::Dismissed => d.active_job = None, + ConsoleOutcome::Unhandled => { + // `r` re-runs after a terminal result. + if key.code == KeyCode::Char('r') + && jobs.job(&job_id).map(|j| j.is_terminal()).unwrap_or(true) + { + d.active_job = None; + return run_doctor(d, jobs); + } + } + } + return Vec::new(); + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *doctor = None, + KeyCode::Enter | KeyCode::Char('r') => return run_doctor(d, jobs), + _ => {} + } + Vec::new() +} + +/// Spawn `rocm doctor` (read-only). A stable id replaces any prior console. +fn run_doctor(d: &mut DoctorManagerState, jobs: &mut State) -> Vec { + let cmd = resolve_exe(); + let id = "doctor".to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args: vec!["doctor".to_string()], + }); + // Doctor uses a single stable id, so a no-op (a prior run still going) + // means re-attach to that same console — intentional, unlike the + // distinct-id screens (serve/engine/update) where a no-op surfaces an + // "already running" message instead. Either way `active_job` points at the + // live job, never a stale one. + d.active_job = Some(id); + fx +} + +/// Render the overlay (intro card, or the job console once running). +pub fn draw_doctor_manager( + f: &mut Frame, + area: Rect, + d: &DoctorManagerState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &d.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(70, 50, 90, 14, area); + let inner = draw_popup_frame(f, popup, "Doctor — environment check", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(inner); + + f.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "Checks this machine's GPU, ROCm install, engines, and folders.", + Style::default().fg(theme.fg), + )), + Line::from(""), + Line::from(Span::styled( + "Read-only — nothing is changed.", + Style::default().fg(theme.muted), + )), + Line::from(""), + Line::from(Span::styled( + " [ Enter: run `rocm doctor` ] ", + Style::default() + .bg(theme.accent) + .fg(theme.bg) + .add_modifier(Modifier::BOLD), + )), + ]), + rows[0], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter/r run · Esc close", + Style::default().fg(theme.muted), + ))), + rows[1], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn enter_runs_doctor_read_only_no_approval() { + let mut d = Some(DoctorManagerState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut d, &mut jobs, key(KeyCode::Enter)); + assert_eq!(fx.len(), 1, "spawns one job, no approval step"); + assert!(matches!(fx[0], SideEffect::SpawnJob { .. })); + assert_eq!(d.as_ref().unwrap().active_job.as_deref(), Some("doctor")); + } + + #[test] + fn esc_closes_when_idle() { + let mut d = Some(DoctorManagerState::default()); + let mut jobs = State::default(); + on_key(&mut d, &mut jobs, key(KeyCode::Esc)); + assert!(d.is_none()); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut d = Some(DoctorManagerState::default()); + let mut jobs = State::default(); + on_key(&mut d, &mut jobs, key(KeyCode::Enter)); + on_key(&mut d, &mut jobs, key(KeyCode::Char('q'))); + assert!(d.is_none()); + } + + #[test] + fn r_reruns_after_a_terminal_result() { + let mut d = Some(DoctorManagerState::default()); + let mut jobs = State::default(); + on_key(&mut d, &mut jobs, key(KeyCode::Enter)); // first run + jobs.apply(StateEvent::JobDone { + id: "doctor".into(), + code: 0, + }); + // `r` on a terminal job re-runs (spawns again). + let fx = on_key(&mut d, &mut jobs, key(KeyCode::Char('r'))); + assert_eq!(fx.len(), 1, "r re-runs after a terminal result"); + assert!(matches!(fx[0], SideEffect::SpawnJob { .. })); + assert_eq!(d.as_ref().unwrap().active_job.as_deref(), Some("doctor")); + } + + #[test] + fn r_at_idle_runs_doctor() { + let mut d = Some(DoctorManagerState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut d, &mut jobs, key(KeyCode::Char('r'))); + assert_eq!(fx.len(), 1); + assert_eq!(d.as_ref().unwrap().active_job.as_deref(), Some("doctor")); + } + + #[test] + fn esc_dismisses_console_only_when_terminal() { + let mut d = Some(DoctorManagerState::default()); + let mut jobs = State::default(); + on_key(&mut d, &mut jobs, key(KeyCode::Enter)); + // Running: Esc does not dismiss. + on_key(&mut d, &mut jobs, key(KeyCode::Esc)); + assert!(d.as_ref().unwrap().active_job.is_some()); + // Terminal: Esc returns to the intro card. + jobs.apply(StateEvent::JobDone { + id: "doctor".into(), + code: 0, + }); + on_key(&mut d, &mut jobs, key(KeyCode::Esc)); + assert!(d.as_ref().unwrap().active_job.is_none()); + assert!(d.is_some()); + } + + #[test] + fn snapshot_intro_then_console() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(100, 20); + let mut term = Terminal::new(backend).unwrap(); + let d = DoctorManagerState::default(); + let jobs = State::default(); + term.draw(|f| draw_doctor_manager(f, f.area(), &d, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Doctor")); + assert!(out.contains("rocm doctor")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/engine_manager.rs b/crates/rocm-dash-tui/src/ui/engine_manager.rs new file mode 100644 index 00000000..3adeb089 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/engine_manager.rs @@ -0,0 +1,466 @@ +//! Engine manager overlay (Phase 3 Wave 1). +//! +//! Lists the serving engines ROCm CLI knows about and runs use/install/reinstall +//! **through the approval gate and the job-bridge** — never inline. Installs +//! stream their output into the shared job console (the Wave-0 `running_job` +//! replacement). This is the third operational screen on the proven primitives; +//! it feeds the serve wizard (which engine to launch a model with). +//! +//! The approval *decision* is the user's, captured by the render+event seam; the +//! CLI owns the actual mutation (`rocm config set-default-engine` / +//! `rocm engines install [--reinstall]`) — the read-only chat invariant stands. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// Engine catalog — names + descriptions mirror `apps/rocm` `engine_inventory()`. +/// Kept TUI-local (a stable, small list) so this layer needs no `rocm-core` dep. +pub const ENGINE_CATALOG: &[(&str, &str)] = &[ + ( + "lemonade", + "default embedded Lemonade server with ROCm llama.cpp backend", + ), + ("pytorch", "TheRock PyTorch local serving engine"), + ("llama.cpp", "external GGUF serving engine for llama-server"), + ( + "vllm", + "Linux/WSL ROCm GPU serving engine through external vLLM", + ), + ( + "sglang", + "Linux/WSL ROCm GPU serving engine through external SGLang", + ), + ( + "atom", + "Linux/WSL ROCm GPU serving engine through external ATOM Python", + ), +]; + +/// A lifecycle operation on an engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EngineAction { + /// Set this engine as the default (`config set-default-engine`). + Use, + /// Install it into the managed engine folder (`engines install`). + Install, + /// Reinstall even if present (`engines install --reinstall`). + Reinstall, +} + +impl EngineAction { + pub fn verb(self) -> &'static str { + match self { + EngineAction::Use => "use", + EngineAction::Install => "install", + EngineAction::Reinstall => "reinstall", + } + } + + /// The `rocm` argv (after the binary) for this action on `engine`. + fn args(self, engine: &str) -> Vec { + match self { + EngineAction::Use => vec![ + "config".into(), + "set-default-engine".into(), + engine.to_string(), + ], + EngineAction::Install => vec!["engines".into(), "install".into(), engine.to_string()], + EngineAction::Reinstall => vec![ + "engines".into(), + "install".into(), + engine.to_string(), + "--reinstall".into(), + ], + } + } +} + +/// An approved-but-not-yet-run engine op. +#[derive(Debug, Clone)] +pub struct PendingEngineOp { + pub action: EngineAction, + pub engine: String, + /// Resolved `rocm` binary (captured at approval time). + pub cmd: String, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct EngineManagerState { + pub selected: usize, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Handle a key while the overlay is open. Mirrors the services-manager seam. +pub fn on_key( + engines: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(em) = engines.as_mut() else { + return Vec::new(); + }; + + // 1) Approval modal has focus. + if let Some(pending) = em.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = em.approval.take() { + return spawn_engine_op(em, jobs, pending); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => em.approval = None, + None => {} + } + return Vec::new(); + } + + // 2) A job is showing in the console. + if let Some(job_id) = em.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *engines = None, + ConsoleOutcome::Dismissed => { + em.active_job = None; + // Clear any stale "already running" notice on return to the list. + em.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 3) List navigation + action requests. + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *engines = None, + KeyCode::Up | KeyCode::Char('k') => em.selected = em.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + em.selected = (em.selected + 1).min(ENGINE_CATALOG.len() - 1); + } + KeyCode::Char('u') => request_op(em, EngineAction::Use), + KeyCode::Char('i') => request_op(em, EngineAction::Install), + KeyCode::Char('r') => request_op(em, EngineAction::Reinstall), + _ => {} + } + Vec::new() +} + +/// Stage an approval for the selected engine + action. +fn request_op(em: &mut EngineManagerState, action: EngineAction) { + let Some((name, _desc)) = ENGINE_CATALOG.get(em.selected.min(ENGINE_CATALOG.len() - 1)) else { + return; + }; + let cmd = resolve_exe(); + let args = action.args(name); + let explain = match action { + EngineAction::Use => "Sets the default serving engine for ROCm CLI.", + EngineAction::Install => "Installs the engine into the managed engine folder (streams).", + EngineAction::Reinstall => "Reinstalls the engine even if it already exists (streams).", + }; + let request = ApprovalRequest::new( + format!("{} engine “{}”", action.verb(), name), + vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + explain.to_string(), + ], + ); + em.message = None; + em.approval = Some(PendingEngineOp { + action, + engine: (*name).to_string(), + cmd, + request, + choice: ApprovalChoice::default(), + }); +} + +/// Launch the approved engine op as a background job. +fn spawn_engine_op( + em: &mut EngineManagerState, + jobs: &mut State, + pending: PendingEngineOp, +) -> Vec { + // Sanitize the engine name for the job id (e.g. `llama.cpp` → `llama-cpp`). + let key: String = pending + .engine + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let id = format!("engine-{}-{key}", pending.action.verb()); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd: pending.cmd, + args: pending.action.args(&pending.engine), + }); + if fx.is_empty() { + em.message = Some(format!( + "a {} job for “{}” is already running", + pending.action.verb(), + pending.engine + )); + return fx; + } + em.active_job = Some(id); + fx +} + +/// Render the overlay (list, or the approval modal, or the job console). +pub fn draw_engine_manager( + f: &mut Frame, + area: Rect, + em: &EngineManagerState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &em.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(80, 76, 120, 26, area); + let inner = draw_popup_frame(f, popup, "Engines — serving backends", theme); + if inner.height == 0 { + return; + } + + let body = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let items: Vec = ENGINE_CATALOG + .iter() + .map(|(name, desc)| { + ListItem::new(Line::from(vec![ + Span::styled(format!("{name:<12}"), Style::default().fg(theme.accent)), + Span::styled((*desc).to_string(), Style::default().fg(theme.muted)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(em.selected.min(ENGINE_CATALOG.len() - 1))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, body[0], &mut ls); + + let msg = em.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + body[1], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ select · u use · i install · r reinstall · Esc close", + Style::default().fg(theme.muted), + ))), + body[2], + ); + + if let Some(pending) = &em.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn use_action_builds_config_set_default_engine() { + assert_eq!( + EngineAction::Use.args("lemonade"), + vec!["config", "set-default-engine", "lemonade"] + ); + } + + #[test] + fn install_and_reinstall_args() { + assert_eq!( + EngineAction::Install.args("vllm"), + vec!["engines", "install", "vllm"] + ); + assert_eq!( + EngineAction::Reinstall.args("vllm"), + vec!["engines", "install", "vllm", "--reinstall"] + ); + } + + #[test] + fn navigation_clamps_to_catalog() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + for _ in 0..ENGINE_CATALOG.len() + 3 { + on_key(&mut em, &mut jobs, key(KeyCode::Down)); + } + assert_eq!(em.as_ref().unwrap().selected, ENGINE_CATALOG.len() - 1); + for _ in 0..ENGINE_CATALOG.len() + 3 { + on_key(&mut em, &mut jobs, key(KeyCode::Up)); + } + assert_eq!(em.as_ref().unwrap().selected, 0); + } + + #[test] + fn install_requires_approval_then_spawns_job() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + // 'i' on the first engine (lemonade) stages approval — NO job yet. + let fx = on_key(&mut em, &mut jobs, key(KeyCode::Char('i'))); + assert!(fx.is_empty(), "install must not run before approval"); + assert!(em.as_ref().unwrap().approval.is_some()); + assert!(jobs.jobs.is_empty()); + + // Approve → one SpawnJob, job registered, console active. + let fx = on_key(&mut em, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert!(matches!(fx[0], SideEffect::SpawnJob { .. })); + let s = em.as_ref().unwrap(); + assert!(s.approval.is_none()); + assert_eq!(s.active_job.as_deref(), Some("engine-install-lemonade")); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn job_id_sanitizes_dotted_engine_name() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + // Select llama.cpp (index 2). + em.as_mut().unwrap().selected = 2; + on_key(&mut em, &mut jobs, key(KeyCode::Char('r'))); + on_key(&mut em, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!( + em.as_ref().unwrap().active_job.as_deref(), + Some("engine-reinstall-llama-cpp") + ); + } + + #[test] + fn relaunch_while_prior_job_running_surfaces_message() { + // The reducer no-ops a StartJob for a still-running id. spawn_engine_op + // must NOT set active_job; it surfaces a message instead. + let mut jobs = State::default(); + let mut em1 = Some(EngineManagerState::default()); + on_key(&mut em1, &mut jobs, key(KeyCode::Char('i'))); // lemonade install + on_key(&mut em1, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!( + em1.as_ref().unwrap().active_job.as_deref(), + Some("engine-install-lemonade") + ); + + // Fresh overlay, same engine+action while the prior job still runs. + let mut em2 = Some(EngineManagerState::default()); + on_key(&mut em2, &mut jobs, key(KeyCode::Char('i'))); + let fx = on_key(&mut em2, &mut jobs, key(KeyCode::Char('y'))); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = em2.as_ref().unwrap(); + assert!(s.active_job.is_none(), "must not point at the stale job"); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + on_key(&mut em, &mut jobs, key(KeyCode::Char('u'))); + let fx = on_key(&mut em, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(em.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn esc_closes_when_idle() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + on_key(&mut em, &mut jobs, key(KeyCode::Esc)); + assert!(em.is_none()); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + on_key(&mut em, &mut jobs, key(KeyCode::Char('i'))); + on_key(&mut em, &mut jobs, key(KeyCode::Char('y'))); + assert!(em.as_ref().unwrap().active_job.is_some()); + on_key(&mut em, &mut jobs, key(KeyCode::Char('q'))); + assert!(em.is_none(), "q must close the overlay even mid-job"); + } + + fn render(em: &EngineManagerState, jobs: &State) -> String { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(124, 28); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw_engine_manager(f, f.area(), em, jobs, &theme)) + .unwrap(); + let buf = term.backend().buffer().clone(); + buf.content().iter().map(|c| c.symbol()).collect() + } + + #[test] + fn snapshot_lists_engines() { + let em = EngineManagerState::default(); + let out = render(&em, &State::default()); + assert!(out.contains("Engines"), "titled overlay"); + assert!(out.contains("lemonade"), "engine listed"); + assert!(out.contains("vllm"), "engine listed"); + assert!(out.contains("i install"), "action hints"); + } + + #[test] + fn snapshot_shows_approval_modal_on_use() { + let mut em = Some(EngineManagerState::default()); + let mut jobs = State::default(); + on_key(&mut em, &mut jobs, key(KeyCode::Char('u'))); + let out = render(em.as_ref().unwrap(), &jobs); + assert!(out.contains("Review:"), "approval modal shown"); + assert!(out.contains("use engine"), "describes the gated action"); + assert!(out.contains("Approve"), "approve button present"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/exec.rs b/crates/rocm-dash-tui/src/ui/exec.rs new file mode 100644 index 00000000..4fad57d0 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/exec.rs @@ -0,0 +1,36 @@ +//! Shared helpers for launching `rocm` sub-commands from operational screens +//! (Phase 3 Wave 1). Every screen that routes a mutating action through the +//! approval gate + job-bridge resolves the binary the same way, so the logic +//! lives here once instead of being re-implemented per screen. + +/// The `rocm` binary to invoke: this process's own path (so an in-tree dev +/// build calls itself), or the bare name `rocm` (PATH lookup) when +/// `current_exe()` is unavailable — never a silent no-op. +pub fn resolve_exe() -> String { + std::env::current_exe() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "rocm".to_string()) +} + +/// Short, human-readable basename of a resolved command, for approval previews. +pub fn exe_label(cmd: &str) -> &str { + cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exe_label_strips_unix_and_windows_paths() { + assert_eq!(exe_label("/usr/local/bin/rocm"), "rocm"); + assert_eq!(exe_label("C:\\tools\\rocm.exe"), "rocm.exe"); + assert_eq!(exe_label("rocm"), "rocm"); + } + + #[test] + fn resolve_exe_is_never_empty() { + assert!(!resolve_exe().is_empty()); + } +} diff --git a/crates/rocm-dash-tui/src/ui/folder_browser.rs b/crates/rocm-dash-tui/src/ui/folder_browser.rs new file mode 100644 index 00000000..5988aecd --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/folder_browser.rs @@ -0,0 +1,373 @@ +//! Reusable folder browser (Phase 3 Wave 0). +//! +//! A shared input primitive: pick (or create-under) a directory. Dependency of +//! serve / install / runtime / onboarding. Models the frozen rocm-cli +//! `folder_browser` entry kinds (`UseCurrent`, `NewChild`, `Parent`, +//! `Directory`) without the per-screen re-implementation. +//! +//! Navigation/selection is pure and unit-testable; only [`build_entries`] +//! touches the filesystem (tolerantly — an unreadable dir yields no children). + +use std::path::{Path, PathBuf}; + +use crossterm::event::KeyCode; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// What a row in the browser represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FolderEntryKind { + /// Choose the directory currently being browsed. + UseCurrent, + /// Create-and-choose a new child directory (CLI side performs the mkdir). + NewChild, + /// Ascend to the parent directory. + Parent, + /// Descend into a child directory. + Directory, +} + +/// One selectable row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FolderEntry { + pub label: String, + pub path: PathBuf, + pub kind: FolderEntryKind, +} + +/// The result of handling a key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FolderOutcome { + /// Key consumed, still browsing. + None, + /// The browser navigated to a new directory (entries refreshed). + Navigated, + /// The user chose `path` (UseCurrent or NewChild). The caller owns it. + Chosen(PathBuf), + /// The user dismissed the browser. + Cancelled, +} + +/// Folder browser state. Construct with [`FolderBrowser::new`]; drive with +/// [`FolderBrowser::on_key`]. +#[derive(Debug, Clone)] +pub struct FolderBrowser { + pub title: String, + pub current_dir: PathBuf, + pub entries: Vec, + pub selected: usize, +} + +impl FolderBrowser { + /// Open the browser rooted at `start`, listing its contents. + pub fn new(title: impl Into, start: PathBuf) -> Self { + let mut b = Self { + title: title.into(), + current_dir: start, + entries: Vec::new(), + selected: 0, + }; + b.refresh(); + b + } + + /// Re-list `current_dir` and clamp the cursor. + pub fn refresh(&mut self) { + self.entries = build_entries(&self.current_dir); + if self.selected >= self.entries.len() { + self.selected = self.entries.len().saturating_sub(1); + } + } + + /// Move the cursor by `delta`, clamped to the entry list. + pub fn move_sel(&mut self, delta: isize) { + if self.entries.is_empty() { + return; + } + let max = self.entries.len() as isize - 1; + self.selected = (self.selected as isize + delta).clamp(0, max) as usize; + } + + fn navigate_to(&mut self, dir: PathBuf) { + self.current_dir = dir; + self.selected = 0; + self.refresh(); + } + + /// Pure-ish key handler. Filesystem is only touched on navigation refresh. + pub fn on_key(&mut self, key: KeyCode) -> FolderOutcome { + match key { + KeyCode::Up | KeyCode::BackTab => { + self.move_sel(-1); + FolderOutcome::None + } + KeyCode::Down | KeyCode::Tab => { + self.move_sel(1); + FolderOutcome::None + } + KeyCode::Home => { + self.selected = 0; + FolderOutcome::None + } + KeyCode::End => { + self.selected = self.entries.len().saturating_sub(1); + FolderOutcome::None + } + KeyCode::Left | KeyCode::Backspace => { + if let Some(parent) = self.current_dir.parent() { + self.navigate_to(parent.to_path_buf()); + FolderOutcome::Navigated + } else { + FolderOutcome::None + } + } + KeyCode::Esc => FolderOutcome::Cancelled, + KeyCode::Enter => self.activate(), + _ => FolderOutcome::None, + } + } + + /// Act on the selected entry. + fn activate(&mut self) -> FolderOutcome { + let Some(entry) = self.entries.get(self.selected).cloned() else { + return FolderOutcome::None; + }; + match entry.kind { + FolderEntryKind::UseCurrent | FolderEntryKind::NewChild => { + FolderOutcome::Chosen(entry.path) + } + FolderEntryKind::Parent | FolderEntryKind::Directory => { + self.navigate_to(entry.path); + FolderOutcome::Navigated + } + } + } +} + +/// Build the row list for `dir`: `[Use this folder]`, `[..]` (if a parent +/// exists), the child directories (sorted), then `[+ new folder]`. +pub fn build_entries(dir: &Path) -> Vec { + let mut entries = vec![FolderEntry { + label: "[ use this folder ]".to_string(), + path: dir.to_path_buf(), + kind: FolderEntryKind::UseCurrent, + }]; + + if let Some(parent) = dir.parent() { + entries.push(FolderEntry { + label: "..".to_string(), + path: parent.to_path_buf(), + kind: FolderEntryKind::Parent, + }); + } + + let mut dirs: Vec = Vec::new(); + if let Ok(read) = std::fs::read_dir(dir) { + for ent in read.flatten() { + let path = ent.path(); + if path.is_dir() { + dirs.push(path); + } + } + } + dirs.sort(); + for path in dirs { + let label = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); + entries.push(FolderEntry { + label: format!("{label}/"), + path, + kind: FolderEntryKind::Directory, + }); + } + + entries.push(FolderEntry { + label: "[ + new folder ]".to_string(), + path: dir.join("new-folder"), + kind: FolderEntryKind::NewChild, + }); + + entries +} + +/// Render the browser over `area`. +pub fn draw_folder_browser(f: &mut Frame, area: Rect, fb: &FolderBrowser, theme: &Theme) { + let popup = centered_rect(80, 80, 110, 30, area); + let inner = draw_popup_frame(f, popup, &fb.title, theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + fb.current_dir.display().to_string(), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ))), + rows[0], + ); + + let items: Vec = fb + .entries + .iter() + .map(|e| { + let color = match e.kind { + FolderEntryKind::UseCurrent => theme.ok, + FolderEntryKind::NewChild => theme.warn, + FolderEntryKind::Parent => theme.muted, + FolderEntryKind::Directory => theme.fg, + }; + ListItem::new(Line::from(Span::styled( + e.label.clone(), + Style::default().fg(color), + ))) + }) + .collect(); + + let mut list_state = ListState::default(); + if !fb.entries.is_empty() { + list_state.select(Some(fb.selected)); + } + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[1], &mut list_state); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ move · → / Enter open · ← parent · Enter on “use”/“new” chooses · Esc cancel", + Style::default().fg(theme.muted), + ))), + rows[2], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn synthetic() -> FolderBrowser { + FolderBrowser { + title: "Pick".into(), + current_dir: PathBuf::from("/home/user/models"), + entries: vec![ + FolderEntry { + label: "[ use this folder ]".into(), + path: PathBuf::from("/home/user/models"), + kind: FolderEntryKind::UseCurrent, + }, + FolderEntry { + label: "..".into(), + path: PathBuf::from("/home/user"), + kind: FolderEntryKind::Parent, + }, + FolderEntry { + label: "llama/".into(), + path: PathBuf::from("/home/user/models/llama"), + kind: FolderEntryKind::Directory, + }, + FolderEntry { + label: "[ + new folder ]".into(), + path: PathBuf::from("/home/user/models/new-folder"), + kind: FolderEntryKind::NewChild, + }, + ], + selected: 0, + } + } + + #[test] + fn use_current_chooses_browsed_dir() { + let mut fb = synthetic(); + // selected = 0 = UseCurrent + let out = fb.on_key(KeyCode::Enter); + assert_eq!( + out, + FolderOutcome::Chosen(PathBuf::from("/home/user/models")) + ); + } + + #[test] + fn new_child_chooses_child_path() { + let mut fb = synthetic(); + fb.selected = 3; // NewChild + let out = fb.on_key(KeyCode::Enter); + assert_eq!( + out, + FolderOutcome::Chosen(PathBuf::from("/home/user/models/new-folder")) + ); + } + + #[test] + fn enter_on_directory_navigates_not_chooses() { + let mut fb = synthetic(); + fb.selected = 2; // llama/ + let out = fb.on_key(KeyCode::Enter); + assert_eq!(out, FolderOutcome::Navigated); + assert_eq!(fb.current_dir, PathBuf::from("/home/user/models/llama")); + assert_eq!(fb.selected, 0); + } + + #[test] + fn movement_clamps() { + let mut fb = synthetic(); + fb.on_key(KeyCode::Up); // already at top + assert_eq!(fb.selected, 0); + fb.on_key(KeyCode::End); + assert_eq!(fb.selected, 3); + fb.on_key(KeyCode::Down); // clamp at bottom + assert_eq!(fb.selected, 3); + } + + #[test] + fn esc_cancels() { + let mut fb = synthetic(); + assert_eq!(fb.on_key(KeyCode::Esc), FolderOutcome::Cancelled); + } + + #[test] + fn build_entries_lists_children_on_real_fs() { + // Create a unique temp dir with two child dirs + one file. + let base = std::env::temp_dir().join(format!("rocmdash-fb-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("alpha")).unwrap(); + std::fs::create_dir_all(base.join("beta")).unwrap(); + std::fs::write(base.join("note.txt"), b"x").unwrap(); + + let entries = build_entries(&base); + // First is UseCurrent, last is NewChild. + assert_eq!(entries.first().unwrap().kind, FolderEntryKind::UseCurrent); + assert_eq!(entries.last().unwrap().kind, FolderEntryKind::NewChild); + let dir_labels: Vec<&str> = entries + .iter() + .filter(|e| e.kind == FolderEntryKind::Directory) + .map(|e| e.label.as_str()) + .collect(); + assert_eq!(dir_labels, vec!["alpha/", "beta/"]); + // The plain file is not listed. + assert!(!entries.iter().any(|e| e.label.contains("note"))); + + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/rocm-dash-tui/src/ui/format.rs b/crates/rocm-dash-tui/src/ui/format.rs new file mode 100644 index 00000000..9b088eb2 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/format.rs @@ -0,0 +1,256 @@ +//! Humanized number / unit formatters. +//! +//! Pure functions, no rendering deps. Used by every tab to keep numeric +//! columns scannable at a glance. +//! +//! Conventions: +//! - Binary units for memory (MiB / GiB), since amd-smi and sysinfo report +//! tibibytes-of-bytes. We translate field names that say `..._mb` into +//! "MiB / GiB" once they cross 1024. +//! - SI units (k / M / B) for token throughput and request counts. +//! - Percentages always with 1 decimal unless < 0.1, then 2 decimals. +//! - Optional values render `-`. + +/// Format a byte count that's already in mebibytes (e.g. amd-smi `vram_used_mb`). +/// Promotes to GiB at 1024, TiB at 1024², with one decimal. +pub fn mib(value: u64) -> String { + if value >= 1024 * 1024 { + format!("{:.1} TiB", value as f64 / (1024.0 * 1024.0)) + } else if value >= 1024 { + format!("{:.1} GiB", value as f64 / 1024.0) + } else { + format!("{value} MiB") + } +} + +/// Pair of (used_mib, total_mib) → "used / total" with promotion. Both promoted +/// to the same unit (driven by total) so they compare visually. +pub fn mib_pair(used: u64, total: u64) -> String { + if total >= 1024 * 1024 { + let scale = 1024.0 * 1024.0; + format!( + "{:.1} / {:.1} TiB", + used as f64 / scale, + total as f64 / scale + ) + } else if total >= 1024 { + let scale = 1024.0; + format!( + "{:.1} / {:.1} GiB", + used as f64 / scale, + total as f64 / scale + ) + } else { + format!("{used} / {total} MiB") + } +} + +/// Percentage rendered with one decimal, two when very small. +pub fn pct(value: f32) -> String { + if value > 0.0 && value < 0.1 { + format!("{value:.2}%") + } else { + format!("{value:.1}%") + } +} + +/// `Option` percentage → `-` when None. +pub fn pct_opt(value: Option) -> String { + match value { + Some(v) => pct(v), + None => "-".to_string(), + } +} + +/// SI-suffixed number: 1234 → "1.23 k", 1_234_567 → "1.23 M". +/// Below 1000 returns the raw integer with no suffix. +pub fn si(value: f64) -> String { + let av = value.abs(); + if av < 1_000.0 { + if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + format!("{value:.1}") + } + } else if av < 1_000_000.0 { + format!("{:.2} k", value / 1_000.0) + } else if av < 1_000_000_000.0 { + format!("{:.2} M", value / 1_000_000.0) + } else { + format!("{:.2} B", value / 1_000_000_000.0) + } +} + +/// Byte-rate (bytes per second), SI-suffixed: `512/s`, `1.20 k/s`, `1.20 M/s`. +/// Used for disk and network throughput on the Hardware tab. Reuses [`si`], so +/// the magnitude suffix (k/M/B) carries the scale and `/s` marks it as a rate; +/// the unit is bytes-per-second by context (the panel labels say disk / net). +/// No panic at 0 or non-finite input. +pub fn bps(value: f64) -> String { + if !value.is_finite() { + return "-".to_string(); + } + format!("{}/s", si(value.max(0.0))) +} + +/// Token throughput. `123.4 tok/s`, `1.23 k tok/s`. `-` when None. +pub fn tps_opt(value: Option) -> String { + match value { + Some(v) if v >= 1_000.0 => format!("{} tok/s", si(v)), + Some(v) => format!("{v:.1} tok/s"), + None => "-".to_string(), + } +} + +/// Energy efficiency: generation throughput per watt. `0.42 tok/W`. `-` when +/// None or non-finite (no throughput sample or no GPU power telemetry). +pub fn tokens_per_watt(value: Option) -> String { + match value { + Some(v) if v.is_finite() => format!("{v:.2} tok/W"), + _ => "-".to_string(), + } +} + +/// Human duration from seconds. Sub-second → `ms`; otherwise `Hh Mm Ss`, +/// dropping any leading zero components. +pub fn duration(seconds: f64) -> String { + if seconds < 1.0 { + let ms = (seconds * 1000.0).round() as i64; + return format!("{ms} ms"); + } + let total = seconds.round() as i64; + let h = total / 3_600; + let m = (total % 3_600) / 60; + let s = total % 60; + if h > 0 { + format!("{h}h {m}m {s}s") + } else if m > 0 { + format!("{m}m {s}s") + } else { + format!("{s}s") + } +} + +/// Request counter rendered with SI suffix for big numbers and `-` for None. +pub fn reqs_opt(value: Option) -> String { + match value { + Some(v) if v >= 1_000 => si(v as f64), + Some(v) => v.to_string(), + None => "-".to_string(), + } +} + +/// Power in watts. One decimal, always trailing `W`. +pub fn watts(value: f32) -> String { + format!("{value:.1} W") +} + +/// Temperature in °C. One decimal, always trailing `°C`. +pub fn celsius(value: f32) -> String { + format!("{value:.1}°C") +} + +/// Clock in MHz, promoted to GHz once it crosses 1000. +pub fn mhz(value: u64) -> String { + if value >= 1000 { + format!("{:.2} GHz", value as f64 / 1000.0) + } else { + format!("{value} MHz") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mib_promotes_to_gib_then_tib() { + assert_eq!(mib(0), "0 MiB"); + assert_eq!(mib(512), "512 MiB"); + assert_eq!(mib(1024), "1.0 GiB"); + assert_eq!(mib(2048 + 512), "2.5 GiB"); + assert_eq!(mib(1024 * 1024), "1.0 TiB"); + assert_eq!(mib(1024 * 1024 * 3), "3.0 TiB"); + } + + #[test] + fn mib_pair_uses_total_to_pick_unit() { + assert_eq!(mib_pair(256, 512), "256 / 512 MiB"); + assert_eq!(mib_pair(2048, 4096), "2.0 / 4.0 GiB"); + assert_eq!(mib_pair(1024, 1024 * 1024), "0.0 / 1.0 TiB"); + } + + #[test] + fn pct_uses_two_decimals_for_tiny_values() { + assert_eq!(pct(0.0), "0.0%"); + assert_eq!(pct(0.05), "0.05%"); + assert_eq!(pct(42.3), "42.3%"); + assert_eq!(pct(100.0), "100.0%"); + } + + #[test] + fn pct_opt_handles_none() { + assert_eq!(pct_opt(None), "-"); + assert_eq!(pct_opt(Some(75.0)), "75.0%"); + } + + #[test] + fn si_scales_into_k_m_b() { + assert_eq!(si(0.0), "0"); + assert_eq!(si(123.0), "123"); + assert_eq!(si(999.0), "999"); + assert_eq!(si(1234.0), "1.23 k"); + assert_eq!(si(1_234_567.0), "1.23 M"); + assert_eq!(si(2_500_000_000.0), "2.50 B"); + } + + #[test] + fn bps_appends_rate_suffix_and_scales() { + assert_eq!(bps(0.0), "0/s"); + assert_eq!(bps(512.0), "512/s"); + assert!(bps(512.0).contains("/s")); + assert_eq!(bps(1_200_000.0), "1.20 M/s"); + assert!(bps(1_200_000.0).contains("M/s")); + assert_eq!(bps(2_500.0), "2.50 k/s"); + // non-finite and negative are handled without panic + assert_eq!(bps(f64::NAN), "-"); + assert_eq!(bps(-5.0), "0/s"); + } + + #[test] + fn tps_opt_promotes_at_thousand() { + assert_eq!(tps_opt(None), "-"); + assert_eq!(tps_opt(Some(45.6)), "45.6 tok/s"); + assert_eq!(tps_opt(Some(1500.0)), "1.50 k tok/s"); + } + + #[test] + fn tokens_per_watt_renders_or_dashes() { + assert_eq!(tokens_per_watt(None), "-"); + assert_eq!(tokens_per_watt(Some(0.42)), "0.42 tok/W"); + assert_eq!(tokens_per_watt(Some(f64::INFINITY)), "-"); + } + + #[test] + fn duration_picks_smallest_unit_combo() { + assert_eq!(duration(0.42), "420 ms"); + assert_eq!(duration(1.0), "1s"); + assert_eq!(duration(75.0), "1m 15s"); + assert_eq!(duration(3700.0), "1h 1m 40s"); + } + + #[test] + fn reqs_opt_collapses_big_counts() { + assert_eq!(reqs_opt(None), "-"); + assert_eq!(reqs_opt(Some(5)), "5"); + assert_eq!(reqs_opt(Some(12_000)), "12.00 k"); + } + + #[test] + fn watts_and_celsius_and_mhz() { + assert_eq!(watts(123.4), "123.4 W"); + assert_eq!(celsius(67.0), "67.0°C"); + assert_eq!(mhz(2400), "2.40 GHz"); + assert_eq!(mhz(800), "800 MHz"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/gradient.rs b/crates/rocm-dash-tui/src/ui/gradient.rs new file mode 100644 index 00000000..7d7f428a --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/gradient.rs @@ -0,0 +1,249 @@ +//! Three-stop horizontal gradient gauge (ok → warn → err by default). +//! +//! ratatui's built-in `Gauge` only supports a single color. This widget +//! renders the filled portion by setting each cell's background to a color +//! interpolated across the bar's width — so a 100% bar reads as a smooth +//! green-yellow-red sweep, and the fill *length* itself still tracks the +//! ratio. The unfilled portion keeps the panel's surface color. +//! +//! Inspired by btop's mem/cpu meters. + +use ratatui::buffer::Buffer; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::Widget; + +#[derive(Debug, Clone, Copy)] +pub struct GradientGauge<'a> { + ratio: f64, + stops: [Color; 3], + track_bg: Color, + label: Option<&'a str>, + label_fg: Color, +} + +impl<'a> GradientGauge<'a> { + pub fn new(ratio: f64) -> Self { + Self { + ratio: ratio.clamp(0.0, 1.0), + stops: [ + Color::Rgb(0x1a, 0xa0, 0x1a), + Color::Rgb(0xf5, 0x9e, 0x0b), + Color::Rgb(0xed, 0x1c, 0x24), + ], + track_bg: Color::Reset, + label: None, + label_fg: Color::White, + } + } + + pub fn stops(mut self, start: Color, mid: Color, end: Color) -> Self { + self.stops = [start, mid, end]; + self + } + + pub fn track_bg(mut self, c: Color) -> Self { + self.track_bg = c; + self + } + + pub fn label(mut self, label: &'a str) -> Self { + self.label = Some(label); + self + } + + pub fn label_fg(mut self, c: Color) -> Self { + self.label_fg = c; + self + } +} + +impl Widget for GradientGauge<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let width = area.width as usize; + // Filled cell count (floor — partial cells aren't subdividable in TTY). + let filled = (self.ratio * width as f64).round() as usize; + let filled = filled.min(width); + + for cy in 0..area.height { + for cx in 0..width { + let bg = if cx < filled { + lerp3(self.stops, cx, width.saturating_sub(1).max(1)) + } else { + self.track_bg + }; + if let Some(cell) = buf.cell_mut((area.x + cx as u16, area.y + cy)) { + cell.set_char(' '); + cell.set_style(Style::default().bg(bg)); + } + } + } + + if let Some(label) = self.label { + // Center label on the middle row. Read the per-cell bg under the + // label so the text foreground stays legible against whatever stop + // it lands on (bg from the gauge below, fg from `label_fg`). + let mid_y = area.y + area.height / 2; + let len = label.chars().count() as u16; + if len <= area.width { + let start_x = area.x + (area.width - len) / 2; + buf.set_string( + start_x, + mid_y, + label, + Style::default() + .fg(self.label_fg) + .add_modifier(Modifier::BOLD), + ); + } + } + let _ = Alignment::Center; + } +} + +/// 3-stop linear color interpolation by cell index across `last+1` cells. +/// Wraps `lerp3_t`; kept for callers that think in column indices. +fn lerp3(stops: [Color; 3], x: usize, last: usize) -> Color { + if last == 0 { + return stops[2]; + } + lerp3_t(stops, x as f64 / last as f64) +} + +/// 3-stop linear color interpolation by a unit-interval parameter `t` +/// (clamped to `[0, 1]`). The first half lerps stops[0]→stops[1]; the +/// second half lerps stops[1]→stops[2]. +/// +/// Exposed for widgets (sparklines, core bars) that need to color samples +/// by their *value*, not their position. +pub fn lerp3_t(stops: [Color; 3], t: f64) -> Color { + let t = t.clamp(0.0, 1.0); + if t <= 0.5 { + lerp2(stops[0], stops[1], t * 2.0) + } else { + lerp2(stops[1], stops[2], (t - 0.5) * 2.0) + } +} + +fn lerp2(a: Color, b: Color, t: f64) -> Color { + let (ar, ag, ab) = rgb_of(a); + let (br, bg, bb) = rgb_of(b); + let t = t.clamp(0.0, 1.0); + let lerp = |x: u8, y: u8| -> u8 { + let v = x as f64 + (y as f64 - x as f64) * t; + v.round().clamp(0.0, 255.0) as u8 + }; + Color::Rgb(lerp(ar, br), lerp(ag, bg), lerp(ab, bb)) +} + +fn rgb_of(c: Color) -> (u8, u8, u8) { + match c { + Color::Rgb(r, g, b) => (r, g, b), + // Fallback: indexed / named colors aren't blendable without a + // palette table; treat as mid-grey so the gauge still renders. + _ => (128, 128, 128), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lerp2_endpoints_are_exact() { + let a = Color::Rgb(0, 0, 0); + let b = Color::Rgb(200, 100, 50); + assert_eq!(lerp2(a, b, 0.0), a); + assert_eq!(lerp2(a, b, 1.0), b); + } + + #[test] + fn lerp2_midpoint_is_arithmetic_mean() { + let a = Color::Rgb(0, 0, 0); + let b = Color::Rgb(100, 200, 50); + let mid = lerp2(a, b, 0.5); + // round(50), round(100), round(25) + assert_eq!(mid, Color::Rgb(50, 100, 25)); + } + + #[test] + fn lerp3_passes_through_each_stop() { + let stops = [ + Color::Rgb(10, 0, 0), + Color::Rgb(0, 20, 0), + Color::Rgb(0, 0, 30), + ]; + assert_eq!(lerp3(stops, 0, 10), stops[0]); + assert_eq!(lerp3(stops, 5, 10), stops[1]); + assert_eq!(lerp3(stops, 10, 10), stops[2]); + } + + #[test] + fn lerp3_handles_zero_width_gauge() { + let stops = [ + Color::Rgb(10, 0, 0), + Color::Rgb(0, 20, 0), + Color::Rgb(0, 0, 30), + ]; + assert_eq!(lerp3(stops, 0, 0), stops[2]); + } + + #[test] + fn render_fills_expected_cells_per_ratio() { + let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1)); + let gauge = GradientGauge::new(0.5); + gauge.render(buf.area, &mut buf); + // 5 cells should have a non-Reset bg, 5 should be Reset (track). + let mut filled = 0; + for x in 0..10 { + let cell = buf.cell((x, 0)).unwrap(); + if !matches!(cell.style().bg, Some(Color::Reset) | None) { + filled += 1; + } + } + assert_eq!(filled, 5); + } + + #[test] + fn render_zero_ratio_paints_no_fill() { + let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1)); + GradientGauge::new(0.0).render(buf.area, &mut buf); + for x in 0..8 { + let cell = buf.cell((x, 0)).unwrap(); + assert!(matches!(cell.style().bg, Some(Color::Reset) | None)); + } + } + + #[test] + fn render_full_ratio_paints_every_cell() { + let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1)); + GradientGauge::new(1.0).render(buf.area, &mut buf); + for x in 0..8 { + let cell = buf.cell((x, 0)).unwrap(); + assert!(!matches!(cell.style().bg, Some(Color::Reset) | None)); + } + } + + #[test] + fn label_renders_at_horizontal_center() { + let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1)); + GradientGauge::new(0.5) + .label("50%") + .render(buf.area, &mut buf); + // Label starts at (10 - 3) / 2 = 3 + let s: String = (3..6) + .map(|x| { + buf.cell((x, 0)) + .unwrap() + .symbol() + .chars() + .next() + .unwrap_or(' ') + }) + .collect(); + assert_eq!(s, "50%"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/heatmap.rs b/crates/rocm-dash-tui/src/ui/heatmap.rs new file mode 100644 index 00000000..06b32745 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/heatmap.rs @@ -0,0 +1,337 @@ +//! 2-D heatmap widget. +//! +//! Each row is a series of `f64` values. Each cell renders as a single +//! character whose background color is interpolated across the gradient +//! stops by `value / row_max`. Optional left labels are rendered in the +//! widget's `label_style`. When the matrix is wider than the area, the +//! widget right-aligns (drops the oldest columns) — the most-recent +//! samples are always visible, which matches how the rest of the dashboard +//! presents history. +//! +//! Designed for "metric × time" matrices: per-GPU detail (util/temp/power/ +//! vram% × time), per-instance kv-cache × time, etc. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::Widget; + +use crate::ui::gradient::lerp3_t; + +#[derive(Debug, Clone)] +pub struct HeatmapRow { + pub label: String, + pub data: Vec, + /// Per-row max for normalization. If zero or negative, the cell renders + /// as `track_bg`. + pub max: f64, + /// Optional per-row gradient override. Falls back to the widget-level + /// `stops` when None. + pub stops: Option<[Color; 3]>, +} + +impl HeatmapRow { + pub fn new(label: impl Into, data: Vec, max: f64) -> Self { + Self { + label: label.into(), + data, + max, + stops: None, + } + } + + pub fn stops(mut self, start: Color, mid: Color, end: Color) -> Self { + self.stops = Some([start, mid, end]); + self + } +} + +pub struct Heatmap<'a> { + rows: &'a [HeatmapRow], + stops: [Color; 3], + track_bg: Color, + label_style: Style, + label_width: u16, +} + +impl<'a> Heatmap<'a> { + pub fn new(rows: &'a [HeatmapRow]) -> Self { + // Pick a sensible default label width: longest label + 1 padding, + // capped at 12. Callers can override with `label_width`. + let default_label_w = rows + .iter() + .map(|r| r.label.chars().count() as u16) + .max() + .unwrap_or(0) + .saturating_add(1) + .min(12); + Self { + rows, + stops: [ + Color::Rgb(0x1a, 0xa0, 0x1a), + Color::Rgb(0xf5, 0x9e, 0x0b), + Color::Rgb(0xed, 0x1c, 0x24), + ], + track_bg: Color::Reset, + label_style: Style::default(), + label_width: default_label_w, + } + } + + pub fn stops(mut self, start: Color, mid: Color, end: Color) -> Self { + self.stops = [start, mid, end]; + self + } + + pub fn track_bg(mut self, c: Color) -> Self { + self.track_bg = c; + self + } + + pub fn label_style(mut self, s: Style) -> Self { + self.label_style = s; + self + } + + pub fn label_width(mut self, w: u16) -> Self { + self.label_width = w; + self + } +} + +impl Widget for Heatmap<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 || self.rows.is_empty() { + return; + } + let label_w = self.label_width.min(area.width); + let data_w = area.width.saturating_sub(label_w); + if data_w == 0 { + // No room for cells — just render labels. + for (i, row) in self.rows.iter().enumerate() { + if i as u16 >= area.height { + break; + } + let y = area.y + i as u16; + let truncated = truncate_to(&row.label, label_w as usize); + buf.set_string(area.x, y, &truncated, self.label_style); + } + return; + } + + for (i, row) in self.rows.iter().enumerate() { + if i as u16 >= area.height { + break; + } + let y = area.y + i as u16; + + // Label. + if label_w > 0 { + let truncated = truncate_to(&row.label, label_w as usize); + buf.set_string(area.x, y, &truncated, self.label_style); + } + + // Right-align data: drop oldest columns when wider than `data_w`. + let data_len = row.data.len(); + let visible = data_w as usize; + let start = data_len.saturating_sub(visible); + let slice = &row.data[start..]; + let row_stops = row.stops.unwrap_or(self.stops); + + for (cx, v) in slice.iter().enumerate() { + let x = area.x + label_w + cx as u16; + let bg = if row.max > 0.0 && *v > 0.0 { + let t = (*v / row.max).clamp(0.0, 1.0); + lerp3_t(row_stops, t) + } else { + self.track_bg + }; + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_char(' '); + cell.set_style(Style::default().bg(bg)); + } + } + // Pad any leftover data area with track_bg so previous frames + // don't bleed through when a row has fewer samples than width. + let painted = slice.len() as u16; + for cx in painted..data_w { + let x = area.x + label_w + cx; + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_char(' '); + cell.set_style(Style::default().bg(self.track_bg)); + } + } + } + } +} + +fn truncate_to(s: &str, max: usize) -> String { + if s.chars().count() <= max { + format!("{s:, max: f64) -> HeatmapRow { + HeatmapRow::new(label, data, max) + } + + #[test] + fn empty_rows_render_nothing() { + let rows: Vec = Vec::new(); + let mut buf = Buffer::empty(Rect::new(0, 0, 10, 3)); + Heatmap::new(&rows).render(buf.area, &mut buf); + // No panic, no styled cells. + for y in 0..3 { + for x in 0..10 { + let cell = buf.cell((x, y)).unwrap(); + assert!(matches!(cell.style().bg, Some(Color::Reset) | None)); + } + } + } + + #[test] + fn default_label_width_is_longest_plus_one_capped() { + let rows = vec![ + row("util", vec![1.0], 100.0), + row("temperature", vec![1.0], 100.0), + row("pwr", vec![1.0], 100.0), + ]; + let h = Heatmap::new(&rows); + // "temperature" is 11 chars; +1 = 12, cap = 12. + assert_eq!(h.label_width, 12); + } + + #[test] + fn cells_use_per_row_gradient_when_set() { + let row_red_only = row("a", vec![50.0], 100.0).stops( + Color::Rgb(255, 0, 0), + Color::Rgb(255, 0, 0), + Color::Rgb(255, 0, 0), + ); + let rows = vec![row_red_only]; + let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1)); + Heatmap::new(&rows) + .label_width(2) + .render(buf.area, &mut buf); + // x = 2 (after label_w=2) is the first data cell. + let cell = buf.cell((2, 0)).unwrap(); + assert_eq!(cell.style().bg, Some(Color::Rgb(255, 0, 0))); + } + + #[test] + fn zero_max_or_zero_value_paints_track_bg() { + let rows = vec![row("a", vec![0.0, 50.0], 0.0)]; + let track = Color::Rgb(20, 20, 20); + let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1)); + Heatmap::new(&rows) + .label_width(2) + .track_bg(track) + .render(buf.area, &mut buf); + // Both cells should be track_bg because row.max == 0. + assert_eq!(buf.cell((2, 0)).unwrap().style().bg, Some(track)); + assert_eq!(buf.cell((3, 0)).unwrap().style().bg, Some(track)); + } + + #[test] + fn right_aligns_when_data_wider_than_area() { + // Width 5 = label(2) + data(3). 5 samples → keeps the last 3. + let mut rows = vec![row("a", vec![10.0, 20.0, 30.0, 40.0, 50.0], 50.0)]; + rows[0] = rows[0].clone().stops( + Color::Rgb(0, 0, 100), + Color::Rgb(0, 100, 0), + Color::Rgb(100, 0, 0), + ); + let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1)); + Heatmap::new(&rows) + .label_width(2) + .render(buf.area, &mut buf); + // The leftmost data cell should reflect value 30 (the 3rd-newest). + let cell30 = buf.cell((2, 0)).unwrap(); + let cell40 = buf.cell((3, 0)).unwrap(); + let cell50 = buf.cell((4, 0)).unwrap(); + // 30/50 = 0.6 → past midpoint, mid→end interp; 50/50 = 1.0 → end stop. + assert_eq!(cell50.style().bg, Some(Color::Rgb(100, 0, 0))); + // 40/50 = 0.8 → mid→end at t=0.6. + assert!(matches!(cell40.style().bg, Some(Color::Rgb(_, _, _)))); + // 30/50 = 0.6 → mid→end at t=0.2 → mostly green-ish. + assert!(matches!(cell30.style().bg, Some(Color::Rgb(_, _, _)))); + } + + #[test] + fn truncates_label_when_too_long() { + let rows = vec![row("temperature", vec![50.0], 100.0)]; + let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1)); + Heatmap::new(&rows) + .label_width(4) + .render(buf.area, &mut buf); + let s: String = (0..4) + .map(|x| { + buf.cell((x, 0)) + .unwrap() + .symbol() + .chars() + .next() + .unwrap_or(' ') + }) + .collect(); + assert_eq!(s, "temp"); + } + + #[test] + fn rows_beyond_height_are_dropped() { + let rows = vec![ + row("a", vec![50.0], 100.0), + row("b", vec![50.0], 100.0), + row("c", vec![50.0], 100.0), + ]; + let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2)); + Heatmap::new(&rows) + .label_width(2) + .render(buf.area, &mut buf); + // Row 2 ("c") wasn't rendered — y=2 doesn't exist in a 2-row area, + // but more importantly the widget didn't panic. + assert_eq!(buf.area.height, 2); + } + + #[test] + fn renders_label_only_when_no_room_for_cells() { + let rows = vec![row("a", vec![50.0], 100.0)]; + let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1)); + Heatmap::new(&rows) + .label_width(4) + .render(buf.area, &mut buf); + // label_w(4) > area.width(2) → clamped to area.width, no data cells. + // No assertion on the data — but make sure the label glyph appears at x=0. + let first = buf + .cell((0, 0)) + .unwrap() + .symbol() + .chars() + .next() + .unwrap_or(' '); + assert_eq!(first, 'a'); + } + + #[test] + fn label_style_is_applied() { + use ratatui::style::Modifier; + let rows = vec![row("a", vec![10.0], 100.0)]; + let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1)); + let style = Style::default() + .fg(Color::Rgb(123, 45, 67)) + .add_modifier(Modifier::BOLD); + Heatmap::new(&rows) + .label_width(2) + .label_style(style) + .render(buf.area, &mut buf); + let cell_style = buf.cell((0, 0)).unwrap().style(); + assert_eq!(cell_style.fg, Some(Color::Rgb(123, 45, 67))); + assert!(cell_style.add_modifier.contains(Modifier::BOLD)); + } +} diff --git a/crates/rocm-dash-tui/src/ui/install_manager.rs b/crates/rocm-dash-tui/src/ui/install_manager.rs new file mode 100644 index 00000000..353c7de8 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/install_manager.rs @@ -0,0 +1,606 @@ +//! Install overlay (Phase 3 Wave 2). +//! +//! A compact form that builds a `rocm install sdk …` invocation (TheRock ROCm +//! wheels). Dry-run is read-only and spawns straight through the job-bridge; a +//! real install is mutating and routes through the approval gate first. The +//! install folder is picked with the Wave-0 [`FolderBrowser`]. Streaming-install +//! archetype, zero thread::spawn/try_recv. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::folder_browser::{FolderBrowser, FolderOutcome, draw_folder_browser}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// Package formats — mirrors `apps/rocm` `InstallFormat` (clap value-enum). +pub const FORMATS: &[&str] = &["wheel", "tarball"]; + +/// Default install channel (mirrors the `rocm install sdk --channel` default). +const DEFAULT_CHANNEL: &str = "release"; + +/// Form fields, in vertical order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Field { + Channel, + Format, + Prefix, + DryRun, + Launch, +} + +pub const FIELDS: &[Field] = &[ + Field::Channel, + Field::Format, + Field::Prefix, + Field::DryRun, + Field::Launch, +]; + +/// An approved-but-not-yet-run mutating install. +#[derive(Debug, Clone)] +pub struct PendingInstall { + pub cmd: String, + pub args: Vec, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone)] +pub struct InstallManagerState { + pub field: usize, + pub channel: String, + pub format_idx: usize, + pub prefix: String, + pub dry_run: bool, + pub browser: Option, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +impl Default for InstallManagerState { + fn default() -> Self { + Self { + field: 0, + channel: DEFAULT_CHANNEL.to_string(), + format_idx: 0, + prefix: String::new(), + // Default to dry-run: the safe, read-only first step. + dry_run: true, + browser: None, + approval: None, + active_job: None, + message: None, + } + } +} + +impl InstallManagerState { + fn current_field(&self) -> Field { + FIELDS[self.field.min(FIELDS.len() - 1)] + } + + fn move_field(&mut self, delta: isize) { + let max = FIELDS.len() as isize - 1; + self.field = (self.field as isize + delta).clamp(0, max) as usize; + } + + fn cycle(&mut self) { + match self.current_field() { + Field::Format => { + self.format_idx = (self.format_idx + 1) % FORMATS.len(); + } + Field::DryRun => self.dry_run = !self.dry_run, + _ => {} + } + } + + fn type_char(&mut self, c: char) { + match self.current_field() { + Field::Channel => self.channel.push(c), + Field::Prefix => self.prefix.push(c), + _ => {} + } + } + + fn backspace(&mut self) { + match self.current_field() { + Field::Channel => { + self.channel.pop(); + } + Field::Prefix => { + self.prefix.pop(); + } + _ => {} + } + } + + /// Build the `rocm install sdk …` argv, or an error message. + fn build_args(&self) -> Result, String> { + let channel = self.channel.trim(); + if channel.is_empty() { + return Err("channel is required (e.g. release)".to_string()); + } + let mut args = vec![ + "install".to_string(), + "sdk".to_string(), + "--channel".to_string(), + channel.to_string(), + "--format".to_string(), + FORMATS[self.format_idx.min(FORMATS.len() - 1)].to_string(), + ]; + let prefix = self.prefix.trim(); + if !prefix.is_empty() { + args.push("--prefix".to_string()); + args.push(prefix.to_string()); + } + if self.dry_run { + args.push("--dry-run".to_string()); + } + Ok(args) + } +} + +/// Handle a key while the overlay is open. +pub fn on_key( + install: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(i) = install.as_mut() else { + return Vec::new(); + }; + + // 1) Folder browser (install prefix) has focus. + if let Some(fb) = i.browser.as_mut() { + match fb.on_key(key.code) { + FolderOutcome::Chosen(path) => { + i.prefix = path.to_string_lossy().into_owned(); + i.browser = None; + } + FolderOutcome::Cancelled => i.browser = None, + FolderOutcome::None | FolderOutcome::Navigated => {} + } + return Vec::new(); + } + + // 2) Approval modal has focus. + if let Some(pending) = i.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = i.approval.take() { + return spawn_install(i, jobs, pending.cmd, pending.args); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => i.approval = None, + None => {} + } + return Vec::new(); + } + + // 3) A job is showing in the console. + if let Some(job_id) = i.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *install = None, + ConsoleOutcome::Dismissed => { + i.active_job = None; + i.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 4) Form editing. Form archetype (like serve_wizard): Esc closes; text + // fields (Channel/Prefix) capture printable chars including `q` — `q` is not + // a close key here (Esc is). Non-text fields ignore typed chars. + match key.code { + KeyCode::Esc => *install = None, + KeyCode::Up => i.move_field(-1), + KeyCode::Down => i.move_field(1), + KeyCode::Left | KeyCode::Right => i.cycle(), + KeyCode::Char(' ') if i.current_field() == Field::DryRun => i.cycle(), + KeyCode::Tab if i.current_field() == Field::Prefix => { + let start = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + i.browser = Some(FolderBrowser::new("Pick an install folder", start)); + } + KeyCode::Enter => { + if i.current_field() == Field::Launch { + return request_launch(i, jobs); + } + i.move_field(1); + } + KeyCode::Backspace => i.backspace(), + KeyCode::Char(c) => i.type_char(c), + _ => {} + } + Vec::new() +} + +/// Validate + run: dry-run spawns straight away (read-only); a real install +/// stages an approval first. +fn request_launch(i: &mut InstallManagerState, jobs: &mut State) -> Vec { + let args = match i.build_args() { + Ok(a) => a, + Err(msg) => { + i.message = Some(msg); + return Vec::new(); + } + }; + let cmd = resolve_exe(); + if i.dry_run { + // Read-only plan resolution — no approval. + i.message = None; + return spawn_install(i, jobs, cmd, args); + } + let request = ApprovalRequest::new( + "install ROCm SDK".to_string(), + vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + "This downloads and installs TheRock ROCm wheels on this machine.".to_string(), + ], + ); + i.message = None; + i.approval = Some(PendingInstall { + cmd, + args, + request, + choice: ApprovalChoice::default(), + }); + Vec::new() +} + +/// Launch the install job. The id distinguishes dry-run from a real install so +/// the two consoles never collide. +fn spawn_install( + i: &mut InstallManagerState, + jobs: &mut State, + cmd: String, + args: Vec, +) -> Vec { + let id = if i.dry_run { + "install-sdk-dryrun" + } else { + "install-sdk" + } + .to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args, + }); + if fx.is_empty() { + i.message = Some("an install job is already running".to_string()); + return fx; + } + i.active_job = Some(id); + fx +} + +/// Render the overlay (form, or browser/approval/console on top). +pub fn draw_install_manager( + f: &mut Frame, + area: Rect, + i: &InstallManagerState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &i.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(72, 70, 96, 20, area); + let inner = draw_popup_frame(f, popup, "Install — ROCm SDK (TheRock)", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let lines: Vec = FIELDS + .iter() + .enumerate() + .map(|(idx, field)| field_line(*field, idx == i.field, i, theme)) + .collect(); + f.render_widget(Paragraph::new(lines), rows[0]); + + let msg = i.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[1], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ field · ←→ cycle (Format/Mode) · Tab browse (prefix) · Enter next/launch · Esc close", + Style::default().fg(theme.muted), + ))), + rows[2], + ); + + if let Some(fb) = &i.browser { + draw_folder_browser(f, area, fb, theme); + } + if let Some(pending) = &i.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +fn field_line<'a>( + field: Field, + selected: bool, + i: &'a InstallManagerState, + theme: &Theme, +) -> Line<'a> { + let (label, value): (&str, String) = match field { + Field::Channel => ("Channel", display(&i.channel, "(e.g. release)")), + Field::Format => ( + "Format", + FORMATS[i.format_idx.min(FORMATS.len() - 1)].to_string(), + ), + Field::Prefix => ( + "Folder", + display(&i.prefix, "(default managed folder · Tab to browse)"), + ), + Field::DryRun => ( + "Mode", + if i.dry_run { + "dry-run (read-only)".to_string() + } else { + "install (needs approval)".to_string() + }, + ), + Field::Launch => ("", String::new()), + }; + + if field == Field::Launch { + let style = if selected { + Style::default() + .bg(theme.ok) + .fg(theme.bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.ok) + }; + let label = if i.dry_run { + " [ Resolve plan ] " + } else { + " [ Install ] " + }; + return Line::from(Span::styled(label, style)); + } + + let marker = if selected { "▶ " } else { " " }; + let label_style = if selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.muted) + }; + let value_style = if selected { + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + Line::from(vec![ + Span::styled(marker, label_style), + Span::styled(format!("{label:<8}"), label_style), + Span::styled(value, value_style), + ]) +} + +fn display(v: &str, placeholder: &'static str) -> String { + if v.is_empty() { + placeholder.to_string() + } else { + v.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn default_is_dry_run_release_wheel() { + let i = InstallManagerState::default(); + assert!(i.dry_run); + assert_eq!(i.channel, "release"); + assert_eq!(FORMATS[i.format_idx], "wheel"); + } + + #[test] + fn build_args_dry_run_by_default() { + let i = InstallManagerState::default(); + let args = i.build_args().unwrap(); + assert_eq!( + args, + vec![ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel", + "--dry-run" + ] + ); + } + + #[test] + fn build_args_install_with_prefix_and_tarball() { + let i = InstallManagerState { + format_idx: 1, // tarball + prefix: "/opt/rocm-sdk".into(), + dry_run: false, + ..Default::default() + }; + let args = i.build_args().unwrap(); + assert!(args.windows(2).any(|p| p == ["--format", "tarball"])); + assert!(args.windows(2).any(|p| p == ["--prefix", "/opt/rocm-sdk"])); + assert!(!args.contains(&"--dry-run".to_string())); + } + + #[test] + fn build_args_requires_channel() { + let i = InstallManagerState { + channel: " ".into(), + ..Default::default() + }; + assert!(i.build_args().unwrap_err().contains("channel")); + } + + #[test] + fn dry_run_launch_is_read_only_no_approval() { + let mut ins = Some(InstallManagerState::default()); // dry_run = true + let mut jobs = State::default(); + ins.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + let fx = on_key(&mut ins, &mut jobs, key(KeyCode::Enter)); + assert_eq!(fx.len(), 1, "dry-run spawns without approval"); + assert!(ins.as_ref().unwrap().approval.is_none()); + assert_eq!( + ins.as_ref().unwrap().active_job.as_deref(), + Some("install-sdk-dryrun") + ); + } + + #[test] + fn real_install_is_gated_then_spawns() { + let mut ins = Some(InstallManagerState::default()); + let mut jobs = State::default(); + ins.as_mut().unwrap().dry_run = false; + ins.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + let fx = on_key(&mut ins, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty(), "install must not run before approval"); + assert!(ins.as_ref().unwrap().approval.is_some()); + assert!(jobs.jobs.is_empty()); + let fx = on_key(&mut ins, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!( + ins.as_ref().unwrap().active_job.as_deref(), + Some("install-sdk") + ); + } + + #[test] + fn tab_on_prefix_opens_browser() { + let mut ins = Some(InstallManagerState::default()); + let mut jobs = State::default(); + ins.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Prefix).unwrap(); + on_key(&mut ins, &mut jobs, key(KeyCode::Tab)); + assert!(ins.as_ref().unwrap().browser.is_some()); + on_key(&mut ins, &mut jobs, key(KeyCode::Esc)); + assert!(ins.as_ref().unwrap().browser.is_none()); + assert!(ins.is_some()); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut ins = Some(InstallManagerState::default()); + let mut jobs = State::default(); + ins.as_mut().unwrap().dry_run = false; + ins.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + on_key(&mut ins, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut ins, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(ins.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn esc_closes_when_idle() { + let mut ins = Some(InstallManagerState::default()); + let mut jobs = State::default(); + on_key(&mut ins, &mut jobs, key(KeyCode::Esc)); + assert!(ins.is_none()); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + // Mirrors the sibling screens: a StartJob for a still-running id no-ops, + // so spawn_install must surface a message and NOT set active_job. + let mut jobs = State::default(); + let launch = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + let mut i1 = Some(InstallManagerState::default()); // dry-run + i1.as_mut().unwrap().field = launch; + on_key(&mut i1, &mut jobs, key(KeyCode::Enter)); + assert_eq!( + i1.as_ref().unwrap().active_job.as_deref(), + Some("install-sdk-dryrun") + ); + // Fresh overlay, same dry-run while the prior job still runs. + let mut i2 = Some(InstallManagerState::default()); + i2.as_mut().unwrap().field = launch; + let fx = on_key(&mut i2, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = i2.as_ref().unwrap(); + assert!(s.active_job.is_none(), "must not point at the stale job"); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_renders_form() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(104, 22); + let mut term = Terminal::new(backend).unwrap(); + let i = InstallManagerState::default(); + let jobs = State::default(); + term.draw(|f| draw_install_manager(f, f.area(), &i, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Install")); + assert!(out.contains("Channel")); + assert!(out.contains("dry-run")); + assert!(out.contains("Resolve plan")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/instance_list.rs b/crates/rocm-dash-tui/src/ui/instance_list.rs new file mode 100644 index 00000000..a91d7e03 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/instance_list.rs @@ -0,0 +1 @@ +//! j/k-navigable instance list with status badges. Stub. diff --git a/crates/rocm-dash-tui/src/ui/job_console.rs b/crates/rocm-dash-tui/src/ui/job_console.rs new file mode 100644 index 00000000..bfcdf06f --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/job_console.rs @@ -0,0 +1,205 @@ +//! Job console widget (Phase 3 Wave 0). +//! +//! Renders a [`JobState`] from the reducer: a status header, the streamed +//! output ring, and key hints. This is the shared "running job" surface every +//! operational screen reuses instead of the frozen rocm-cli `running_job` +//! modal. The widget is read-only over the reducer's job model. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use rocm_dash_core::state::{JobState, JobStatus, SideEffect, State, StateEvent}; + +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// What a console keypress means to the owning screen. The shared seam every +/// operational overlay routes its `active_job` keys through, so the +/// Ctrl+C/`q`/Esc-Enter behavior is defined once instead of per screen. +#[derive(Debug)] +pub enum ConsoleOutcome { + /// Ctrl+C cancelled the running job — the caller runs these effects. + Cancelled(Vec), + /// `q` — the caller closes the whole overlay. + Closed, + /// Esc/Enter on a terminal job — the caller dismisses the console (returns + /// to the screen body), and may clear any transient message. + Dismissed, + /// Not a console key — the caller may handle it (e.g. a screen-specific + /// re-run shortcut). + Unhandled, +} + +/// Interpret a key while a job console is showing `job_id`. Pure except for the +/// `CancelJob` reducer apply (which only mutates the in-memory job model). +pub fn on_console_key(job_id: &str, jobs: &mut State, key: KeyEvent) -> ConsoleOutcome { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ConsoleOutcome::Cancelled(jobs.apply(StateEvent::CancelJob(job_id.to_string()))) + } + // `q` always closes the overlay so the user is never trapped mid-job + // (the job keeps running in the background). + KeyCode::Char('q') => ConsoleOutcome::Closed, + KeyCode::Esc | KeyCode::Enter + if jobs.job(job_id).map(|j| j.is_terminal()).unwrap_or(true) => + { + ConsoleOutcome::Dismissed + } + _ => ConsoleOutcome::Unhandled, + } +} + +/// Human-readable status label + the color it should render in. +pub fn status_label(job: &JobState, theme: &Theme) -> (String, ratatui::style::Color) { + match &job.status { + JobStatus::Running => ("running".to_string(), theme.accent), + JobStatus::Done { code: 0 } => ("done".to_string(), theme.ok), + JobStatus::Done { code } => (format!("exited ({code})"), theme.warn), + JobStatus::Failed { message } => (format!("failed: {message}"), theme.err), + JobStatus::Cancelled => ("cancelled".to_string(), theme.muted), + } +} + +/// Render the job console centered over `area`. `scroll` is the first visible +/// output line offset (the caller clamps it). +pub fn draw_job_console(f: &mut Frame, area: Rect, job: &JobState, scroll: u16, theme: &Theme) { + let popup = centered_rect(90, 84, 140, 40, area); + let title = format!("{} {}", job.cmd, job.args.join(" ")); + let inner = draw_popup_frame(f, popup, title.trim(), theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + // Header: status badge. + let (label, color) = status_label(job, theme); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " status ", + Style::default() + .fg(theme.bg) + .bg(color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + ])), + rows[0], + ); + + // Body: streamed output lines. + let lines: Vec = job + .output + .iter() + .map(|l| Line::from(Span::styled(l.clone(), Style::default().fg(theme.fg)))) + .collect(); + f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), rows[1]); + + // Footer: key hints (cancel only while running). + let hints = if matches!(job.status, JobStatus::Running) { + "Ctrl+C cancel · PgUp/PgDn scroll" + } else { + "Enter/Esc close · PgUp/PgDn scroll" + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + hints, + Style::default().fg(theme.muted), + ))), + rows[2], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use rocm_dash_core::state::{State, StateEvent}; + + fn theme() -> Theme { + Theme::from_name("default") + } + + fn k(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { + crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) + } + + #[test] + fn console_key_outcomes() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut s = State::default(); + s.apply(StateEvent::StartJob { + id: "j".into(), + cmd: "sleep".into(), + args: vec!["1".into()], + }); + // Ctrl+C cancels (returns effects). + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert!(matches!( + on_console_key("j", &mut s, ctrl_c), + ConsoleOutcome::Cancelled(_) + )); + // `q` closes regardless of job state. + assert!(matches!( + on_console_key("j", &mut s, k(KeyCode::Char('q'))), + ConsoleOutcome::Closed + )); + // Esc on a RUNNING job is not a dismiss (Unhandled). + let mut s2 = State::default(); + s2.apply(StateEvent::StartJob { + id: "j".into(), + cmd: "x".into(), + args: vec![], + }); + assert!(matches!( + on_console_key("j", &mut s2, k(KeyCode::Esc)), + ConsoleOutcome::Unhandled + )); + // Esc on a TERMINAL job dismisses. + s2.apply(StateEvent::JobDone { + id: "j".into(), + code: 0, + }); + assert!(matches!( + on_console_key("j", &mut s2, k(KeyCode::Esc)), + ConsoleOutcome::Dismissed + )); + // A missing job id is treated as terminal → Esc dismisses. + assert!(matches!( + on_console_key("gone", &mut s2, k(KeyCode::Enter)), + ConsoleOutcome::Dismissed + )); + } + + #[test] + fn status_labels_track_lifecycle() { + let mut s = State::default(); + s.apply(StateEvent::StartJob { + id: "j".into(), + cmd: "echo".into(), + args: vec!["hi".into()], + }); + let t = theme(); + assert_eq!(status_label(s.job("j").unwrap(), &t).0, "running"); + s.apply(StateEvent::JobDone { + id: "j".into(), + code: 0, + }); + assert_eq!(status_label(s.job("j").unwrap(), &t).0, "done"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/logs_view.rs b/crates/rocm-dash-tui/src/ui/logs_view.rs new file mode 100644 index 00000000..ef4d6103 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/logs_view.rs @@ -0,0 +1,235 @@ +//! Logs overlay (Phase 3 Wave 3). +//! +//! Browses recent ROCm CLI logs via `rocm logs [--search WORDS]` — read-only, so +//! no approval gate. An optional search box filters before running. Output +//! streams into the shared job console. Read-only-with-input archetype. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::exec::resolve_exe; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct LogsViewState { + /// Optional space-separated search terms. + pub query: String, + /// In-flight (or just-finished) `rocm logs` job id. + pub active_job: Option, +} + +/// Handle a key while the overlay is open. +pub fn on_key( + logs: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(l) = logs.as_mut() else { + return Vec::new(); + }; + + if let Some(job_id) = l.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *logs = None, + ConsoleOutcome::Dismissed => l.active_job = None, + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // Search box (read-only-with-input archetype): Esc closes; printable chars + // — including `q` — are captured as search text, not treated as a close key + // (the same form-field tradeoff as serve_wizard's Model field). From the + // running console, `q` closes via on_console_key above. + match key.code { + KeyCode::Esc => *logs = None, + KeyCode::Enter => return run_logs(l, jobs), + KeyCode::Backspace => { + l.query.pop(); + } + KeyCode::Char(c) => l.query.push(c), + _ => {} + } + Vec::new() +} + +/// The `rocm logs` argv for the current query (read-only). +fn logs_args(query: &str) -> Vec { + let mut args = vec!["logs".to_string()]; + let terms: Vec<&str> = query.split_whitespace().collect(); + if !terms.is_empty() { + args.push("--search".to_string()); + args.extend(terms.into_iter().map(str::to_string)); + } + args +} + +/// Spawn `rocm logs` (read-only). A stable id replaces any prior console. +fn run_logs(l: &mut LogsViewState, jobs: &mut State) -> Vec { + let cmd = resolve_exe(); + let id = "logs".to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args: logs_args(&l.query), + }); + // Single stable id → a no-op means re-attach to the running console. + l.active_job = Some(id); + fx +} + +/// Render the overlay (search box, or the job console once running). +pub fn draw_logs_view(f: &mut Frame, area: Rect, l: &LogsViewState, jobs: &State, theme: &Theme) { + if let Some(job_id) = &l.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(70, 40, 88, 10, area); + let inner = draw_popup_frame(f, popup, "Logs — recent ROCm CLI activity", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(inner); + + let query_display = if l.query.is_empty() { + "(optional: type words to search, or just Enter for recent)".to_string() + } else { + l.query.clone() + }; + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("search: ", Style::default().fg(theme.muted)), + Span::styled( + query_display, + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ), + ])), + rows[0], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "type to search · Enter view · Esc close", + Style::default().fg(theme.muted), + ))), + rows[1], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn logs_args_recent_when_no_query() { + assert_eq!(logs_args(""), vec!["logs"]); + assert_eq!(logs_args(" "), vec!["logs"]); + } + + #[test] + fn logs_args_search_splits_terms() { + assert_eq!( + logs_args("error serve"), + vec!["logs", "--search", "error", "serve"] + ); + } + + #[test] + fn typing_builds_query_then_enter_runs_read_only() { + let mut l = Some(LogsViewState::default()); + let mut jobs = State::default(); + for c in "vllm".chars() { + on_key(&mut l, &mut jobs, key(KeyCode::Char(c))); + } + assert_eq!(l.as_ref().unwrap().query, "vllm"); + let fx = on_key(&mut l, &mut jobs, key(KeyCode::Enter)); + assert_eq!(fx.len(), 1, "read-only spawn, no approval"); + assert!(matches!(fx[0], SideEffect::SpawnJob { .. })); + assert_eq!(l.as_ref().unwrap().active_job.as_deref(), Some("logs")); + } + + #[test] + fn esc_closes_when_idle_and_q_types_into_query() { + let mut l = Some(LogsViewState::default()); + let mut jobs = State::default(); + // `q` is a search character here, not a close key (Esc closes). + on_key(&mut l, &mut jobs, key(KeyCode::Char('q'))); + assert_eq!(l.as_ref().unwrap().query, "q"); + on_key(&mut l, &mut jobs, key(KeyCode::Esc)); + assert!(l.is_none()); + } + + #[test] + fn q_closes_overlay_while_job_runs() { + let mut l = Some(LogsViewState::default()); + let mut jobs = State::default(); + on_key(&mut l, &mut jobs, key(KeyCode::Enter)); + on_key(&mut l, &mut jobs, key(KeyCode::Char('q'))); + assert!(l.is_none(), "q closes the overlay from the console view"); + } + + #[test] + fn second_enter_while_running_reattaches_to_console() { + // Single stable id: a second view while the prior job still runs re-uses + // the same console (read-only re-attach, like doctor) — never an error. + let mut l = Some(LogsViewState::default()); + let mut jobs = State::default(); + on_key(&mut l, &mut jobs, key(KeyCode::Enter)); + assert_eq!(l.as_ref().unwrap().active_job.as_deref(), Some("logs")); + // Dismiss is unreachable while running; simulate re-entry from a fresh + // overlay against the same still-running job. + let mut l2 = Some(LogsViewState::default()); + let fx = on_key(&mut l2, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty(), "no second spawn for the running id"); + assert_eq!( + l2.as_ref().unwrap().active_job.as_deref(), + Some("logs"), + "re-attaches to the live console" + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_shows_search_box() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(94, 14); + let mut term = Terminal::new(backend).unwrap(); + let l = LogsViewState::default(); + let jobs = State::default(); + term.draw(|f| draw_logs_view(f, f.area(), &l, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Logs")); + assert!(out.contains("search")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/mod.rs b/crates/rocm-dash-tui/src/ui/mod.rs new file mode 100644 index 00000000..ed565781 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/mod.rs @@ -0,0 +1,265 @@ +pub mod approval; +pub mod automations_manager; +pub mod bench; +pub mod command_screen; +pub mod config_manager; +pub mod core_bars; +pub mod doctor_manager; +pub mod engine_manager; +pub mod exec; +pub mod folder_browser; +pub mod format; +pub mod gradient; +pub mod heatmap; +pub mod install_manager; +pub mod instance_list; +pub mod job_console; +pub mod logs_view; +pub mod modal; +pub mod model_picker; +pub mod monitor; +pub mod onboarding; +pub mod runtime_manager; +pub mod serve_wizard; +pub mod services_manager; +pub mod sparkline; +pub mod tabs; +pub mod theme; +pub mod update_manager; +pub mod widgets; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{ActiveTab, AppState, ConnState, Modal}; +use crate::ui::theme::Theme; + +pub fn draw(f: &mut Frame, state: &mut AppState) { + let theme = state.theme; + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(f.area()); + + state.last_tab_bar_area = Some(outer[1]); + state.last_body_area = Some(outer[2]); + + draw_header(f, outer[0], state, &theme); + tabs::draw_tab_bar(f, outer[1], state.active_tab, &theme); + match state.active_tab { + ActiveTab::Overview => tabs::overview::draw(f, outer[2], state, &theme), + ActiveTab::Hardware => tabs::hardware::draw(f, outer[2], state, &theme), + ActiveTab::Instances => tabs::instances::draw(f, outer[2], state, &theme), + ActiveTab::Bench => tabs::bench::draw(f, outer[2], state, &theme), + ActiveTab::Chat => tabs::chat::draw(f, outer[2], state, &theme), + } + draw_footer(f, outer[3], state, &theme); + + // Modal overlay (rendered last so it sits on top of the body). + match state.modal { + Modal::None => {} + Modal::Help => modal::draw_help(f, outer[2], state.active_tab, &theme), + Modal::Detail => match state.active_tab { + ActiveTab::Instances => tabs::instances::draw_detail(f, outer[2], state, &theme), + ActiveTab::Bench => tabs::bench::draw_detail(f, outer[2], state, &theme), + ActiveTab::Hardware => tabs::hardware::draw_detail(f, outer[2], state, &theme), + _ => {} + }, + Modal::ThemePicker => modal::draw_theme_picker( + f, + outer[2], + state.theme_picker_sel, + &state.theme_name, + &theme, + ), + } + + // Operational overlays (Phase 3 Wave 1): only one is open at a time. They + // sit above the tab body + modals when open. + if let Some(sm) = &state.services { + services_manager::draw_services_manager( + f, + outer[2], + sm, + &state.instances, + &state.jobs, + &theme, + ); + } else if let Some(w) = &state.serve_wizard { + serve_wizard::draw_serve_wizard(f, outer[2], w, &state.jobs, &state.model_recipes, &theme); + } else if let Some(em) = &state.engine_manager { + engine_manager::draw_engine_manager(f, outer[2], em, &state.jobs, &theme); + } else if let Some(d) = &state.doctor_manager { + doctor_manager::draw_doctor_manager(f, outer[2], d, &state.jobs, &theme); + } else if let Some(u) = &state.update_manager { + update_manager::draw_update_manager(f, outer[2], u, &state.jobs, &theme); + } else if let Some(im) = &state.install_manager { + install_manager::draw_install_manager(f, outer[2], im, &state.jobs, &theme); + } else if let Some(lv) = &state.logs_view { + logs_view::draw_logs_view(f, outer[2], lv, &state.jobs, &theme); + } else if let Some(rm) = &state.runtime_manager { + runtime_manager::draw_runtime_manager( + f, + outer[2], + rm, + &state.runtimes, + &state.jobs, + &theme, + ); + } else if let Some(o) = &state.onboarding { + onboarding::draw_onboarding(f, outer[2], o, &state.jobs, &theme); + } else if let Some(am) = &state.automations_manager { + automations_manager::draw_automations_manager( + f, + outer[2], + am, + &state.automations, + &state.jobs, + &theme, + ); + } else if let Some(c) = &state.command_screen { + command_screen::draw_command_screen(f, outer[2], c, &state.jobs, &theme); + } else if let Some(cm) = &state.config_manager { + config_manager::draw_config_manager(f, outer[2], cm, &state.jobs, &theme); + } +} + +fn draw_header(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let (status_text, status_color) = match &state.conn { + ConnState::Initial => ("starting".to_string(), theme.muted), + ConnState::Connecting => ("connecting…".to_string(), theme.warn), + ConnState::Connected { host, version } => ( + format!("connected · {host} · rocm daemon {version}"), + theme.ok, + ), + ConnState::Disconnected { reason } => (format!("disconnected · {reason}"), theme.err), + }; + + let mut spans: Vec = vec![ + Span::styled( + "rocm.ai", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.accent), + ), + Span::raw(" "), + Span::styled( + format!("→ {}", state.connect), + Style::default().fg(theme.muted), + ), + Span::raw(" "), + Span::styled(status_text, Style::default().fg(status_color)), + ]; + let warning_count = state.latest.as_ref().map(|s| s.warnings.len()).unwrap_or(0); + if warning_count > 0 { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!(" ⚠ {warning_count} "), + Style::default() + .bg(theme.warn) + .fg(theme.surface_2) + .add_modifier(Modifier::BOLD), + )); + } + if let Some(r) = state.replay.as_ref() { + spans.push(Span::raw(" ")); + let (icon, fg) = if r.paused { + ("⏸", theme.warn) + } else { + ("▶", theme.ok) + }; + spans.push(Span::styled( + format!(" {icon} {:.2}× ", r.speed), + Style::default() + .bg(theme.surface_2) + .fg(fg) + .add_modifier(Modifier::BOLD), + )); + if r.total_s > 0 { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!( + "{} / {}", + crate::app::format_mmss(r.elapsed_s), + crate::app::format_mmss(r.total_s) + ), + Style::default().fg(theme.muted), + )); + } + } + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("theme: {}", state.theme_name), + Style::default().fg(theme.muted), + )); + let header = Paragraph::new(vec![Line::from(spans)]).block( + Block::default() + .borders(Borders::ALL) + .border_style(theme.border_style()), + ); + f.render_widget(header, area); +} + +fn draw_footer(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let chip = |k: &str| { + Span::styled( + format!(" {k} "), + Style::default().bg(theme.surface_2).fg(theme.fg), + ) + }; + let mut spans: Vec = vec![ + chip("Tab"), + Span::raw(" next "), + chip("1–5"), + Span::raw(" jump "), + ]; + if matches!( + state.active_tab, + ActiveTab::Hardware | ActiveTab::Instances | ActiveTab::Bench + ) { + spans.push(chip("j/k")); + spans.push(Span::raw(" select ")); + spans.push(chip("Enter")); + spans.push(Span::raw(" detail ")); + } + // Operational-screen entry points (Phase 3 Wave 1). + if matches!(state.active_tab, ActiveTab::Overview | ActiveTab::Instances) { + spans.push(chip("w")); + spans.push(Span::raw(" serve ")); + spans.push(chip("e")); + spans.push(Span::raw(" engines ")); + spans.push(chip("d")); + spans.push(Span::raw(" doctor ")); + spans.push(chip("u")); + spans.push(Span::raw(" update ")); + spans.push(chip("i")); + spans.push(Span::raw(" install ")); + spans.push(chip("l")); + spans.push(Span::raw(" logs ")); + } + if state.active_tab == ActiveTab::Instances { + spans.push(chip("s")); + spans.push(Span::raw(" services ")); + } + if state.replay.is_some() { + spans.push(chip("Space")); + spans.push(Span::raw(" pause ")); + spans.push(chip("+/-")); + spans.push(Span::raw(" speed ")); + } + spans.push(chip("t")); + spans.push(Span::raw(" theme ")); + spans.push(chip("?")); + spans.push(Span::raw(" help ")); + spans.push(chip("q")); + spans.push(Span::raw(" quit")); + f.render_widget(Paragraph::new(Line::from(spans)), area); +} diff --git a/crates/rocm-dash-tui/src/ui/modal.rs b/crates/rocm-dash-tui/src/ui/modal.rs new file mode 100644 index 00000000..e8673cad --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/modal.rs @@ -0,0 +1,390 @@ +//! Centered popup helpers + the Help overlay. +//! +//! Modal content is rendered by the active tab (`detail_modal` from the tab +//! module) or by `draw_help` here. This module owns the geometry and the +//! Clear-then-block pattern so the underlying body shows through the gaps. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +use crate::app::ActiveTab; +use crate::ui::gradient::GradientGauge; +use crate::ui::sparkline::BrailleSparkline; +use crate::ui::theme::{self, Theme}; + +/// Centered rectangle taking `pct_x`% width and `pct_y`% height of `area`, +/// clamped to a maximum so it doesn't drown the screen on big terminals. +pub fn centered_rect(pct_x: u16, pct_y: u16, max_w: u16, max_h: u16, area: Rect) -> Rect { + let h_pct = (area.height * pct_y / 100).min(max_h).max(5); + let v_pad = (area.height.saturating_sub(h_pct)) / 2; + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(v_pad), + Constraint::Length(h_pct), + Constraint::Min(0), + ]) + .split(area); + + let w_pct = (area.width * pct_x / 100).min(max_w).max(20); + let h_pad = (area.width.saturating_sub(w_pct)) / 2; + let horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(h_pad), + Constraint::Length(w_pct), + Constraint::Min(0), + ]) + .split(vert[1]); + + horiz[1] +} + +/// Render a bordered block with `title` over `area` after clearing it, +/// returning the inner area so the caller can render content into it. +pub fn draw_popup_frame(f: &mut Frame, area: Rect, title: &str, theme: &Theme) -> Rect { + f.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(Style::default().fg(theme.accent)) + .title_style( + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ); + let inner = block.inner(area); + f.render_widget(block, area); + inner +} + +/// Shared chrome: a titled popup whose body is a scrollable block of `lines`. +/// Centralizes the `draw_modal_*` pattern so operational screens don't rebuild +/// it (Phase 3 Wave 0). `scroll` is the first visible line offset. +pub fn draw_scrollable_lines( + f: &mut Frame, + area: Rect, + title: &str, + lines: Vec, + scroll: u16, + theme: &Theme, +) { + let inner = draw_popup_frame(f, area, title, theme); + if inner.height == 0 { + return; + } + let p = Paragraph::new(lines) + .scroll((scroll, 0)) + .wrap(Wrap { trim: false }); + f.render_widget(p, inner); +} + +/// Render the Help modal for the active tab. +pub fn draw_help(f: &mut Frame, area: Rect, tab: ActiveTab, theme: &Theme) { + let popup = centered_rect(70, 70, 80, 22, area); + let inner = draw_popup_frame(f, popup, "Help", theme); + + let mut lines: Vec = vec![ + key_line("q", "quit", theme), + key_line("?", "toggle this help", theme), + key_line("Tab / Shift-Tab", "next / previous tab", theme), + key_line("1 .. 5", "jump to tab", theme), + key_line("t", "open theme picker", theme), + key_line("Space", "pause / resume (replay only)", theme), + key_line("+ / -", "speed up / slow down (replay only)", theme), + key_line("[ / ]", "jump ±10s (replay only)", theme), + key_line("{ / }", "jump ±60s (replay only)", theme), + Line::raw(""), + ]; + let tab_help: &[(&str, &str)] = match tab { + ActiveTab::Overview => &[("(no tab-specific keys)", "")], + ActiveTab::Hardware => &[ + ("j / Down", "select next GPU (scrolls when list overflows)"), + ( + "k / Up", + "select previous GPU (scrolls when list overflows)", + ), + ("g / Home", "first GPU"), + ("G / End", "last GPU"), + ("Enter", "open GPU detail"), + ("Esc / Enter", "close any modal"), + ], + ActiveTab::Instances => &[ + ("j / Down", "select next instance"), + ("k / Up", "select previous instance"), + ("g / Home", "first instance"), + ("G / End", "last instance"), + ("Enter", "open instance detail"), + ("Esc / Enter", "close any modal"), + ], + ActiveTab::Bench => &[ + ("j / Down", "select next bench row"), + ("k / Up", "select previous bench row"), + ("g / Home", "first row"), + ("G / End", "last row (newest)"), + ("Enter", "open row detail"), + ("Esc / Enter", "close any modal"), + ], + ActiveTab::Chat => &[ + ("y / Enter", "accept the detected endpoint (consent prompt)"), + ("n", "decline / disable chat"), + ("d", "detect a local engine (gate)"), + ( + "y / s / n", + "detected engine: use now / use & save / dismiss", + ), + ("i / Enter", "focus the input (insert mode, once enabled)"), + ("Esc", "leave insert mode"), + ("Enter", "send the message (while focused)"), + ("Backspace", "delete a character (while focused)"), + ], + }; + lines.push(Line::from(Span::styled( + format!("— {tab:?} tab —"), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::BOLD), + ))); + for (k, desc) in tab_help { + lines.push(key_line(k, desc, theme)); + } + + let p = Paragraph::new(lines).wrap(Wrap { trim: false }); + f.render_widget(p, inner); +} + +fn key_line<'a>(key: &'a str, desc: &'a str, theme: &Theme) -> Line<'a> { + Line::from(vec![ + Span::styled( + format!(" {key:<18} "), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(desc, Style::default().fg(theme.fg)), + ]) +} + +/// Theme picker modal. Renders the registered themes as a scrollable list +/// with a five-color swatch preview per entry. The cursor row is highlighted. +/// +/// `sel` is the picker cursor; clamped against `theme_names().len()`. +/// `current_name` is the currently-active theme name (rendered with a marker). +pub fn draw_theme_picker( + f: &mut Frame, + area: Rect, + sel: usize, + current_name: &str, + active_theme: &Theme, +) { + let popup = centered_rect(80, 80, 110, 30, area); + let inner = draw_popup_frame( + f, + popup, + "Theme — j/k select, Enter apply, Esc cancel", + active_theme, + ); + if inner.height == 0 { + return; + } + + // Split into list (left) + live preview (right). When the popup is too + // narrow for both, fall back to list-only. + let split = if inner.width >= 60 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(38), Constraint::Min(20)]) + .split(inner) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0)]) + .split(inner) + }; + + draw_theme_list(f, split[0], sel, current_name, active_theme); + if split.len() == 2 { + let names = theme::theme_names(); + if let Some(name) = names.get(sel) { + let preview_theme = Theme::from_name(name); + draw_theme_preview(f, split[1], &preview_theme, active_theme); + } + } +} + +fn draw_theme_list( + f: &mut Frame, + inner: Rect, + sel: usize, + current_name: &str, + active_theme: &Theme, +) { + let names = theme::theme_names(); + let visible = inner.height as usize; + let start = if sel >= visible { + sel.saturating_sub(visible - 1) + } else { + 0 + }; + let end = (start + visible).min(names.len()); + + let mut lines: Vec = Vec::with_capacity(visible); + for (i, name) in names[start..end].iter().enumerate() { + let idx = start + i; + let theme = Theme::from_name(name); + let marker = if name == ¤t_name { "●" } else { " " }; + let selected = idx == sel; + + // Five-color swatch: bg / accent / ok / warn / err. + let swatch = vec![ + Span::styled(" ██ ", Style::default().fg(theme.bg)), + Span::styled("██ ", Style::default().fg(theme.accent)), + Span::styled("██ ", Style::default().fg(theme.ok)), + Span::styled("██ ", Style::default().fg(theme.warn)), + Span::styled("██ ", Style::default().fg(theme.err)), + ]; + + let label_style = if selected { + Style::default() + .bg(active_theme.surface_2) + .fg(active_theme.fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(active_theme.fg) + }; + let marker_style = Style::default() + .fg(active_theme.accent) + .add_modifier(Modifier::BOLD); + + let mut spans: Vec = Vec::with_capacity(8); + spans.push(Span::styled(format!(" {marker} "), marker_style)); + spans.extend(swatch); + spans.push(Span::styled(format!(" {name}"), label_style)); + lines.push(Line::from(spans)); + } + f.render_widget(Paragraph::new(lines), inner); +} + +/// Live preview of a candidate theme. Renders a compact composition that +/// exercises the colors most-affected by a theme switch: bg/fg contrast, +/// accent, the ok/warn/err triple, and the gradient ramp. +/// +/// `preview_theme` is the theme being previewed (the one the cursor is on). +/// `active_theme` is the currently-applied theme — used only for the inner +/// title border / label color so the preview frame stays consistent with +/// the surrounding modal even when the previewed bg is light/dark inverse. +pub fn draw_theme_preview(f: &mut Frame, area: Rect, preview_theme: &Theme, active_theme: &Theme) { + let block = Block::default() + .borders(Borders::ALL) + .title(" preview ") + .border_style(Style::default().fg(active_theme.muted)) + .title_style( + Style::default() + .fg(active_theme.muted) + .add_modifier(Modifier::BOLD), + ); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 || inner.width == 0 { + return; + } + + // Paint the preview canvas with the candidate theme's bg so contrast + // against the rest of the modal is visible at a glance. + f.render_widget(Clear, inner); + let bg_fill = Paragraph::new("").style(Style::default().bg(preview_theme.bg)); + f.render_widget(bg_fill, inner); + + // Stacked rows: header, gauge, sparkline, badges. + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(inner); + + // Row 0: mock header line. + let header = Paragraph::new(Line::from(vec![ + Span::styled( + "rocm.ai", + Style::default() + .fg(preview_theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" → connected", Style::default().fg(preview_theme.muted)), + ])) + .style(Style::default().bg(preview_theme.bg)); + f.render_widget(header, rows[0]); + + // Row 1: gradient memory gauge at 73%. + let label = "73.0%"; + let gauge = GradientGauge::new(0.73) + .stops(preview_theme.ok, preview_theme.warn, preview_theme.err) + .track_bg(preview_theme.surface_2) + .label(label) + .label_fg(preview_theme.fg); + f.render_widget(gauge, rows[1]); + + // Row 2: gradient sparkline over a deterministic sine-ish series so the + // preview is visually rich and stable. + let data: Vec = (0..rows[2].width as usize) + .map(|i| { + let t = i as f64 / rows[2].width.max(1) as f64; + // Two-bump curve so the gradient sweeps through all three stops. + let v = ((t * std::f64::consts::PI * 2.0).sin() * 40.0 + 60.0).max(2.0); + v as u64 + }) + .collect(); + let spark = BrailleSparkline::new(&data) + .max(100) + .style(Style::default().fg(preview_theme.accent)) + .gradient(preview_theme.ok, preview_theme.warn, preview_theme.err); + f.render_widget(spark, rows[2]); + + // Row 3 (flexible): three status badges + a footer-style accent_2 span. + let badges = Paragraph::new(vec![ + Line::from(vec![ + badge(" OK ", preview_theme.ok, preview_theme), + Span::raw(" "), + badge(" WARN ", preview_theme.warn, preview_theme), + Span::raw(" "), + badge(" ERR ", preview_theme.err, preview_theme), + ]), + Line::from(Span::styled( + " info", + Style::default().fg(preview_theme.accent_2), + )), + Line::from(Span::styled( + " muted text reads here", + Style::default().fg(preview_theme.muted), + )), + ]) + .style(Style::default().bg(preview_theme.bg)); + f.render_widget(badges, rows[3]); + + // Bottom row: theme name in the previewed fg so you see fg/bg contrast. + let footer = Paragraph::new(Line::from(Span::styled( + " preview rendered with the highlighted theme ", + Style::default() + .fg(preview_theme.fg) + .bg(preview_theme.surface_2), + ))); + f.render_widget(footer, rows[4]); +} + +fn badge<'a>(label: &'a str, bg: ratatui::style::Color, preview_theme: &Theme) -> Span<'a> { + Span::styled( + label, + Style::default() + .bg(bg) + .fg(preview_theme.bg) + .add_modifier(Modifier::BOLD), + ) +} diff --git a/crates/rocm-dash-tui/src/ui/model_picker.rs b/crates/rocm-dash-tui/src/ui/model_picker.rs new file mode 100644 index 00000000..71dfe62f --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/model_picker.rs @@ -0,0 +1,318 @@ +//! Model picker (Phase 3 Wave 1). +//! +//! A reusable, filterable list of model recipes — the sub-step the serve wizard +//! opens from its Model field so the user can pick a built-in recipe instead of +//! typing a model name. Selecting a recipe fills the model id and (when known) +//! pre-selects the recipe's preferred engine. +//! +//! Recipes are passed in as plain TUI-local [`ModelRecipeSummary`] values — the +//! bin (`apps/rocm`, which has `rocm-core`) adapts the full `ModelRecipeRecord` +//! registry into these summaries, so this layer needs no `rocm-core` dep +//! (invariant `LRN-20260405-004`). Navigation/selection is pure and testable. + +use crossterm::event::KeyCode; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// A flattened model-recipe entry for the picker. Mirrors the fields of +/// `rocm-core::ModelRecipeRecord` the picker needs, with no core dependency. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelRecipeSummary { + /// Canonical model id (what `rocm serve` is invoked with). + pub id: String, + /// Aliases the recipe also matches (searchable). + pub aliases: Vec, + /// Human task label (e.g. "chat", "embedding"). + pub task: String, + /// Preferred serving engine, if the recipe declares one. + pub preferred_engine: Option, +} + +/// Picker state: a filter query + a cursor into the filtered list. +#[derive(Debug, Clone, Default)] +pub struct ModelPicker { + pub query: String, + pub selected: usize, +} + +/// The result of handling a key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PickerOutcome { + /// Key consumed, still picking. + None, + /// The user chose a recipe. The caller owns it. + Chosen(ModelRecipeSummary), + /// The user dismissed the picker. + Cancelled, +} + +impl ModelPicker { + /// The recipes matching the current query (case-insensitive substring over + /// id + aliases). An empty query matches everything. + pub fn filtered<'a>(&self, recipes: &'a [ModelRecipeSummary]) -> Vec<&'a ModelRecipeSummary> { + if self.query.trim().is_empty() { + return recipes.iter().collect(); + } + let q = self.query.to_lowercase(); + recipes + .iter() + .filter(|r| { + r.id.to_lowercase().contains(&q) + || r.aliases.iter().any(|a| a.to_lowercase().contains(&q)) + }) + .collect() + } + + /// Pure key handler. Filesystem-free; only mutates the cursor/query. + pub fn on_key(&mut self, key: KeyCode, recipes: &[ModelRecipeSummary]) -> PickerOutcome { + let len = self.filtered(recipes).len(); + match key { + KeyCode::Esc => PickerOutcome::Cancelled, + KeyCode::Up => { + self.selected = self.selected.saturating_sub(1); + PickerOutcome::None + } + KeyCode::Down => { + if len > 0 { + self.selected = (self.selected + 1).min(len - 1); + } + PickerOutcome::None + } + KeyCode::Enter => self + .filtered(recipes) + .get(self.selected) + .map(|r| PickerOutcome::Chosen((*r).clone())) + .unwrap_or(PickerOutcome::None), + KeyCode::Backspace => { + self.query.pop(); + self.selected = 0; + PickerOutcome::None + } + KeyCode::Char(c) => { + self.query.push(c); + self.selected = 0; + PickerOutcome::None + } + _ => PickerOutcome::None, + } + } +} + +/// Render the picker over `area`. +pub fn draw_model_picker( + f: &mut Frame, + area: Rect, + picker: &ModelPicker, + recipes: &[ModelRecipeSummary], + theme: &Theme, +) { + let popup = centered_rect(76, 78, 110, 28, area); + let inner = draw_popup_frame(f, popup, "Pick a model recipe", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + let query_display = if picker.query.is_empty() { + "(type to filter)".to_string() + } else { + picker.query.clone() + }; + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("filter: ", Style::default().fg(theme.muted)), + Span::styled( + query_display, + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ), + ])), + rows[0], + ); + + let filtered = picker.filtered(recipes); + if filtered.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "No recipes match. Backspace to widen, or Esc to type a name directly.", + Style::default().fg(theme.muted), + ))), + rows[1], + ); + } else { + let items: Vec = filtered + .iter() + .map(|r| { + let eng = r.preferred_engine.as_deref().unwrap_or("—"); + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:<30}", trunc(&r.id, 30)), + Style::default().fg(theme.fg), + ), + Span::styled( + format!("{:<12}", trunc(&r.task, 12)), + Style::default().fg(theme.muted), + ), + Span::styled(format!("engine: {eng}"), Style::default().fg(theme.accent)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(picker.selected.min(filtered.len().saturating_sub(1)))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[1], &mut ls); + } + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "type filter · ↑↓ select · Enter choose · Esc cancel", + Style::default().fg(theme.muted), + ))), + rows[2], + ); +} + +fn trunc(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let keep: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{keep}…") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn recipes() -> Vec { + vec![ + ModelRecipeSummary { + id: "Qwen3-4B-Instruct".into(), + aliases: vec!["qwen".into()], + task: "chat".into(), + preferred_engine: Some("lemonade".into()), + }, + ModelRecipeSummary { + id: "GLM-4".into(), + aliases: vec!["glm".into()], + task: "chat".into(), + preferred_engine: Some("vllm".into()), + }, + ModelRecipeSummary { + id: "Llama-3.2-3B".into(), + aliases: vec!["llama".into()], + task: "chat".into(), + preferred_engine: None, + }, + ] + } + + #[test] + fn empty_query_matches_all() { + let p = ModelPicker::default(); + assert_eq!(p.filtered(&recipes()).len(), 3); + } + + #[test] + fn filter_matches_id_and_alias_case_insensitive() { + let rs = recipes(); + let mut p = ModelPicker { + query: "QWEN".into(), + ..Default::default() + }; + let f = p.filtered(&rs); + assert_eq!(f.len(), 1); + assert_eq!(f[0].id, "Qwen3-4B-Instruct"); + // Alias match. + p.query = "glm".into(); + assert_eq!(p.filtered(&rs)[0].id, "GLM-4"); + } + + #[test] + fn typing_filters_and_resets_cursor() { + let mut p = ModelPicker { + selected: 2, + ..Default::default() + }; + let out = p.on_key(KeyCode::Char('g'), &recipes()); + assert_eq!(out, PickerOutcome::None); + assert_eq!(p.query, "g"); + assert_eq!(p.selected, 0); + } + + #[test] + fn enter_chooses_the_selected_filtered_recipe() { + let mut p = ModelPicker { + query: "glm".into(), + ..Default::default() + }; + let out = p.on_key(KeyCode::Enter, &recipes()); + match out { + PickerOutcome::Chosen(r) => { + assert_eq!(r.id, "GLM-4"); + assert_eq!(r.preferred_engine.as_deref(), Some("vllm")); + } + other => panic!("expected Chosen, got {other:?}"), + } + } + + #[test] + fn down_clamps_to_filtered_len() { + let mut p = ModelPicker::default(); + for _ in 0..10 { + p.on_key(KeyCode::Down, &recipes()); + } + assert_eq!(p.selected, 2); + } + + #[test] + fn enter_with_no_match_is_none() { + let mut p = ModelPicker { + query: "zzz-nothing".into(), + ..Default::default() + }; + assert_eq!(p.on_key(KeyCode::Enter, &recipes()), PickerOutcome::None); + } + + #[test] + fn esc_cancels() { + let mut p = ModelPicker::default(); + assert_eq!(p.on_key(KeyCode::Esc, &recipes()), PickerOutcome::Cancelled); + } + + #[test] + fn snapshot_lists_recipes() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(116, 24); + let mut term = Terminal::new(backend).unwrap(); + let p = ModelPicker::default(); + let rs = recipes(); + term.draw(|f| draw_model_picker(f, f.area(), &p, &rs, &theme)) + .unwrap(); + let buf = term.backend().buffer().clone(); + let out: String = buf.content().iter().map(|c| c.symbol()).collect(); + assert!(out.contains("Pick a model recipe")); + assert!(out.contains("Qwen3-4B-Instruct")); + assert!(out.contains("engine")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/monitor.rs b/crates/rocm-dash-tui/src/ui/monitor.rs new file mode 100644 index 00000000..775f054f --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/monitor.rs @@ -0,0 +1,4 @@ +//! 6-panel monitor layout placeholder. Modeled on ctux's `crates/tui/src/ui/monitor.rs`. + +// TODO: split the main area into 6 panels — host CPU/mem, GPU sparklines, instance list, +// per-instance KV cache, request queue, bench-row scroll. diff --git a/crates/rocm-dash-tui/src/ui/onboarding.rs b/crates/rocm-dash-tui/src/ui/onboarding.rs new file mode 100644 index 00000000..020e15da --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/onboarding.rs @@ -0,0 +1,656 @@ +//! Onboarding wizard (Phase 3 Wave 2, minimal). +//! +//! The first-run flow, rebuilt on the Wave-0 primitives. This is the **minimal** +//! version the phase-3 plan calls for: get a clean machine to a working ROCm +//! runtime two ways — +//! +//! - **Install ROCm SDK** — a one-shot gated `rocm install sdk --channel release +//! --format wheel`. +//! - **Adopt existing folder** — pick an existing ROCm env with the Wave-0 +//! [`FolderBrowser`], then approve `rocm runtimes adopt`. +//! +//! Reinstall / uninstall / show-log sub-modals (the full frozen onboarding), +//! first-run auto-trigger + `onboarding_dismissed` persistence, and the frozen +//! flow's post-install `reconcile_onboarding_engine_preference` are documented +//! fast-follows — this overlay is additive and key-triggered (`n`), so it never +//! touches the frozen tui.rs first-run gate. Both paths run through the approval +//! gate and the job-bridge — zero `std::thread::spawn`/`try_recv`. + +use std::path::Path; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::state::{JobStatus, SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::folder_browser::{FolderBrowser, FolderOutcome, draw_folder_browser}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// Which step of the wizard is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OnboardingStep { + #[default] + Welcome, + Choose, + Done, +} + +/// The two minimal setup paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingChoice { + InstallSdk, + AdoptExisting, +} + +/// Menu order; `choice` indexes this. +pub const CHOICES: &[OnboardingChoice] = &[ + OnboardingChoice::InstallSdk, + OnboardingChoice::AdoptExisting, +]; + +impl OnboardingChoice { + fn label(self) -> &'static str { + match self { + OnboardingChoice::InstallSdk => "Install ROCm SDK (release · pip)", + OnboardingChoice::AdoptExisting => "Adopt an existing ROCm folder", + } + } + + fn job_id(self) -> &'static str { + match self { + OnboardingChoice::InstallSdk => "onboard-install", + OnboardingChoice::AdoptExisting => "onboard-adopt", + } + } + + fn title(self) -> &'static str { + match self { + OnboardingChoice::InstallSdk => "install ROCm SDK", + OnboardingChoice::AdoptExisting => "adopt existing ROCm folder", + } + } + + fn explanation(self) -> &'static str { + match self { + OnboardingChoice::InstallSdk => { + "This downloads and installs TheRock ROCm wheels on this machine." + } + OnboardingChoice::AdoptExisting => { + "This registers an existing ROCm folder without modifying it." + } + } + } +} + +/// An approved-but-not-yet-run onboarding op. +#[derive(Debug, Clone)] +pub struct PendingOnboard { + pub choice: OnboardingChoice, + pub cmd: String, + pub args: Vec, + pub request: ApprovalRequest, + pub approval_choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the wizard is closed. +#[derive(Debug, Clone, Default)] +pub struct OnboardingState { + pub step: OnboardingStep, + pub choice: usize, + pub browser: Option, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Derive the conventional Python executable inside an env root (matches the +/// runtime_manager / rocm-core per-host layout without a `rocm-core` dep). +fn derive_python_executable(root: &str) -> String { + let root = Path::new(root); + let path = if cfg!(windows) { + root.join("Scripts").join("python.exe") + } else { + root.join("bin").join("python") + }; + path.to_string_lossy().into_owned() +} + +/// Handle a key while the wizard is open. +pub fn on_key( + ob: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(o) = ob.as_mut() else { + return Vec::new(); + }; + + // 1) Adopt folder browser has focus. + if let Some(fb) = o.browser.as_mut() { + match fb.on_key(key.code) { + FolderOutcome::Chosen(path) => { + o.browser = None; + let root = path.to_string_lossy().into_owned(); + let args = vec![ + "runtimes".to_string(), + "adopt".to_string(), + "--root".to_string(), + root.clone(), + "--python".to_string(), + derive_python_executable(&root), + ]; + stage_approval(o, OnboardingChoice::AdoptExisting, args); + } + FolderOutcome::Cancelled => o.browser = None, + FolderOutcome::None | FolderOutcome::Navigated => {} + } + return Vec::new(); + } + + // 2) Approval modal has focus. + if let Some(pending) = o.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.approval_choice); + pending.approval_choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = o.approval.take() { + return spawn_onboard(o, jobs, pending.choice, pending.cmd, pending.args); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => o.approval = None, + None => {} + } + return Vec::new(); + } + + // 3) A job is showing in the console. + if let Some(job_id) = o.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *ob = None, + ConsoleOutcome::Dismissed => { + // Only a clean exit (code 0) advances to Done. A failed or + // cancelled job returns to the Choose step with an honest + // message so the wizard never claims success it didn't earn. + let ok = jobs + .job(&job_id) + .map(|j| matches!(j.status, JobStatus::Done { code: 0 })) + .unwrap_or(false); + o.active_job = None; + if ok { + o.message = None; + o.step = OnboardingStep::Done; + } else { + o.message = Some( + "That step didn't finish cleanly — review the output, then try again." + .to_string(), + ); + o.step = OnboardingStep::Choose; + } + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 4) Step navigation. + match o.step { + OnboardingStep::Welcome => match key.code { + KeyCode::Esc | KeyCode::Char('q') => *ob = None, + KeyCode::Enter => o.step = OnboardingStep::Choose, + _ => {} + }, + OnboardingStep::Choose => match key.code { + KeyCode::Esc | KeyCode::Char('q') => *ob = None, + KeyCode::Up | KeyCode::Char('k') => o.choice = o.choice.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + o.choice = (o.choice + 1).min(CHOICES.len() - 1); + } + KeyCode::Enter => return activate_choice(o), + _ => {} + }, + OnboardingStep::Done => match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => *ob = None, + _ => {} + }, + } + Vec::new() +} + +/// Run the selected choice: install stages an approval; adopt opens the picker +/// first (its approval is staged once a folder is chosen). +fn activate_choice(o: &mut OnboardingState) -> Vec { + match CHOICES[o.choice.min(CHOICES.len() - 1)] { + OnboardingChoice::InstallSdk => { + let args = vec![ + "install".to_string(), + "sdk".to_string(), + "--channel".to_string(), + "release".to_string(), + "--format".to_string(), + "wheel".to_string(), + ]; + stage_approval(o, OnboardingChoice::InstallSdk, args); + } + OnboardingChoice::AdoptExisting => { + let start = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + o.browser = Some(FolderBrowser::new("Pick an existing ROCm folder", start)); + } + } + Vec::new() +} + +/// Stage an approval for a setup op (no job yet). +fn stage_approval(o: &mut OnboardingState, choice: OnboardingChoice, args: Vec) { + let cmd = resolve_exe(); + let request = ApprovalRequest::new( + choice.title().to_string(), + vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + choice.explanation().to_string(), + ], + ); + o.message = None; + o.approval = Some(PendingOnboard { + choice, + cmd, + args, + request, + approval_choice: ApprovalChoice::default(), + }); +} + +/// Spawn the approved setup job. +fn spawn_onboard( + o: &mut OnboardingState, + jobs: &mut State, + choice: OnboardingChoice, + cmd: String, + args: Vec, +) -> Vec { + let id = choice.job_id().to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args, + }); + if fx.is_empty() { + o.message = Some(format!("“{}” is already running", choice.title())); + return fx; + } + o.active_job = Some(id); + fx +} + +/// Render the wizard (current step, or a browser/approval/console on top). +pub fn draw_onboarding( + f: &mut Frame, + area: Rect, + o: &OnboardingState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &o.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(72, 64, 96, 18, area); + let inner = draw_popup_frame(f, popup, "Welcome to ROCm — first-run setup", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + match o.step { + OnboardingStep::Welcome => { + let body = vec![ + Line::from(Span::styled( + "Let's get ROCm set up on this machine.", + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "You can install the ROCm SDK fresh, or adopt an existing ROCm", + Style::default().fg(theme.muted), + )), + Line::from(Span::styled( + "folder you already have.", + Style::default().fg(theme.muted), + )), + ]; + f.render_widget(Paragraph::new(body), rows[0]); + } + OnboardingStep::Choose => { + let items: Vec = CHOICES + .iter() + .map(|c| { + ListItem::new(Line::from(vec![ + Span::styled(c.label().to_string(), Style::default().fg(theme.fg)), + Span::styled(" (needs approval)", Style::default().fg(theme.warn)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(o.choice.min(CHOICES.len() - 1))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[0], &mut ls); + } + OnboardingStep::Done => { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "Setup step finished. You're ready to go — open the dashboard \ + or chat to get started.", + Style::default().fg(theme.ok).add_modifier(Modifier::BOLD), + ))), + rows[0], + ); + } + } + + let msg = o.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[1], + ); + + let hint = match o.step { + OnboardingStep::Welcome => "Enter continue · Esc close", + OnboardingStep::Choose => "↑↓ select · Enter run · Esc close", + OnboardingStep::Done => "Enter/Esc close", + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + hint, + Style::default().fg(theme.muted), + ))), + rows[2], + ); + + if let Some(fb) = &o.browser { + draw_folder_browser(f, area, fb, theme); + } + if let Some(pending) = &o.approval { + draw_approval(f, area, &pending.request, pending.approval_choice, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn welcome_enter_advances_to_choose() { + let mut ob = Some(OnboardingState::default()); + let mut jobs = State::default(); + assert_eq!(ob.as_ref().unwrap().step, OnboardingStep::Welcome); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + assert_eq!(ob.as_ref().unwrap().step, OnboardingStep::Choose); + } + + #[test] + fn welcome_esc_closes() { + let mut ob = Some(OnboardingState::default()); + let mut jobs = State::default(); + on_key(&mut ob, &mut jobs, key(KeyCode::Esc)); + assert!(ob.is_none()); + } + + #[test] + fn install_choice_is_gated_then_spawns() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); // choice 0 = InstallSdk + let mut jobs = State::default(); + // Enter stages approval, NO job. + let fx = on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + let pending = ob.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.choice, OnboardingChoice::InstallSdk); + assert_eq!( + pending.args, + vec![ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel" + ] + ); + assert!(jobs.jobs.is_empty()); + // Approve → spawns. + let fx = on_key(&mut ob, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!( + ob.as_ref().unwrap().active_job.as_deref(), + Some("onboard-install") + ); + } + + #[test] + fn adopt_choice_opens_browser_then_gates_with_python() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + choice: 1, // AdoptExisting + ..Default::default() + }); + let mut jobs = State::default(); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + assert!(ob.as_ref().unwrap().browser.is_some()); + // Enter on row 0 (UseCurrent) chooses cwd → stages adopt approval. + let fx = on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(ob.as_ref().unwrap().browser.is_none()); + let pending = ob.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.choice, OnboardingChoice::AdoptExisting); + assert!(pending.args.iter().any(|a| a == "adopt")); + assert!(pending.args.iter().any(|a| a == "--python")); + let want = if cfg!(windows) { + "python.exe" + } else { + "bin/python" + }; + assert!(pending.args.iter().any(|a| a.contains(want))); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + let mut jobs = State::default(); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); // stage install + let fx = on_key(&mut ob, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(ob.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn job_dismiss_advances_to_done() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + let mut jobs = State::default(); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); // stage + on_key(&mut ob, &mut jobs, key(KeyCode::Char('y'))); // spawn + let id = ob.as_ref().unwrap().active_job.clone().unwrap(); + // Finish the job, then Enter dismisses the console → Done. + jobs.apply(StateEvent::JobDone { id, code: 0 }); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + assert!(ob.as_ref().unwrap().active_job.is_none()); + assert_eq!(ob.as_ref().unwrap().step, OnboardingStep::Done); + // Enter on Done closes the wizard. + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + assert!(ob.is_none()); + } + + #[test] + fn failed_job_returns_to_choose_with_honest_message_not_done() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + let mut jobs = State::default(); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); // stage install + on_key(&mut ob, &mut jobs, key(KeyCode::Char('y'))); // spawn + let id = ob.as_ref().unwrap().active_job.clone().unwrap(); + // The install FAILED (non-zero exit). Dismiss must NOT claim success. + jobs.apply(StateEvent::JobDone { id, code: 1 }); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); + let s = ob.as_ref().unwrap(); + assert!(s.active_job.is_none()); + assert_eq!( + s.step, + OnboardingStep::Choose, + "failure must not reach Done" + ); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("didn't finish cleanly") + ); + } + + #[test] + fn choose_navigation_clamps() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + let mut jobs = State::default(); + for _ in 0..5 { + on_key(&mut ob, &mut jobs, key(KeyCode::Down)); + } + assert_eq!(ob.as_ref().unwrap().choice, CHOICES.len() - 1); + for _ in 0..5 { + on_key(&mut ob, &mut jobs, key(KeyCode::Up)); + } + assert_eq!(ob.as_ref().unwrap().choice, 0); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut ob = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + let mut jobs = State::default(); + on_key(&mut ob, &mut jobs, key(KeyCode::Enter)); // stage + on_key(&mut ob, &mut jobs, key(KeyCode::Char('y'))); // spawn + on_key(&mut ob, &mut jobs, key(KeyCode::Char('q'))); + assert!(ob.is_none()); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + let mut jobs = State::default(); + let mut o1 = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + on_key(&mut o1, &mut jobs, key(KeyCode::Enter)); + on_key(&mut o1, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!( + o1.as_ref().unwrap().active_job.as_deref(), + Some("onboard-install") + ); + // Fresh wizard, same install while the prior job still runs. + let mut o2 = Some(OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }); + on_key(&mut o2, &mut jobs, key(KeyCode::Enter)); // stage + let fx = on_key(&mut o2, &mut jobs, key(KeyCode::Char('y'))); // approve + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = o2.as_ref().unwrap(); + assert!(s.active_job.is_none(), "must not point at the stale job"); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_welcome_and_choose() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + // Welcome. + let backend = TestBackend::new(100, 20); + let mut term = Terminal::new(backend).unwrap(); + let o = OnboardingState::default(); + let jobs = State::default(); + term.draw(|f| draw_onboarding(f, f.area(), &o, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("first-run setup")); + assert!(out.contains("get ROCm set up")); + // Choose. + let backend = TestBackend::new(100, 20); + let mut term = Terminal::new(backend).unwrap(); + let o = OnboardingState { + step: OnboardingStep::Choose, + ..Default::default() + }; + term.draw(|f| draw_onboarding(f, f.area(), &o, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Install ROCm SDK")); + assert!(out.contains("Adopt an existing")); + assert!(out.contains("needs approval")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/runtime_manager.rs b/crates/rocm-dash-tui/src/ui/runtime_manager.rs new file mode 100644 index 00000000..3e53d243 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/runtime_manager.rs @@ -0,0 +1,784 @@ +//! Runtime manager overlay (Phase 3 Wave 2). +//! +//! Lists the registered ROCm runtimes (plumbed in from the bin, which owns +//! `rocm-core` — this crate stays core-free per `LRN-20260405-004`) and runs the +//! `rocm runtimes …` lifecycle verbs: +//! +//! - **refresh** (`rocm runtimes list`) — read-only, spawns straight through the +//! job-bridge. +//! - **activate / rollback / uninstall** — mutating, routed through the approval +//! gate first. +//! - **adopt** — pick an existing ROCm env folder with the Wave-0 +//! [`FolderBrowser`], then approve `rocm runtimes adopt`. +//! - **import** — type a manifest path, then approve `rocm runtimes import`. +//! +//! Zero `std::thread::spawn`/`try_recv`: every command runs as a job-bridge job. + +use std::path::Path; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::folder_browser::{FolderBrowser, FolderOutcome, draw_folder_browser}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// A flattened registered-runtime entry for the list. Mirrors the fields of the +/// bin's `InstalledRuntimeManifest` (+ active/rollback status from config) that +/// the manager needs, with no `rocm-core` dependency. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RuntimeSummary { + /// Stable runtime key (what `rocm runtimes activate ` takes). + pub key: String, + /// Friendly runtime id. + pub id: String, + /// Install channel (release/nightly/…). + pub channel: String, + /// ROCm version string. + pub version: String, + /// Install root path (display only). + pub root: String, + /// Whether this runtime is the active default. + pub active: bool, + /// Whether this runtime is the rollback target (`previous_runtime_key`). + pub rollback: bool, +} + +/// The mutating lifecycle verbs (everything except the read-only refresh). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeAction { + Activate, + Rollback, + Uninstall, + Adopt, + Import, +} + +impl RuntimeAction { + /// Stable job id (one console per verb). + fn job_id(self) -> &'static str { + match self { + RuntimeAction::Activate => "runtimes-activate", + RuntimeAction::Rollback => "runtimes-rollback", + RuntimeAction::Uninstall => "runtimes-uninstall", + RuntimeAction::Adopt => "runtimes-adopt", + RuntimeAction::Import => "runtimes-import", + } + } + + fn title(self) -> &'static str { + match self { + RuntimeAction::Activate => "activate runtime", + RuntimeAction::Rollback => "roll back to previous runtime", + RuntimeAction::Uninstall => "uninstall runtime", + RuntimeAction::Adopt => "adopt existing ROCm folder", + RuntimeAction::Import => "import runtime manifest", + } + } + + fn explanation(self) -> &'static str { + match self { + RuntimeAction::Activate => "This switches the default ROCm runtime for this machine.", + RuntimeAction::Rollback => "This restores the previously active ROCm runtime.", + RuntimeAction::Uninstall => "This removes the runtime record from ROCm CLI.", + RuntimeAction::Adopt => "This registers an existing ROCm folder without modifying it.", + RuntimeAction::Import => "This registers a runtime from a saved manifest file.", + } + } +} + +/// An approved-but-not-yet-run mutating runtime op. +#[derive(Debug, Clone)] +pub struct PendingRuntime { + pub action: RuntimeAction, + pub cmd: String, + pub args: Vec, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. The runtime +/// list itself lives on `AppState` (plumbed from the bin), not here. +#[derive(Debug, Clone, Default)] +pub struct RuntimeManagerState { + pub selected: usize, + /// Adopt folder picker (env root). `Some` = picker has focus. + pub browser: Option, + /// Import manifest path prompt. `Some` = the text prompt has focus. + pub import_input: Option, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Derive the conventional Python executable inside an env root, matching +/// rocm-core's per-host layout (`Scripts\python.exe` on Windows, `bin/python` +/// elsewhere) without taking a `rocm-core` dependency. The approval modal shows +/// the exact argv, so the user verifies it before anything runs. +fn derive_python_executable(root: &str) -> String { + let root = Path::new(root); + let path = if cfg!(windows) { + root.join("Scripts").join("python.exe") + } else { + root.join("bin").join("python") + }; + path.to_string_lossy().into_owned() +} + +/// Handle a key while the overlay is open. `runtimes` is the plumbed list. +pub fn on_key( + rm: &mut Option, + runtimes: &[RuntimeSummary], + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(r) = rm.as_mut() else { + return Vec::new(); + }; + + // 1) Adopt folder browser has focus. + if let Some(fb) = r.browser.as_mut() { + match fb.on_key(key.code) { + FolderOutcome::Chosen(path) => { + r.browser = None; + let root = path.to_string_lossy().into_owned(); + let args = vec![ + "runtimes".to_string(), + "adopt".to_string(), + "--root".to_string(), + root.clone(), + "--python".to_string(), + derive_python_executable(&root), + ]; + stage_approval(r, RuntimeAction::Adopt, args); + } + FolderOutcome::Cancelled => r.browser = None, + FolderOutcome::None | FolderOutcome::Navigated => {} + } + return Vec::new(); + } + + // 2) Import path prompt has focus. + if let Some(input) = r.import_input.as_mut() { + match key.code { + KeyCode::Esc => r.import_input = None, + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Enter => { + let path = input.trim().to_string(); + if path.is_empty() { + r.message = Some("manifest path is required".to_string()); + r.import_input = None; + } else { + r.import_input = None; + let args = vec!["runtimes".to_string(), "import".to_string(), path]; + stage_approval(r, RuntimeAction::Import, args); + } + } + KeyCode::Char(c) => input.push(c), + _ => {} + } + return Vec::new(); + } + + // 3) Approval modal has focus. + if let Some(pending) = r.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = r.approval.take() { + return spawn_runtime(r, jobs, pending.action, pending.cmd, pending.args); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => r.approval = None, + None => {} + } + return Vec::new(); + } + + // 4) A job is showing in the console. + if let Some(job_id) = r.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *rm = None, + ConsoleOutcome::Dismissed => { + r.active_job = None; + r.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 5) List navigation + actions. + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *rm = None, + KeyCode::Up | KeyCode::Char('k') => r.selected = r.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') if !runtimes.is_empty() => { + r.selected = (r.selected + 1).min(runtimes.len() - 1); + } + KeyCode::Char('l') => return spawn_refresh(r, jobs), + KeyCode::Enter | KeyCode::Char('a') => { + if let Some(rt) = runtimes.get(r.selected) { + let args = vec![ + "runtimes".to_string(), + "activate".to_string(), + rt.key.clone(), + ]; + stage_approval(r, RuntimeAction::Activate, args); + } else { + r.message = Some("no runtime selected".to_string()); + } + } + KeyCode::Char('r') => { + let args = vec!["runtimes".to_string(), "rollback".to_string()]; + stage_approval(r, RuntimeAction::Rollback, args); + } + KeyCode::Char('x') => { + if let Some(rt) = runtimes.get(r.selected) { + let args = vec![ + "runtimes".to_string(), + "uninstall".to_string(), + rt.key.clone(), + ]; + stage_approval(r, RuntimeAction::Uninstall, args); + } else { + r.message = Some("no runtime selected".to_string()); + } + } + KeyCode::Char('o') => { + let start = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + r.browser = Some(FolderBrowser::new("Pick an existing ROCm folder", start)); + } + KeyCode::Char('i') => r.import_input = Some(String::new()), + _ => {} + } + Vec::new() +} + +/// Stage an approval for a mutating verb (no job yet). +fn stage_approval(r: &mut RuntimeManagerState, action: RuntimeAction, args: Vec) { + let cmd = resolve_exe(); + let request = ApprovalRequest::new( + action.title().to_string(), + vec![ + format!("{} {}", exe_label(&cmd), args.join(" ")), + String::new(), + action.explanation().to_string(), + ], + ); + r.message = None; + r.approval = Some(PendingRuntime { + action, + cmd, + args, + request, + choice: ApprovalChoice::default(), + }); +} + +/// Spawn the read-only `rocm runtimes list` refresh. +fn spawn_refresh(r: &mut RuntimeManagerState, jobs: &mut State) -> Vec { + let id = "runtimes-list".to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd: resolve_exe(), + args: vec!["runtimes".to_string(), "list".to_string()], + }); + if fx.is_empty() { + r.message = Some("a runtimes refresh is already running".to_string()); + return fx; + } + r.active_job = Some(id); + fx +} + +/// Spawn the approved mutating job. +fn spawn_runtime( + r: &mut RuntimeManagerState, + jobs: &mut State, + action: RuntimeAction, + cmd: String, + args: Vec, +) -> Vec { + let id = action.job_id().to_string(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args, + }); + if fx.is_empty() { + r.message = Some(format!("“{}” is already running", action.title())); + return fx; + } + r.active_job = Some(id); + fx +} + +/// Render the overlay (list, or a browser/prompt/approval/console on top). +pub fn draw_runtime_manager( + f: &mut Frame, + area: Rect, + r: &RuntimeManagerState, + runtimes: &[RuntimeSummary], + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &r.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(80, 72, 120, 24, area); + let inner = draw_popup_frame(f, popup, "Runtimes — ROCm installs", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + if runtimes.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "No runtimes registered. Press o to adopt an existing folder, \ + i to import a manifest, or l to refresh.", + Style::default().fg(theme.muted), + ))), + rows[0], + ); + } else { + let items: Vec = runtimes + .iter() + .map(|rt| { + let marker = if rt.active { + "● " + } else if rt.rollback { + "↺ " + } else { + " " + }; + let marker_color = if rt.active { + theme.ok + } else if rt.rollback { + theme.warn + } else { + theme.muted + }; + ListItem::new(Line::from(vec![ + Span::styled(marker, Style::default().fg(marker_color)), + Span::styled( + format!("{:<26}", trunc(&rt.key, 26)), + Style::default().fg(theme.fg), + ), + Span::styled( + format!("{:<10}", trunc(&rt.channel, 10)), + Style::default().fg(theme.muted), + ), + Span::styled( + format!("{:<14}", trunc(&rt.version, 14)), + Style::default().fg(theme.accent), + ), + Span::styled(trunc(&rt.root, 40), Style::default().fg(theme.muted)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(r.selected.min(runtimes.len().saturating_sub(1)))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[0], &mut ls); + } + + let msg = r.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[1], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ select · Enter/a activate · r rollback · x uninstall · o adopt · i import · l refresh · Esc close", + Style::default().fg(theme.muted), + ))), + rows[2], + ); + + if let Some(fb) = &r.browser { + draw_folder_browser(f, area, fb, theme); + } + if let Some(input) = &r.import_input { + draw_import_prompt(f, area, input, theme); + } + if let Some(pending) = &r.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +/// Render the import manifest-path prompt. +fn draw_import_prompt(f: &mut Frame, area: Rect, input: &str, theme: &Theme) { + let popup = centered_rect(70, 30, 96, 8, area); + let inner = draw_popup_frame(f, popup, "Import runtime manifest", theme); + if inner.height == 0 { + return; + } + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "Manifest file path:", + Style::default().fg(theme.muted), + ))), + rows[0], + ); + let shown = if input.is_empty() { + "(type a path)".to_string() + } else { + input.to_string() + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + shown, + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ))), + rows[1], + ); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter confirm · Esc cancel", + Style::default().fg(theme.muted), + ))), + rows[2], + ); +} + +fn trunc(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let keep: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{keep}…") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + fn runtimes() -> Vec { + vec![ + RuntimeSummary { + key: "therock-release-gfx94".into(), + id: "rocm-6.4".into(), + channel: "release".into(), + version: "6.4.0".into(), + root: "/opt/rocm-6.4".into(), + active: true, + rollback: false, + }, + RuntimeSummary { + key: "therock-nightly-gfx94".into(), + id: "rocm-nightly".into(), + channel: "nightly".into(), + version: "6.5.0-dev".into(), + root: "/opt/rocm-nightly".into(), + active: false, + rollback: true, + }, + ] + } + + #[test] + fn refresh_is_read_only_and_spawns_without_approval() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut rm, &runtimes(), &mut jobs, key(KeyCode::Char('l'))); + assert_eq!(fx.len(), 1); + assert!( + rm.as_ref().unwrap().approval.is_none(), + "no gate for refresh" + ); + assert_eq!( + rm.as_ref().unwrap().active_job.as_deref(), + Some("runtimes-list") + ); + } + + #[test] + fn activate_is_gated_then_spawns_with_selected_key() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + let rts = runtimes(); + // Enter on row 0 stages approval, NO job. + let fx = on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + let pending = rm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, RuntimeAction::Activate); + assert_eq!( + pending.args, + vec!["runtimes", "activate", "therock-release-gfx94"] + ); + assert!(jobs.jobs.is_empty()); + // Approve → spawns. + let fx = on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!( + rm.as_ref().unwrap().active_job.as_deref(), + Some("runtimes-activate") + ); + } + + #[test] + fn uninstall_targets_the_selected_row() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + let rts = runtimes(); + on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Down)); // select row 1 + on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Char('x'))); + let pending = rm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, RuntimeAction::Uninstall); + assert_eq!( + pending.args, + vec!["runtimes", "uninstall", "therock-nightly-gfx94"] + ); + } + + #[test] + fn rollback_needs_no_selection() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('r'))); + let pending = rm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, RuntimeAction::Rollback); + assert_eq!(pending.args, vec!["runtimes", "rollback"]); + } + + #[test] + fn activate_with_no_runtimes_surfaces_message_not_approval() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + let fx = on_key(&mut rm, &[], &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(rm.as_ref().unwrap().approval.is_none()); + assert!( + rm.as_ref() + .unwrap() + .message + .as_deref() + .unwrap_or("") + .contains("no runtime selected") + ); + } + + #[test] + fn adopt_opens_browser_then_stages_approval_with_python() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('o'))); + assert!(rm.as_ref().unwrap().browser.is_some()); + // Drive the browser: Enter on row 0 = UseCurrent → chooses cwd. + let fx = on_key(&mut rm, &[], &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(rm.as_ref().unwrap().browser.is_none()); + let pending = rm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, RuntimeAction::Adopt); + assert!(pending.args.iter().any(|a| a == "adopt")); + assert!(pending.args.iter().any(|a| a == "--root")); + assert!(pending.args.iter().any(|a| a == "--python")); + // The derived python path uses the host bin/Scripts convention. + let want = if cfg!(windows) { + "python.exe" + } else { + "bin/python" + }; + assert!(pending.args.iter().any(|a| a.contains(want))); + } + + #[test] + fn import_prompt_then_stages_approval() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('i'))); + assert!(rm.as_ref().unwrap().import_input.is_some()); + for c in "/tmp/m.json".chars() { + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char(c))); + } + let fx = on_key(&mut rm, &[], &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(rm.as_ref().unwrap().import_input.is_none()); + let pending = rm.as_ref().unwrap().approval.as_ref().unwrap(); + assert_eq!(pending.action, RuntimeAction::Import); + assert_eq!(pending.args, vec!["runtimes", "import", "/tmp/m.json"]); + } + + #[test] + fn import_empty_path_is_rejected() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('i'))); + let fx = on_key(&mut rm, &[], &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(rm.as_ref().unwrap().approval.is_none()); + assert!(rm.as_ref().unwrap().import_input.is_none()); + assert!( + rm.as_ref() + .unwrap() + .message + .as_deref() + .unwrap_or("") + .contains("required") + ); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + let rts = runtimes(); + on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Enter)); // stage activate + let fx = on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(rm.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn navigation_clamps() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + let rts = runtimes(); + for _ in 0..10 { + on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Down)); + } + assert_eq!(rm.as_ref().unwrap().selected, rts.len() - 1); + for _ in 0..10 { + on_key(&mut rm, &rts, &mut jobs, key(KeyCode::Up)); + } + assert_eq!(rm.as_ref().unwrap().selected, 0); + } + + #[test] + fn q_closes_overlay_when_idle() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('q'))); + assert!(rm.is_none()); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut rm = Some(RuntimeManagerState::default()); + let mut jobs = State::default(); + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('l'))); // refresh spawns + on_key(&mut rm, &[], &mut jobs, key(KeyCode::Char('q'))); + assert!(rm.is_none()); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + // A StartJob for a still-running id no-ops, so spawn_refresh must surface + // a message and NOT point active_job at it. + let mut jobs = State::default(); + let mut rm1 = Some(RuntimeManagerState::default()); + on_key(&mut rm1, &[], &mut jobs, key(KeyCode::Char('l'))); + assert_eq!( + rm1.as_ref().unwrap().active_job.as_deref(), + Some("runtimes-list") + ); + let mut rm2 = Some(RuntimeManagerState::default()); + let fx = on_key(&mut rm2, &[], &mut jobs, key(KeyCode::Char('l'))); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = rm2.as_ref().unwrap(); + assert!(s.active_job.is_none(), "must not point at the stale job"); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn snapshot_lists_runtimes_and_markers() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(120, 22); + let mut term = Terminal::new(backend).unwrap(); + let r = RuntimeManagerState::default(); + let rts = runtimes(); + let jobs = State::default(); + term.draw(|f| draw_runtime_manager(f, f.area(), &r, &rts, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Runtimes")); + assert!(out.contains("therock-release-gfx94")); + assert!(out.contains("activate")); + } + + #[test] + fn snapshot_empty_shows_hint() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(120, 22); + let mut term = Terminal::new(backend).unwrap(); + let r = RuntimeManagerState::default(); + let jobs = State::default(); + term.draw(|f| draw_runtime_manager(f, f.area(), &r, &[], &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("No runtimes registered")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/serve_wizard.rs b/crates/rocm-dash-tui/src/ui/serve_wizard.rs new file mode 100644 index 00000000..de3bd4d6 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/serve_wizard.rs @@ -0,0 +1,957 @@ +//! Serve wizard overlay (Phase 3 Wave 1). +//! +//! The headline operational screen rebuilt on the Wave-0 primitives: a compact +//! form that builds a `rocm serve … --managed` invocation and runs it **through +//! the approval gate and the job-bridge** — never inline, never with a legacy +//! `std::thread::spawn` + `try_recv`. A served model launched here surfaces in +//! the services manager and the dashboard's live `gen_tps` (the D7 wire is +//! already in place via `rocm serve --managed`). +//! +//! The model field can be typed directly (a recipe name, alias, or path) or +//! filled from the reusable Wave-0 [`FolderBrowser`] for a local model path +//! (`Tab` on the Model field). The approval *decision* is the user's, captured +//! by the render+event seam; the CLI owns the actual launch — the read-only +//! chat invariant is untouched. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::folder_browser::{FolderBrowser, FolderOutcome, draw_folder_browser}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::model_picker::{ModelPicker, ModelRecipeSummary, PickerOutcome, draw_model_picker}; +use crate::ui::theme::Theme; + +/// Engine inventory — names mirror `apps/rocm` `engine_inventory()`. Kept +/// TUI-local (a stable, small list) so this layer needs no `rocm-core` dep. +pub const ENGINES: &[&str] = &["lemonade", "pytorch", "llama.cpp", "vllm", "sglang", "atom"]; + +/// Device-policy choices. Index 0 omits `--device` entirely (engine default); +/// the rest mirror `rocm-core`'s validated `gpu_required|gpu_preferred|cpu_only`. +pub const DEVICES: &[&str] = &[ + "(engine default)", + "gpu_required", + "gpu_preferred", + "cpu_only", +]; + +/// Mirrors `rocm-core::DEFAULT_LOCAL_HOST` / `DEFAULT_LOCAL_PORT` (TUI-local to +/// avoid the dep; the CLI re-applies its own defaults if these are cleared). +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_PORT: &str = "11435"; + +/// The form fields, in vertical order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Field { + Model, + Engine, + Device, + Host, + Port, + Mode, + Launch, +} + +/// Field order; `state.field` indexes this. +pub const FIELDS: &[Field] = &[ + Field::Model, + Field::Engine, + Field::Device, + Field::Host, + Field::Port, + Field::Mode, + Field::Launch, +]; + +/// An approved-but-not-yet-launched serve invocation. +#[derive(Debug, Clone)] +pub struct PendingServe { + /// Resolved `rocm` binary path (captured at approval time so a later + /// `current_exe()` failure can't silently drop an approved launch). + pub cmd: String, + /// The argv after the binary (`["serve", model, "--engine", …]`). + pub args: Vec, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the wizard is closed. +#[derive(Debug, Clone)] +pub struct ServeWizardState { + pub field: usize, + pub model: String, + pub engine_idx: usize, + pub device_idx: usize, + pub host: String, + pub port: String, + pub managed: bool, + /// Local-path picker (Wave-0 primitive); `Some` while browsing. + pub browser: Option, + /// Model-recipe picker sub-step; `Some` while choosing a recipe. + pub picker: Option, + /// Approval modal; `Some` while a launch is gated. + pub approval: Option, + /// In-flight (or just-finished) launch job id. + pub active_job: Option, + /// Transient validation message (e.g. empty model / bad port). + pub message: Option, +} + +impl Default for ServeWizardState { + fn default() -> Self { + Self { + field: 0, + model: String::new(), + engine_idx: 0, + device_idx: 0, + host: DEFAULT_HOST.to_string(), + port: DEFAULT_PORT.to_string(), + managed: true, + browser: None, + picker: None, + approval: None, + active_job: None, + message: None, + } + } +} + +impl ServeWizardState { + fn current_field(&self) -> Field { + FIELDS[self.field.min(FIELDS.len() - 1)] + } + + fn move_field(&mut self, delta: isize) { + let max = FIELDS.len() as isize - 1; + self.field = (self.field as isize + delta).clamp(0, max) as usize; + } + + fn cycle(&mut self, delta: isize) { + match self.current_field() { + Field::Engine => self.engine_idx = cycle_idx(self.engine_idx, ENGINES.len(), delta), + Field::Device => self.device_idx = cycle_idx(self.device_idx, DEVICES.len(), delta), + Field::Mode => self.managed = !self.managed, + _ => {} + } + } + + fn type_char(&mut self, c: char) { + match self.current_field() { + Field::Model => self.model.push(c), + Field::Host => self.host.push(c), + // Port accepts digits only — never builds an unparseable `--port`. + Field::Port if c.is_ascii_digit() => self.port.push(c), + _ => {} + } + } + + fn backspace(&mut self) { + match self.current_field() { + Field::Model => { + self.model.pop(); + } + Field::Host => { + self.host.pop(); + } + Field::Port => { + self.port.pop(); + } + _ => {} + } + } + + /// Build the `rocm` argv for the current form, or an error message. + fn build_args(&self) -> Result, String> { + let model = self.model.trim(); + if model.is_empty() { + return Err("model is required".to_string()); + } + let mut args = vec!["serve".to_string(), model.to_string()]; + args.push("--engine".to_string()); + args.push(ENGINES[self.engine_idx.min(ENGINES.len() - 1)].to_string()); + // Index 0 = engine default → omit --device. + if self.device_idx > 0 { + args.push("--device".to_string()); + args.push(DEVICES[self.device_idx.min(DEVICES.len() - 1)].to_string()); + } + let host = self.host.trim(); + if !host.is_empty() { + args.push("--host".to_string()); + args.push(host.to_string()); + } + let port = self.port.trim(); + if !port.is_empty() { + // u16 accepts 0, but 0 is not a bindable listen port — reject it so + // the error surfaces in the form, not as a downstream bind failure. + match port.parse::() { + Ok(p) if p > 0 => { + args.push("--port".to_string()); + args.push(p.to_string()); + } + _ => return Err(format!("port `{port}` is not a valid 1–65535 value")), + } + } + // Managed (default) hands supervision to the daemon → it shows up in the + // services manager + dashboard gen_tps. Foreground runs in this job. + if self.managed { + args.push("--managed".to_string()); + } else { + args.push("--foreground".to_string()); + } + Ok(args) + } +} + +fn cycle_idx(cur: usize, len: usize, delta: isize) -> usize { + if len == 0 { + return 0; + } + let n = len as isize; + (((cur as isize + delta) % n + n) % n) as usize +} + +/// Handle a key while the wizard is open. Mirrors the services-manager seam: +/// mutates the overlay + job model in place and returns reducer side effects +/// (e.g. `SpawnJob`) for the event loop to drive through the job-bridge. +pub fn on_key( + wizard: &mut Option, + jobs: &mut State, + recipes: &[ModelRecipeSummary], + key: KeyEvent, +) -> Vec { + let Some(w) = wizard.as_mut() else { + return Vec::new(); + }; + + // 1) Model-recipe picker sub-step has focus. + if let Some(picker) = w.picker.as_mut() { + match picker.on_key(key.code, recipes) { + PickerOutcome::Chosen(summary) => { + w.model = summary.id; + // Pre-select the recipe's preferred engine when it is one this + // wizard lists; otherwise leave the engine choice untouched. + if let Some(eng) = summary.preferred_engine + && let Some(idx) = ENGINES.iter().position(|e| *e == eng) + { + w.engine_idx = idx; + } + w.picker = None; + } + PickerOutcome::Cancelled => w.picker = None, + PickerOutcome::None => {} + } + return Vec::new(); + } + + // 2) Folder browser (local model path) has focus. + if let Some(fb) = w.browser.as_mut() { + match fb.on_key(key.code) { + FolderOutcome::Chosen(path) => { + w.model = path.to_string_lossy().into_owned(); + w.browser = None; + } + FolderOutcome::Cancelled => w.browser = None, + FolderOutcome::None | FolderOutcome::Navigated => {} + } + return Vec::new(); + } + + // 2) Approval modal has focus. + if let Some(pending) = w.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = w.approval.take() { + return spawn_serve(w, jobs, pending); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => w.approval = None, + None => {} + } + return Vec::new(); + } + + // 3) A launch job is showing in the console. + if let Some(job_id) = w.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *wizard = None, + ConsoleOutcome::Dismissed => w.active_job = None, + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 4) Form editing. + match key.code { + KeyCode::Esc => *wizard = None, + KeyCode::Up => w.move_field(-1), + KeyCode::Down => w.move_field(1), + KeyCode::Left => w.cycle(-1), + KeyCode::Right => w.cycle(1), + KeyCode::Char(' ') if w.current_field() == Field::Mode => w.cycle(1), + // Tab on the Model field opens the local-path picker (Wave-0 primitive). + KeyCode::Tab if w.current_field() == Field::Model => { + let start = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + w.browser = Some(FolderBrowser::new("Pick a local model path", start)); + } + KeyCode::Enter => { + if w.current_field() == Field::Launch { + request_launch(w); + } else if w.current_field() == Field::Model && !recipes.is_empty() { + // On the Model field, Enter opens the recipe picker (the + // model_picker sub-step); free-text typing + Tab-browse remain. + // Seed the filter with anything already typed so the picker + // opens pre-narrowed (e.g. typed "qwen" → Qwen recipes). + w.picker = Some(ModelPicker { + query: w.model.trim().to_string(), + selected: 0, + }); + } else { + w.move_field(1); + } + } + KeyCode::Backspace => w.backspace(), + KeyCode::Char(c) => w.type_char(c), + _ => {} + } + Vec::new() +} + +/// Validate the form and stage an approval (no job runs until approved). +fn request_launch(w: &mut ServeWizardState) { + match w.build_args() { + Ok(args) => { + let cmd = resolve_exe(); + let cmdline = format!("{} {}", exe_label(&cmd), args.join(" ")); + let request = ApprovalRequest::new( + format!("serve “{}”", w.model.trim()), + vec![ + cmdline, + String::new(), + "This launches a local model server through the ROCm CLI.".to_string(), + if w.managed { + "Managed: it will appear in the services manager and dashboard.".to_string() + } else { + "Foreground: it runs in this job console until stopped.".to_string() + }, + ], + ); + w.message = None; + w.approval = Some(PendingServe { + cmd, + args, + request, + choice: ApprovalChoice::default(), + }); + } + Err(msg) => w.message = Some(msg), + } +} + +/// Launch the approved serve invocation as a background job. +fn spawn_serve( + w: &mut ServeWizardState, + jobs: &mut State, + pending: PendingServe, +) -> Vec { + // A stable id keyed by the model so re-launches replace the prior console. + let model_key: String = w + .model + .trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let id = format!("serve-{model_key}"); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd: pending.cmd, + args: pending.args, + }); + // The reducer is idempotent: a `StartJob` for an id that is already running + // (not terminal) no-ops and returns no effects. If that happens, do NOT + // point `active_job` at the stale job and claim success — surface it and + // leave the form so the user can wait, cancel, or rename. + if fx.is_empty() { + w.message = Some(format!("a job for “{}” is already running", w.model.trim())); + return fx; + } + w.active_job = Some(id); + fx +} + +/// Render the overlay (form, or the folder browser, or the approval modal, or +/// the job console — in priority order). +pub fn draw_serve_wizard( + f: &mut Frame, + area: Rect, + w: &ServeWizardState, + jobs: &State, + recipes: &[ModelRecipeSummary], + theme: &Theme, +) { + // The job console takes over while a launch is in flight / finished. + if let Some(job_id) = &w.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(72, 78, 96, 26, area); + let inner = draw_popup_frame(f, popup, "Serve a model", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let has_recipes = !recipes.is_empty(); + let lines: Vec = FIELDS + .iter() + .enumerate() + .map(|(i, field)| field_line(*field, i == w.field, w, has_recipes, theme)) + .collect(); + f.render_widget(Paragraph::new(lines), rows[0]); + + let msg = w.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[1], + ); + + let hint = if recipes.is_empty() { + "↑↓ field · ←→ change · Tab browse (model) · Enter next/launch · Esc close" + } else { + "↑↓ field · ←→ change · Enter pick (model)/next/launch · Tab browse · Esc close" + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + hint, + Style::default().fg(theme.muted), + ))), + rows[2], + ); + + // Picker / folder browser / approval sit on top of the form when active. + if let Some(picker) = &w.picker { + draw_model_picker(f, area, picker, recipes, theme); + } + if let Some(fb) = &w.browser { + draw_folder_browser(f, area, fb, theme); + } + if let Some(pending) = &w.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +fn field_line<'a>( + field: Field, + selected: bool, + w: &'a ServeWizardState, + has_recipes: bool, + theme: &Theme, +) -> Line<'a> { + let model_placeholder = if has_recipes { + "(Enter to pick a recipe · type a name · Tab to browse)" + } else { + "(type a name / path, or Tab to browse)" + }; + let (label, value): (&str, String) = match field { + Field::Model => ("Model", display_value(&w.model, model_placeholder)), + Field::Engine => ( + "Engine", + ENGINES[w.engine_idx.min(ENGINES.len() - 1)].to_string(), + ), + Field::Device => ( + "Device", + DEVICES[w.device_idx.min(DEVICES.len() - 1)].to_string(), + ), + Field::Host => ("Host", display_value(&w.host, "(engine default)")), + Field::Port => ("Port", display_value(&w.port, "(engine default)")), + Field::Mode => ( + "Mode", + if w.managed { + "managed".to_string() + } else { + "foreground".to_string() + }, + ), + Field::Launch => ("", String::new()), + }; + + if field == Field::Launch { + let style = if selected { + Style::default() + .bg(theme.ok) + .fg(theme.bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.ok) + }; + return Line::from(Span::styled(" [ Launch ] ", style)); + } + + let marker = if selected { "▶ " } else { " " }; + let label_style = if selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.muted) + }; + let value_style = if selected { + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + Line::from(vec![ + Span::styled(marker, label_style), + Span::styled(format!("{label:<8}"), label_style), + Span::styled(value, value_style), + ]) +} + +fn display_value(v: &str, placeholder: &'static str) -> String { + if v.is_empty() { + placeholder.to_string() + } else { + v.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + fn typed(s: &str) -> Vec { + s.chars().map(|c| key(KeyCode::Char(c))).collect() + } + + #[test] + fn cycle_idx_wraps_both_directions() { + assert_eq!(cycle_idx(0, 3, -1), 2); + assert_eq!(cycle_idx(2, 3, 1), 0); + assert_eq!(cycle_idx(0, 0, 1), 0); + } + + #[test] + fn default_form_targets_managed_lemonade() { + let w = ServeWizardState::default(); + assert!(w.managed); + assert_eq!(ENGINES[w.engine_idx], "lemonade"); + assert_eq!(w.device_idx, 0); // engine default → no --device + assert_eq!(w.host, "127.0.0.1"); + assert_eq!(w.port, "11435"); + } + + #[test] + fn build_args_requires_a_model() { + let w = ServeWizardState::default(); + assert_eq!(w.build_args().unwrap_err(), "model is required"); + } + + #[test] + fn build_args_emits_managed_serve_with_defaults() { + let w = ServeWizardState { + model: "qwen".into(), + ..Default::default() + }; + let args = w.build_args().unwrap(); + assert_eq!( + args, + vec![ + "serve", + "qwen", + "--engine", + "lemonade", + "--host", + "127.0.0.1", + "--port", + "11435", + "--managed", + ] + ); + } + + #[test] + fn build_args_includes_device_when_not_default_and_foreground() { + let w = ServeWizardState { + model: "glm".into(), + device_idx: 1, // gpu_required + managed: false, + ..Default::default() + }; + let args = w.build_args().unwrap(); + assert!(args.windows(2).any(|p| p == ["--device", "gpu_required"])); + assert!(args.contains(&"--foreground".to_string())); + assert!(!args.contains(&"--managed".to_string())); + } + + #[test] + fn build_args_rejects_bad_port() { + let w = ServeWizardState { + model: "m".into(), + port: "99999".into(), + ..Default::default() + }; + assert!(w.build_args().unwrap_err().contains("port")); + } + + #[test] + fn build_args_rejects_port_zero() { + // u16 parses 0, but it is not a bindable listen port — must be rejected + // in the form, matching the "1–65535" message. + let w = ServeWizardState { + model: "m".into(), + port: "0".into(), + ..Default::default() + }; + assert!(w.build_args().unwrap_err().contains("port")); + } + + #[test] + fn port_field_accepts_digits_only() { + let mut w = ServeWizardState::default(); + w.port.clear(); + w.field = FIELDS.iter().position(|f| *f == Field::Port).unwrap(); + for k in typed("80a0") { + // route through type_char via the field + if let KeyCode::Char(c) = k.code { + w.type_char(c); + } + } + assert_eq!(w.port, "800"); + } + + #[test] + fn launch_requires_approval_then_spawns_job() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + // Fill the model by typing on the Model field (field 0 by default). + for k in typed("qwen") { + on_key(&mut wiz, &mut jobs, &[], k); + } + assert_eq!(wiz.as_ref().unwrap().model, "qwen"); + // Jump to Launch and press Enter → approval staged, NO job yet. + let launch_idx = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + wiz.as_mut().unwrap().field = launch_idx; + let fx = on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + assert!(fx.is_empty(), "launch must not run before approval"); + assert!(wiz.as_ref().unwrap().approval.is_some()); + assert!(jobs.jobs.is_empty()); + + // Approve → exactly one SpawnJob, job registered, console active. + let fx = on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert!(matches!(fx[0], SideEffect::SpawnJob { .. })); + let w = wiz.as_ref().unwrap(); + assert!(w.approval.is_none()); + assert_eq!(w.active_job.as_deref(), Some("serve-qwen")); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn empty_model_launch_sets_message_not_approval() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + let launch_idx = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + wiz.as_mut().unwrap().field = launch_idx; + let fx = on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + assert!(fx.is_empty()); + let w = wiz.as_ref().unwrap(); + assert!(w.approval.is_none()); + assert_eq!(w.message.as_deref(), Some("model is required")); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + wiz.as_mut().unwrap().model = "m".into(); + wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + let fx = on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(wiz.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn esc_closes_when_idle() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Esc)); + assert!(wiz.is_none()); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + wiz.as_mut().unwrap().model = "m".into(); + wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('y'))); + assert!(wiz.as_ref().unwrap().active_job.is_some()); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('q'))); + assert!(wiz.is_none(), "q must close the overlay even mid-job"); + } + + #[test] + fn esc_is_ignored_while_job_is_running_then_dismisses_when_terminal() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + wiz.as_mut().unwrap().model = "m".into(); + wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('y'))); + let job_id = wiz.as_ref().unwrap().active_job.clone().unwrap(); + // Job is Running: Esc must NOT dismiss the console or close the overlay. + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Esc)); + assert_eq!(wiz.as_ref().unwrap().active_job.as_ref(), Some(&job_id)); + assert!(wiz.is_some()); + // Once terminal, Esc dismisses the console back to the form. + jobs.apply(StateEvent::JobDone { + id: job_id, + code: 0, + }); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Esc)); + assert!(wiz.as_ref().unwrap().active_job.is_none()); + assert!( + wiz.is_some(), + "dismissing the console keeps the wizard open" + ); + } + + #[test] + fn relaunch_while_prior_job_running_surfaces_message_not_stale_console() { + // The reducer no-ops a StartJob for a still-running id. spawn_serve must + // NOT claim success (set active_job) when no SpawnJob was emitted. + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + let launch = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + + // First launch of "qwen": real spawn. + for k in typed("qwen") { + on_key(&mut wiz, &mut jobs, &[], k); + } + wiz.as_mut().unwrap().field = launch; + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('y'))); + assert_eq!( + wiz.as_ref().unwrap().active_job.as_deref(), + Some("serve-qwen") + ); + + // Simulate the user closing + reopening the overlay (fresh state) while + // the "serve-qwen" job is still Running in the shared job model. + let mut wiz2 = Some(ServeWizardState::default()); + for k in typed("qwen") { + on_key(&mut wiz2, &mut jobs, &[], k); + } + wiz2.as_mut().unwrap().field = launch; + on_key(&mut wiz2, &mut jobs, &[], key(KeyCode::Enter)); + let fx = on_key(&mut wiz2, &mut jobs, &[], key(KeyCode::Char('y'))); + // No new SpawnJob, no stale console, an informative message instead. + assert!(fx.is_empty(), "no double-spawn for a running id"); + let w = wiz2.as_ref().unwrap(); + assert!(w.active_job.is_none(), "must not point at the stale job"); + assert!( + w.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1, "still just the one job"); + } + + #[test] + fn tab_on_model_opens_folder_browser() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Tab)); + assert!(wiz.as_ref().unwrap().browser.is_some()); + // Esc inside the browser closes the browser, not the overlay. + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Esc)); + assert!(wiz.as_ref().unwrap().browser.is_none()); + assert!(wiz.is_some()); + } + + #[test] + fn left_right_cycles_engine() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Engine).unwrap(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Right)); + assert_eq!(wiz.as_ref().unwrap().engine_idx, 1); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Left)); + assert_eq!(wiz.as_ref().unwrap().engine_idx, 0); + } + + #[test] + fn enter_on_model_opens_picker_and_choice_fills_model_and_engine() { + let recipes = vec![ModelRecipeSummary { + id: "GLM-4".into(), + aliases: vec!["glm".into()], + task: "chat".into(), + preferred_engine: Some("vllm".into()), + }]; + let mut wiz = Some(ServeWizardState::default()); // field 0 = Model + let mut jobs = State::default(); + // Enter on Model opens the picker when recipes exist. + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); + assert!(wiz.as_ref().unwrap().picker.is_some()); + // Enter in the picker chooses the (only) recipe → fills model + engine. + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); + let w = wiz.as_ref().unwrap(); + assert!(w.picker.is_none()); + assert_eq!(w.model, "GLM-4"); + assert_eq!( + ENGINES[w.engine_idx], "vllm", + "preferred engine pre-selected" + ); + } + + #[test] + fn enter_on_model_advances_when_no_recipes() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + assert!(wiz.as_ref().unwrap().picker.is_none()); + assert_eq!(wiz.as_ref().unwrap().field, 1, "Enter advances to Engine"); + } + + #[test] + fn recipe_with_unknown_preferred_engine_leaves_engine_idx() { + // Pins the silent-fallback contract: a preferred_engine the wizard does + // not list must NOT crash and must leave the engine choice untouched + // (model still filled). Guards future ENGINES vs rocm-core divergence. + let recipes = vec![ModelRecipeSummary { + id: "some-model".into(), + aliases: vec![], + task: "chat".into(), + preferred_engine: Some("not-in-engines-list".into()), + }]; + let mut wiz = Some(ServeWizardState::default()); // engine_idx 0 = lemonade + let mut jobs = State::default(); + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); // open picker + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); // choose first + let w = wiz.as_ref().unwrap(); + assert_eq!(w.model, "some-model"); + assert_eq!( + w.engine_idx, 0, + "unknown preferred engine leaves the choice" + ); + } + + #[test] + fn picker_opens_pre_filtered_by_typed_model_text() { + let recipes = vec![ + ModelRecipeSummary { + id: "Qwen3-4B".into(), + aliases: vec!["qwen".into()], + task: "chat".into(), + preferred_engine: None, + }, + ModelRecipeSummary { + id: "GLM-4".into(), + aliases: vec!["glm".into()], + task: "chat".into(), + preferred_engine: None, + }, + ]; + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + // Type "qwen" on the Model field, then Enter to open the picker. + for k in typed("qwen") { + on_key(&mut wiz, &mut jobs, &recipes, k); + } + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); + let picker = wiz.as_ref().unwrap().picker.as_ref().unwrap(); + assert_eq!(picker.query, "qwen"); + assert_eq!(picker.filtered(&recipes).len(), 1, "pre-narrowed to Qwen"); + // Enter chooses the single match. + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); + assert_eq!(wiz.as_ref().unwrap().model, "Qwen3-4B"); + } + + #[test] + fn picker_esc_returns_to_form_without_changing_model() { + let recipes = vec![ModelRecipeSummary { + id: "GLM-4".into(), + aliases: vec![], + task: "chat".into(), + preferred_engine: None, + }]; + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Enter)); + on_key(&mut wiz, &mut jobs, &recipes, key(KeyCode::Esc)); + let w = wiz.as_ref().unwrap(); + assert!(w.picker.is_none()); + assert!(w.model.is_empty()); + assert!(wiz.is_some(), "picker Esc keeps the wizard open"); + } + + fn render(w: &ServeWizardState, jobs: &State) -> String { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(120, 30); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw_serve_wizard(f, f.area(), w, jobs, &[], &theme)) + .unwrap(); + let buf = term.backend().buffer().clone(); + buf.content().iter().map(|c| c.symbol()).collect() + } + + #[test] + fn snapshot_renders_form_fields() { + let w = ServeWizardState::default(); + let out = render(&w, &State::default()); + assert!(out.contains("Serve a model"), "titled overlay"); + assert!(out.contains("Model"), "model field"); + assert!(out.contains("lemonade"), "default engine shown"); + assert!(out.contains("Launch"), "launch action"); + } + + #[test] + fn snapshot_shows_approval_modal_on_launch() { + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + wiz.as_mut().unwrap().model = "qwen".into(); + wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + let out = render(wiz.as_ref().unwrap(), &jobs); + assert!(out.contains("Review:"), "approval modal shown"); + assert!(out.contains("serve"), "describes the gated launch"); + assert!(out.contains("Approve"), "approve button present"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/services_manager.rs b/crates/rocm-dash-tui/src/ui/services_manager.rs new file mode 100644 index 00000000..f9f63327 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/services_manager.rs @@ -0,0 +1,462 @@ +//! Services manager overlay (Phase 3 Wave 1). +//! +//! The first operational screen rebuilt on the Wave-0 primitives: it lists the +//! managed inference services the daemon surfaces (model · port · status · live +//! `gen_tps`) and runs stop/restart **through the approval gate and the +//! job-bridge** — never inline. This is the pattern the remaining Wave-1 screens +//! (serve_wizard / model_picker / engine_manager) reuse. +//! +//! Mutating actions invoke the CLI (`rocm services stop|restart --yes`) as +//! a background job; the approval *decision* is the user's, captured by the +//! render+event seam, and the CLI owns the actual mutation — the read-only chat +//! invariant is untouched. + +use std::collections::HashMap; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::metrics::Instance; +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::format; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// A lifecycle operation on a managed service. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LifecycleAction { + Stop, + Restart, +} + +impl LifecycleAction { + pub fn verb(self) -> &'static str { + match self { + LifecycleAction::Stop => "stop", + LifecycleAction::Restart => "restart", + } + } +} + +/// An approval awaiting the user's verdict before a lifecycle op runs. +#[derive(Debug, Clone)] +pub struct PendingLifecycle { + pub action: LifecycleAction, + pub service_id: String, + /// The `rocm` binary to invoke, resolved when the approval was staged so a + /// later `current_exe()` failure can't silently drop an approved action. + pub cmd: String, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct ServicesManagerState { + pub selected: usize, + /// Set while an approval modal is up (mutating op gated). + pub approval: Option, + /// The job id of an in-flight (or just-finished) lifecycle op. + pub active_job: Option, +} + +/// A render-ready row derived from an `Instance`. +#[derive(Debug, Clone, PartialEq)] +pub struct ServiceRow { + pub id: String, + pub model: String, + pub port: Option, + pub status: String, + pub gen_tps: Option, +} + +/// Build the sorted service list from the daemon-surfaced instances. +pub fn service_rows(instances: &HashMap) -> Vec { + let mut rows: Vec = instances + .values() + .map(|i| ServiceRow { + id: i.container_id.clone(), + model: i.model_name.clone(), + port: i.port, + status: format!("{:?}", i.status), + gen_tps: i.gen_tps, + }) + .collect(); + rows.sort_by(|a, b| a.id.cmp(&b.id)); + rows +} + +/// Handle a key while the overlay is open. Mutates the overlay + the job model +/// in place and returns the reducer side effects (e.g. `SpawnJob`) for the +/// event loop to run through the job-bridge. Returns `true` (via the closed +/// state) — the caller checks `state.services` for closure. +pub fn on_key( + services: &mut Option, + jobs: &mut State, + instances: &HashMap, + key: KeyEvent, +) -> Vec { + let rows = service_rows(instances); + let Some(sm) = services.as_mut() else { + return Vec::new(); + }; + + // 1) Approval modal has focus. + if let Some(pending) = sm.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + // `take()` here always yields `Some` (we are inside the guard), but + // pattern-match rather than `expect()` so there is no panic path. + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = sm.approval.take() { + return spawn_lifecycle(sm, jobs, pending); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => { + sm.approval = None; + } + None => {} + } + return Vec::new(); + } + + // 2) A lifecycle job is showing in the console. + if let Some(job_id) = sm.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *services = None, + ConsoleOutcome::Dismissed => sm.active_job = None, + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 3) List navigation + lifecycle requests. + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *services = None, + KeyCode::Up | KeyCode::Char('k') => { + sm.selected = sm.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') if !rows.is_empty() => { + sm.selected = (sm.selected + 1).min(rows.len() - 1); + } + KeyCode::Char('s') => request_lifecycle(sm, &rows, LifecycleAction::Stop), + KeyCode::Char('r') => request_lifecycle(sm, &rows, LifecycleAction::Restart), + _ => {} + } + Vec::new() +} + +/// Stage an approval for the selected service. +fn request_lifecycle(sm: &mut ServicesManagerState, rows: &[ServiceRow], action: LifecycleAction) { + let Some(row) = rows.get(sm.selected) else { + return; + }; + let cmd = resolve_exe(); + let request = ApprovalRequest::new( + format!("{} service “{}”", action.verb(), row.id), + vec![ + format!( + "{} services {} {} --yes", + exe_label(&cmd), + action.verb(), + row.id + ), + format!("model: {} port: {}", row.model, port_str(row.port)), + String::new(), + format!( + "This runs the CLI `services {}` for this managed service.", + action.verb() + ), + ], + ); + sm.approval = Some(PendingLifecycle { + action, + service_id: row.id.clone(), + cmd, + request, + choice: ApprovalChoice::default(), + }); +} + +/// Launch the approved lifecycle op as a background job. Returns the effects. +/// The command was resolved when the approval was staged, so this never +/// silently drops an approved action. +fn spawn_lifecycle( + sm: &mut ServicesManagerState, + jobs: &mut State, + pending: PendingLifecycle, +) -> Vec { + let id = format!("svc-{}-{}", pending.action.verb(), pending.service_id); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd: pending.cmd, + args: vec![ + "services".into(), + pending.action.verb().into(), + pending.service_id, + "--yes".into(), + ], + }); + sm.active_job = Some(id); + fx +} + +fn port_str(port: Option) -> String { + port.map(|p| p.to_string()) + .unwrap_or_else(|| "—".to_string()) +} + +/// Render the overlay (list, or the approval modal, or the job console). +pub fn draw_services_manager( + f: &mut Frame, + area: Rect, + sm: &ServicesManagerState, + instances: &HashMap, + jobs: &State, + theme: &Theme, +) { + // The job console takes over while a lifecycle op is in flight / finished. + if let Some(job_id) = &sm.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(82, 80, 130, 34, area); + let inner = draw_popup_frame(f, popup, "Services — managed inference servers", theme); + if inner.height == 0 { + return; + } + + let rows = service_rows(instances); + let body = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(inner); + + if rows.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "No managed services. Start one with `rocm serve --managed`.", + Style::default().fg(theme.muted), + ))), + body[0], + ); + } else { + let items: Vec = rows + .iter() + .map(|r| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:<22}", trunc(&r.id, 22)), + Style::default().fg(theme.fg), + ), + Span::styled( + format!("{:<26}", trunc(&r.model, 26)), + Style::default().fg(theme.accent), + ), + Span::styled( + format!(":{:<6}", port_str(r.port)), + Style::default().fg(theme.muted), + ), + Span::styled(format!("{:<10}", r.status), Style::default().fg(theme.ok)), + Span::styled( + format!("gen {}", format::tps_opt(r.gen_tps)), + Style::default().fg(theme.fg), + ), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(sm.selected.min(rows.len().saturating_sub(1)))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, body[0], &mut ls); + } + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ select · s stop · r restart · Esc close", + Style::default().fg(theme.muted), + ))), + body[1], + ); + + // Approval modal sits on top of the list. + if let Some(pending) = &sm.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +fn trunc(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let keep: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{keep}…") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use rocm_dash_core::metrics::{Instance, InstanceStatus}; + + fn inst(id: &str, model: &str, tps: Option) -> Instance { + Instance { + container_id: id.into(), + container_name: id.into(), + model_name: model.into(), + status: InstanceStatus::Running, + port: Some(8000), + gen_tps: tps, + ..Instance::default() + } + } + + fn instances() -> HashMap { + let mut m = HashMap::new(); + m.insert("a".into(), inst("svc-a", "llama3", Some(42.0))); + m.insert("b".into(), inst("svc-b", "qwen", None)); + m + } + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn rows_are_sorted_by_id() { + let rows = service_rows(&instances()); + assert_eq!( + rows.iter().map(|r| r.id.as_str()).collect::>(), + vec!["svc-a", "svc-b"] + ); + assert_eq!(rows[0].gen_tps, Some(42.0)); + } + + #[test] + fn stop_requires_approval_then_spawns_job() { + let mut services = Some(ServicesManagerState::default()); + let mut jobs = State::default(); + let insts = instances(); + + // 's' on the first row stages an approval — NO job yet (gated). + let fx = on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('s'))); + assert!(fx.is_empty(), "stop must not run before approval"); + let sm = services.as_ref().unwrap(); + assert!(sm.approval.is_some()); + assert_eq!(sm.approval.as_ref().unwrap().action, LifecycleAction::Stop); + assert!(jobs.jobs.is_empty(), "no job spawned pre-approval"); + + // Approve ('y') → a SpawnJob effect is produced + a job registered. + let fx = on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1, "approve spawns exactly one job"); + assert!(matches!(fx[0], SideEffect::SpawnJob { .. })); + let sm = services.as_ref().unwrap(); + assert!(sm.approval.is_none()); + assert_eq!(sm.active_job.as_deref(), Some("svc-stop-svc-a")); + assert_eq!(jobs.jobs.len(), 1); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + // Approve a stop so a job is active, then ensure 'q' is never trapped. + let mut services = Some(ServicesManagerState::default()); + let mut jobs = State::default(); + let insts = instances(); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('s'))); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('y'))); + assert!(services.as_ref().unwrap().active_job.is_some()); + // Job is still Running (terminal events would arrive via the bridge). + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('q'))); + assert!(services.is_none(), "q must close the overlay even mid-job"); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut services = Some(ServicesManagerState::default()); + let mut jobs = State::default(); + let insts = instances(); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('s'))); + let fx = on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(services.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty(), "deny spawns nothing"); + } + + #[test] + fn esc_closes_when_idle() { + let mut services = Some(ServicesManagerState::default()); + let mut jobs = State::default(); + let insts = instances(); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Esc)); + assert!(services.is_none()); + } + + #[test] + fn navigation_clamps_to_rows() { + let mut services = Some(ServicesManagerState::default()); + let mut jobs = State::default(); + let insts = instances(); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Down)); + assert_eq!(services.as_ref().unwrap().selected, 1); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Down)); // clamp at last + assert_eq!(services.as_ref().unwrap().selected, 1); + } + + fn render( + sm: &ServicesManagerState, + jobs: &State, + insts: &HashMap, + ) -> String { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(120, 28); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw_services_manager(f, f.area(), sm, insts, jobs, &theme)) + .unwrap(); + let buf = term.backend().buffer().clone(); + buf.content().iter().map(|c| c.symbol()).collect() + } + + #[test] + fn snapshot_lists_services_with_gen_tps() { + let sm = ServicesManagerState::default(); + let out = render(&sm, &State::default(), &instances()); + assert!(out.contains("Services"), "titled overlay"); + assert!(out.contains("svc-a"), "service id listed"); + assert!(out.contains("llama3"), "model listed"); + assert!(out.contains("s stop"), "lifecycle hints"); + } + + #[test] + fn snapshot_shows_approval_modal_on_stop() { + let mut services = Some(ServicesManagerState::default()); + let mut jobs = State::default(); + let insts = instances(); + on_key(&mut services, &mut jobs, &insts, key(KeyCode::Char('s'))); + let out = render(services.as_ref().unwrap(), &jobs, &insts); + assert!(out.contains("Review:"), "approval modal shown"); + assert!(out.contains("stop service"), "describes the gated action"); + assert!(out.contains("Approve"), "approve button present"); + } +} diff --git a/crates/rocm-dash-tui/src/ui/sparkline.rs b/crates/rocm-dash-tui/src/ui/sparkline.rs new file mode 100644 index 00000000..49036ef8 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/sparkline.rs @@ -0,0 +1,232 @@ +//! Braille sparkline widget. +//! +//! One character cell = 2 cols × 4 rows of dots. We pack two consecutive +//! samples into each character (left half + right half), so an `N`-wide area +//! renders `2N` x-bins at `4 * height` vertical resolution. +//! +//! Inspired by btop's CPU graph and the matching widget in ctux. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::Widget; + +use crate::ui::gradient::lerp3_t; + +pub struct BrailleSparkline<'a> { + data: &'a [u64], + max: u64, + style: Style, + gradient: Option<[Color; 3]>, +} + +impl<'a> BrailleSparkline<'a> { + pub fn new(data: &'a [u64]) -> Self { + Self { + data, + max: 1, + style: Style::default(), + gradient: None, + } + } + + pub fn max(mut self, m: u64) -> Self { + self.max = m.max(1); + self + } + + pub fn style(mut self, s: Style) -> Self { + self.style = s; + self + } + + /// Color each character cell by the larger of its two samples, mapped + /// across the gradient (low value = stops[0], peak = stops[2]). When + /// set, overrides `style.fg` per cell. Leaves `style.fg` as the fallback + /// for empty/zero cells. + pub fn gradient(mut self, start: Color, mid: Color, end: Color) -> Self { + self.gradient = Some([start, mid, end]); + self + } +} + +impl Widget for BrailleSparkline<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let cols = area.width as usize; + let rows = area.height as usize; + let total_dot_rows = rows * 4; + + // Right-align: show the most recent `cols * 2` samples. + let bins = cols * 2; + let start = self.data.len().saturating_sub(bins); + let slice = &self.data[start..]; + + for cx in 0..cols { + let left = slice.get(cx * 2).copied(); + let right = slice.get(cx * 2 + 1).copied(); + // Color the whole character column by the larger of its two + // samples — peaks render with the gradient end color even when + // their neighbor is low. + let cell_value = left.unwrap_or(0).max(right.unwrap_or(0)); + let style = match self.gradient { + Some(stops) if cell_value > 0 => { + let t = cell_value as f64 / self.max as f64; + self.style.fg(lerp3_t(stops, t)) + } + _ => self.style, + }; + for cy in 0..rows { + let row_top = cy * 4; + let l = lit_dots(left, self.max, total_dot_rows, row_top); + let r = lit_dots(right, self.max, total_dot_rows, row_top); + if l == 0 && r == 0 { + continue; + } + let s = braille_char(l, r).to_string(); + buf.set_string(area.x + cx as u16, area.y + cy as u16, &s, style); + } + } + } +} + +/// Returns a 4-bit mask of which dots in this character row are lit +/// (bit 0 = top dot in the cell, bit 3 = bottom). +fn lit_dots(value: Option, max: u64, total_dot_rows: usize, row_top: usize) -> u8 { + let Some(v) = value else { + return 0; + }; + let v = v.min(max); + // Fill from the bottom. A value at `max` lights every dot row. + let lit = ((v as f64 / max as f64) * total_dot_rows as f64).round() as usize; + let first_lit_row = total_dot_rows.saturating_sub(lit); + + let mut mask = 0u8; + for i in 0..4 { + let dot_row = row_top + i; + if dot_row >= first_lit_row && dot_row < total_dot_rows { + mask |= 1 << i; + } + } + mask +} + +/// Compose left + right 4-bit dot columns into a Unicode braille code point. +/// +/// Braille (U+2800..U+28FF) bit layout: +/// +/// ```text +/// left col right col +/// 1 ● 4 ● bits 0x01 / 0x08 +/// 2 ● 5 ● bits 0x02 / 0x10 +/// 3 ● 6 ● bits 0x04 / 0x20 +/// 7 ● 8 ● bits 0x40 / 0x80 +/// ``` +fn braille_char(left: u8, right: u8) -> char { + let mut code = 0u32; + if left & 0b0001 != 0 { + code |= 0x01; + } + if left & 0b0010 != 0 { + code |= 0x02; + } + if left & 0b0100 != 0 { + code |= 0x04; + } + if left & 0b1000 != 0 { + code |= 0x40; + } + if right & 0b0001 != 0 { + code |= 0x08; + } + if right & 0b0010 != 0 { + code |= 0x10; + } + if right & 0b0100 != 0 { + code |= 0x20; + } + if right & 0b1000 != 0 { + code |= 0x80; + } + char::from_u32(0x2800 + code).expect("valid braille code point") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_is_blank_braille() { + assert_eq!(braille_char(0, 0), '\u{2800}'); + } + + #[test] + fn all_dots_is_full_braille_block() { + assert_eq!(braille_char(0b1111, 0b1111), '⣿'); + } + + #[test] + fn bottom_only_lights_bottom_row_of_dots() { + // dot 7 (left bottom) + dot 8 (right bottom) = 0x40 | 0x80 + let c = braille_char(0b1000, 0b1000); + assert_eq!(c as u32 - 0x2800, 0xC0); + } + + #[test] + fn full_value_fills_all_rows() { + // 1 char row tall = 4 dot rows; full value should light all 4 dots + let m = lit_dots(Some(100), 100, 4, 0); + assert_eq!(m, 0b1111); + } + + #[test] + fn zero_value_lights_nothing() { + let m = lit_dots(Some(0), 100, 4, 0); + assert_eq!(m, 0); + } + + #[test] + fn half_value_in_2_row_cell_fills_bottom_row_only() { + // 2 rows = 8 dot rows; half value (50) → 4 dots from the bottom + // top char row (row_top=0): nothing lit (top dots are above the fill) + // bottom char row (row_top=4): all 4 dots lit + assert_eq!(lit_dots(Some(50), 100, 8, 0), 0); + assert_eq!(lit_dots(Some(50), 100, 8, 4), 0b1111); + } + + #[test] + fn gradient_colors_peak_cell_with_end_stop() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + let data = vec![100u64]; // single peak value at max + let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1)); + let stops = ( + Color::Rgb(10, 0, 0), + Color::Rgb(0, 20, 0), + Color::Rgb(0, 0, 30), + ); + BrailleSparkline::new(&data) + .max(100) + .gradient(stops.0, stops.1, stops.2) + .render(buf.area, &mut buf); + let fg = buf.cell((0, 0)).unwrap().style().fg; + assert_eq!(fg, Some(stops.2)); + } + + #[test] + fn gradient_disabled_uses_flat_style() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + let data = vec![100u64]; + let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1)); + let flat = Color::Rgb(123, 45, 67); + BrailleSparkline::new(&data) + .max(100) + .style(Style::default().fg(flat)) + .render(buf.area, &mut buf); + let fg = buf.cell((0, 0)).unwrap().style().fg; + assert_eq!(fg, Some(flat)); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/bench.rs b/crates/rocm-dash-tui/src/ui/tabs/bench.rs new file mode 100644 index 00000000..e5cb876b --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/tabs/bench.rs @@ -0,0 +1,799 @@ +//! Bench tab — full-screen bench browser with Pass^N / Pass@N rollups + sparkline. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use rocm_dash_core::bench_rollup::{PassNRollup, rollup_pass_n, row_verdict}; +use rocm_dash_core::bench_schema::{BenchmarkRow, PassFail}; + +use crate::app::{AppState, KeyAction}; +use crate::ui::format; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::sparkline::BrailleSparkline; +use crate::ui::theme::Theme; + +pub fn draw(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + if state.bench_rows.is_empty() { + let block = Block::default() + .borders(Borders::ALL) + .title(" Bench ") + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let p = Paragraph::new(Line::from(Span::styled( + "no rows · start daemon with --bench-csv ", + Style::default().fg(theme.muted), + ))) + .block(block); + f.render_widget(p, area); + return; + } + + let rollup_rows = rollup_pass_n(state.bench_rows.iter()); + let rollup_height = compute_rollup_height(rollup_rows.len()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(rollup_height), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + draw_rollup(f, chunks[0], &rollup_rows, theme); + draw_rows_table(f, chunks[1], state, theme); + draw_sparkline(f, chunks[2], state, theme); +} + +// ---------- rollup ---------- + +fn compute_rollup_height(n_groups: usize) -> u16 { + // 2 for borders + 1 for header + up to 8 data rows + let data_rows = n_groups.min(8) as u16; + (2 + 1 + data_rows).max(4) +} + +/// Compact `tp·dtype` config token, e.g. `4·fp8`. `-` for missing parts. +fn cfg_token(r: &PassNRollup) -> String { + let tp = r.tp.map(|v| v.to_string()).unwrap_or_else(|| "-".into()); + let dtype = r.dtype.as_deref().unwrap_or("-"); + format!("{tp}·{dtype}") +} + +/// `✓`/`✗` verdict span, green (`ok`) when `pass`, red (`err`) otherwise. +fn verdict_mark(pass: bool, theme: &Theme) -> Span<'static> { + let (mark, color) = if pass { + ("✓", theme.ok) + } else { + ("✗", theme.err) + }; + Span::styled( + mark, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +/// Pass@N mark. Pass@N is only a *distinct* signal when N > 1; for a +/// single-trial group it is identical to Pass^N, so render a muted dash +/// instead of a redundant second tick/cross. +fn at_n_mark(r: &PassNRollup, theme: &Theme) -> Span<'static> { + if r.n_trials <= 1 { + Span::styled("—", Style::default().fg(theme.muted)) + } else { + verdict_mark(r.pass_at_n, theme) + } +} + +fn draw_rollup(f: &mut Frame, area: Rect, rows: &[PassNRollup], theme: &Theme) { + let title = format!(" Rollup · {} groups ", rows.len()); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + + let header = Line::from(vec![Span::styled( + format!( + "{:<12} {:<16} {:<10} {:>3} {:>6} {:>6} {:>14} {:>14}", + "cell", "model", "cfg", "N", "Pass^N", "Pass@N", "meanPTPS", "meanGTPS" + ), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::BOLD), + )]); + + let max_rows = (inner.height as usize).saturating_sub(1); + let shown = rows.iter().take(max_rows.min(8)); + let mut lines: Vec = Vec::with_capacity(max_rows + 1); + lines.push(header); + + for r in shown { + let cell = trunc_str(&r.cell, 12); + let model = trunc_str(r.model.as_deref().unwrap_or("?"), 16); + let cfg = trunc_str(&cfg_token(r), 10); + lines.push(Line::from(vec![ + Span::styled(format!("{cell:<12} "), Style::default().fg(theme.fg)), + Span::styled(format!("{model:<16} "), Style::default().fg(theme.accent)), + Span::styled(format!("{cfg:<10} "), Style::default().fg(theme.muted)), + Span::styled( + format!("{:>3} ", r.n_trials), + Style::default().fg(theme.muted), + ), + verdict_mark(r.pass_n_of_n, theme), + Span::styled(" ", Style::default()), + at_n_mark(r, theme), + Span::styled(" ", Style::default()), + Span::styled( + format!("{:>14} ", format::tps_opt(r.mean_prompt_tps)), + Style::default().fg(theme.fg), + ), + Span::styled( + format!("{:>14}", format::tps_opt(r.mean_gen_tps)), + Style::default().fg(theme.fg), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), inner); +} + +// ---------- wide rows table ---------- + +fn verdict_label(r: &BenchmarkRow) -> &'static str { + match row_verdict(r) { + PassFail::Pass => "Pass", + PassFail::Fail => "Fail", + PassFail::Unknown => "Unknown", + } +} + +fn verdict_color(r: &BenchmarkRow, theme: &Theme) -> Color { + match row_verdict(r) { + PassFail::Pass => theme.ok, + PassFail::Fail => theme.err, + PassFail::Unknown => theme.muted, + } +} + +fn trunc_str(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + s.chars().take(n).collect() + } +} + +/// Compute the visible window `[start, end)` over `total` rows so that +/// `sel` is visible, biasing toward keeping the newest (highest index) rows +/// in view. `visible_rows` is the number of data rows that fit. +/// +/// Returns `(start, end)` with `end - start <= visible_rows` and +/// `start <= sel < end` whenever `total > 0` and `sel < total`. +fn visible_window(total: usize, visible_rows: usize, sel: usize) -> (usize, usize) { + if total == 0 || visible_rows == 0 { + return (0, 0); + } + let cap = visible_rows.min(total); + // Default window: anchor to the newest rows (tail). + let mut start = total - cap; + let mut end = total; + if sel < start { + // Scroll up: put sel at the top of the window. + start = sel; + end = (start + cap).min(total); + } else if sel >= end { + // Scroll down: put sel at the bottom of the window. + end = (sel + 1).min(total); + start = end - cap; + } + (start, end) +} + +fn draw_rows_table(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let total = state.bench_rows.len(); + let inner_height_estimate = area.height.saturating_sub(2); + let avail_estimate = (inner_height_estimate as usize).saturating_sub(1); + let sel_display = if total == 0 { + 0 + } else { + state.bench_sel.min(total - 1) + 1 + }; + let title = format!( + " Bench rows · {total} total · row {sel_display}/{total} · showing {} ", + avail_estimate.min(total) + ); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + + let header = Line::from(vec![Span::styled( + format!( + "{:<10} {:>4} {:<20} {:>3} {:>6} {:>10} {:>13} {:>13} {:>5} {:<8}", + "cell", "run", "model", "tp", "dtype", "wall", "pTPS", "gTPS", "m_run", "verdict", + ), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::BOLD), + )]); + + let avail = (inner.height as usize).saturating_sub(1); + let sel = state.bench_sel.min(total.saturating_sub(1)); + let (start, end) = visible_window(total, avail, sel); + + let mut lines: Vec = Vec::with_capacity(end - start + 1); + lines.push(header); + + for (idx, r) in state + .bench_rows + .iter() + .enumerate() + .skip(start) + .take(end - start) + { + let cell = trunc_str(&r.cell, 10); + let model = trunc_str(r.model.as_deref().unwrap_or("?"), 20); + let tp = r.tp.map(|v| v.to_string()).unwrap_or_else(|| "-".into()); + let dtype = trunc_str(r.dtype.as_deref().unwrap_or("-"), 6); + let wall = match r.wall_s { + Some(v) => format::duration(v), + None => "-".into(), + }; + let ptps = format::tps_opt(r.prompt_tps); + let gtps = format::tps_opt(r.gen_tps); + let mrun = r + .max_running_reqs + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".into()); + let v_text = verdict_label(r); + let v_color = verdict_color(r, theme); + + let is_sel = idx == sel; + let row_bg = if is_sel { Some(theme.surface_2) } else { None }; + let apply_bg = |s: Style| match row_bg { + Some(bg) => s.bg(bg).add_modifier(Modifier::BOLD), + None => s, + }; + + lines.push(Line::from(vec![ + Span::styled( + format!("{cell:<10} "), + apply_bg(Style::default().fg(theme.accent)), + ), + Span::styled( + format!("{:>4} ", r.run), + apply_bg(Style::default().fg(theme.muted)), + ), + Span::styled( + format!("{model:<20} "), + apply_bg(Style::default().fg(theme.fg)), + ), + Span::styled( + format!("{tp:>3} "), + apply_bg(Style::default().fg(theme.muted)), + ), + Span::styled( + format!("{dtype:>6} "), + apply_bg(Style::default().fg(theme.muted)), + ), + Span::styled( + format!("{wall:>10} "), + apply_bg(Style::default().fg(theme.fg)), + ), + Span::styled( + format!("{ptps:>13} "), + apply_bg(Style::default().fg(theme.fg)), + ), + Span::styled( + format!("{gtps:>13} "), + apply_bg(Style::default().fg(theme.fg)), + ), + Span::styled( + format!("{mrun:>5} "), + apply_bg(Style::default().fg(theme.muted)), + ), + Span::styled( + format!("{v_text:<8}"), + apply_bg(Style::default().fg(v_color).add_modifier(Modifier::BOLD)), + ), + ])); + } + f.render_widget(Paragraph::new(lines), inner); +} + +// ---------- sparkline ---------- + +fn sparkline_max(data: &[u64]) -> u64 { + let peak = data.iter().copied().max().unwrap_or(0); + if peak == 0 { + 100 + } else { + // round up to nearest 500 + let rounded = ((peak / 500) + 1) * 500; + rounded.max(peak + peak / 10) + } +} + +fn draw_sparkline(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let n = state.bench_rows.len(); + let title = format!(" prompt_tps · last {n} rows "); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 || inner.width == 0 { + return; + } + + let data: Vec = state + .bench_rows + .iter() + .map(|r| r.prompt_tps.unwrap_or(0.0).max(0.0) as u64) + .collect(); + let max = sparkline_max(&data); + // Higher prompt_tps is better, so use a "cool" gradient that ramps from + // muted accent up through bright accent into ok-green for peak values — + // visually rewards high throughput rather than flagging it. + let spark = BrailleSparkline::new(&data) + .max(max) + .style(Style::default().fg(theme.accent)) + .gradient(theme.accent_2, theme.accent, theme.ok); + f.render_widget(spark, inner); +} + +/// Pure helper: given the rows-table's *inner* (post-border) area and the +/// currently visible window `[start, end)`, resolve a click at `(x, y)` to +/// a bench-row index, or `None` if the click misses a data line. +/// +/// Row 0 of `rows_table_inner` is the header; rows 1..=visible are data +/// lines mapped to `[start, end)` in order. +fn row_hit(rows_table_inner: Rect, start: usize, end: usize, x: u16, y: u16) -> Option { + if rows_table_inner.width == 0 || rows_table_inner.height == 0 { + return None; + } + if x < rows_table_inner.x || x >= rows_table_inner.x + rows_table_inner.width { + return None; + } + if y < rows_table_inner.y || y >= rows_table_inner.y + rows_table_inner.height { + return None; + } + let row_offset = y - rows_table_inner.y; + if row_offset == 0 { + // Header line. + return None; + } + let visible = end.saturating_sub(start); + let data_idx = (row_offset - 1) as usize; + if data_idx >= visible { + return None; + } + Some(start + data_idx) +} + +/// Resolve a click at `(x, y)` inside the Bench tab body. Returns a +/// `KeyAction` to dispatch, or `None` when the click misses everything +/// actionable. +pub fn hit_test(area: Rect, x: u16, y: u16, state: &AppState) -> Option { + if state.bench_rows.is_empty() { + return None; + } + // Recompute the same vertical layout as `draw`. + let rollup_rows = rollup_pass_n(state.bench_rows.iter()); + let rollup_height = compute_rollup_height(rollup_rows.len()); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(rollup_height), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + let rows_outer = chunks[1]; + // Mirror Block::default().borders(ALL).inner(rows_outer). + let rows_inner = Block::default().borders(Borders::ALL).inner(rows_outer); + + let total = state.bench_rows.len(); + let avail = (rows_inner.height as usize).saturating_sub(1); + if avail == 0 { + return None; + } + let sel = state.bench_sel.min(total.saturating_sub(1)); + let (start, end) = visible_window(total, avail, sel); + + let target = row_hit(rows_inner, start, end, x, y)?; + if target == state.bench_sel { + Some(KeyAction::OpenDetail) + } else { + let delta = target as isize - state.bench_sel as isize; + Some(KeyAction::Move(delta)) + } +} + +pub fn draw_detail(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let Some(row) = state.bench_rows.get(state.bench_sel) else { + let popup = centered_rect(60, 30, 80, 10, area); + let inner = draw_popup_frame(f, popup, "Bench row detail", theme); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "no selection", + Style::default().fg(theme.muted), + ))), + inner, + ); + return; + }; + + let title = format!("Row · {} run {}", row.cell, row.run); + let popup = centered_rect(85, 85, 130, 36, area); + let inner = draw_popup_frame(f, popup, &title, theme); + if inner.height == 0 || inner.width == 0 { + return; + } + + let lines = build_detail_lines(row, theme); + let max_scroll = lines.len().saturating_sub(1) as u16; + let scroll = state.bench_detail_scroll.min(max_scroll); + + // Reserve the last inner row for a footer hint; body gets the rest. + let (body_area, footer_area) = if inner.height >= 2 { + let body = Rect::new(inner.x, inner.y, inner.width, inner.height - 1); + let footer = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + (body, Some(footer)) + } else { + (inner, None) + }; + + let p = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + f.render_widget(p, body_area); + + if let Some(footer) = footer_area { + let hint = Paragraph::new(Line::from(Span::styled( + "j/k or ↑/↓ scroll · PgUp/PgDn jump · Esc close", + Style::default().fg(theme.muted), + ))); + f.render_widget(hint, footer); + } +} + +// ---------- detail body ---------- + +fn fmt_opt(v: &Option) -> String { + match v { + Some(x) => x.to_string(), + None => "-".to_string(), + } +} + +fn fmt_opt_f64_4(v: Option) -> String { + match v { + Some(x) => format!("{x:.4}"), + None => "-".to_string(), + } +} + +/// SI-formatted optional `u32` counter (`-` when None). +fn fmt_opt_u32_si(v: Option) -> String { + match v { + Some(x) => format::si(x as f64), + None => "-".to_string(), + } +} + +/// SI-formatted optional `u64` counter (`-` when None). +fn fmt_opt_u64_si(v: Option) -> String { + match v { + Some(x) => format::si(x as f64), + None => "-".to_string(), + } +} + +fn fmt_opt_f32_4(v: Option) -> String { + match v { + Some(x) => format!("{x:.4}"), + None => "-".to_string(), + } +} + +fn fmt_opt_bool(v: Option) -> &'static str { + match v { + Some(true) => "true", + Some(false) => "false", + None => "-", + } +} + +fn verdict_span(v: PassFail, theme: &Theme) -> Span<'static> { + let (label, color) = match v { + PassFail::Pass => ("Pass", theme.ok), + PassFail::Fail => ("Fail", theme.err), + PassFail::Unknown => ("Unknown", theme.muted), + }; + Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +fn section_header(title: &str, theme: &Theme) -> Line<'static> { + Line::from(Span::styled( + format!("— {} —", title), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::BOLD), + )) +} + +fn kv_line(key: &str, value: String, theme: &Theme) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {key:<22} "), Style::default().fg(theme.accent)), + Span::styled(value, Style::default().fg(theme.fg)), + ]) +} + +fn kv_span_line(key: &str, value: Span<'static>, theme: &Theme) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {key:<22} "), Style::default().fg(theme.accent)), + value, + ]) +} + +fn build_detail_lines(row: &BenchmarkRow, theme: &Theme) -> Vec> { + let mut lines: Vec = Vec::with_capacity(48); + lines.push(section_header("identity", theme)); + lines.push(kv_line("cell", row.cell.clone(), theme)); + lines.push(kv_line("run", row.run.to_string(), theme)); + lines.push(kv_line("model", fmt_opt(&row.model), theme)); + lines.push(kv_line("endpoint", fmt_opt(&row.endpoint), theme)); + lines.push(kv_line("judge_model", fmt_opt(&row.judge_model), theme)); + lines.push(Line::raw("")); + + // config + lines.push(section_header("config", theme)); + lines.push(kv_line("tp", fmt_opt(&row.tp), theme)); + lines.push(kv_line("pp", fmt_opt(&row.pp), theme)); + lines.push(kv_line("dtype", fmt_opt(&row.dtype), theme)); + lines.push(kv_line( + "attention_backend", + fmt_opt(&row.attention_backend), + theme, + )); + lines.push(kv_line("max_num_seqs", fmt_opt(&row.max_num_seqs), theme)); + lines.push(kv_line("concurrency", fmt_opt(&row.concurrency), theme)); + lines.push(kv_line("extra_args", fmt_opt(&row.extra_args), theme)); + lines.push(Line::raw("")); + + // performance + lines.push(section_header("performance", theme)); + lines.push(kv_line( + "wall_s", + row.wall_s + .map(format::duration) + .unwrap_or_else(|| "-".into()), + theme, + )); + lines.push(kv_line("n_requests", fmt_opt_u32_si(row.n_requests), theme)); + lines.push(kv_line( + "prompt_tokens", + fmt_opt_u64_si(row.prompt_tokens), + theme, + )); + lines.push(kv_line( + "prompt_tps", + format::tps_opt(row.prompt_tps), + theme, + )); + lines.push(kv_line( + "completion_tokens", + fmt_opt_u64_si(row.completion_tokens), + theme, + )); + lines.push(kv_line("gen_tps", format::tps_opt(row.gen_tps), theme)); + lines.push(kv_line( + "max_running_reqs", + fmt_opt_u32_si(row.max_running_reqs), + theme, + )); + lines.push(kv_line( + "max_waiting_reqs", + fmt_opt_u32_si(row.max_waiting_reqs), + theme, + )); + lines.push(kv_line("out_chars", fmt_opt_u64_si(row.out_chars), theme)); + lines.push(Line::raw("")); + + // verdict + lines.push(section_header("verdict", theme)); + lines.push(kv_line("rc", fmt_opt(&row.rc), theme)); + lines.push(kv_span_line( + "pass_fail", + verdict_span(row.pass_fail, theme), + theme, + )); + lines.push(kv_span_line( + "judge_pass_fail", + verdict_span(row.judge_pass_fail, theme), + theme, + )); + lines.push(kv_line( + "assertion_pass", + fmt_opt_bool(row.assertion_pass).to_string(), + theme, + )); + lines.push(kv_line( + "assertion_fail_count", + fmt_opt(&row.assertion_fail_count), + theme, + )); + lines.push(kv_line( + "assertion_summary", + fmt_opt(&row.assertion_summary), + theme, + )); + lines.push(kv_line( + "quality_score", + fmt_opt_f32_4(row.quality_score), + theme, + )); + lines.push(kv_line( + "safety_pass", + fmt_opt_bool(row.safety_pass).to_string(), + theme, + )); + lines.push(kv_line( + "safety_violations", + fmt_opt(&row.safety_violations), + theme, + )); + + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(cell: &str, tp: Option, dtype: Option<&str>) -> PassNRollup { + PassNRollup { + cell: cell.to_string(), + model: None, + engine: None, + tp, + dtype: dtype.map(|s| s.to_string()), + concurrency: None, + n_trials: 0, + n_passed: 0, + pass_n_of_n: false, + pass_at_n: false, + mean_prompt_tps: None, + mean_gen_tps: None, + } + } + + #[test] + fn cfg_token_renders_tp_and_dtype() { + assert_eq!(cfg_token(&group("A", Some(4), Some("fp8"))), "4·fp8"); + assert_eq!(cfg_token(&group("A", None, Some("fp16"))), "-·fp16"); + assert_eq!(cfg_token(&group("A", Some(8), None)), "8·-"); + assert_eq!(cfg_token(&group("A", None, None)), "-·-"); + } + + #[test] + fn verdict_mark_colors_pass_and_fail() { + let theme = Theme::default_dark(); + let ok = verdict_mark(true, &theme); + assert_eq!(ok.content, "✓"); + assert_eq!(ok.style.fg, Some(theme.ok)); + let err = verdict_mark(false, &theme); + assert_eq!(err.content, "✗"); + assert_eq!(err.style.fg, Some(theme.err)); + } + + #[test] + fn sparkline_max_handles_empty_and_zero() { + assert_eq!(sparkline_max(&[]), 100); + assert_eq!(sparkline_max(&[0, 0]), 100); + let m = sparkline_max(&[100, 250]); + assert!(m >= 500); + let m2 = sparkline_max(&[600]); + assert!(m2 >= 1000); + } + + #[test] + fn visible_window_anchors_to_tail_when_sel_in_tail() { + // 50 rows, 10 visible, selecting newest -> window [40, 50). + assert_eq!(visible_window(50, 10, 49), (40, 50)); + // Selecting somewhere inside the default tail window stays anchored. + assert_eq!(visible_window(50, 10, 45), (40, 50)); + } + + #[test] + fn visible_window_scrolls_up_when_sel_above_tail() { + // sel=5 is well above the default [40, 50) tail; window should shift. + let (start, end) = visible_window(50, 10, 5); + assert_eq!(start, 5); + assert_eq!(end, 15); + assert!(start <= 5 && 5 < end); + } + + #[test] + fn visible_window_handles_empty_and_zero_height() { + assert_eq!(visible_window(0, 10, 0), (0, 0)); + assert_eq!(visible_window(10, 0, 5), (0, 0)); + } + + #[test] + fn visible_window_keeps_sel_visible_when_total_smaller_than_capacity() { + // total < visible_rows: show everything. + assert_eq!(visible_window(3, 10, 0), (0, 3)); + assert_eq!(visible_window(3, 10, 2), (0, 3)); + } + + #[test] + fn visible_window_scrolls_down_when_sel_below_default_window() { + // total=20, visible=5: default window is [15, 20). sel=18 stays inside. + assert_eq!(visible_window(20, 5, 18), (15, 20)); + // Smaller window: visible=3, default [17,20). sel=10 needs scroll. + let (s, e) = visible_window(20, 3, 10); + assert!(s <= 10 && 10 < e); + assert_eq!(e - s, 3); + } + + #[test] + fn row_hit_returns_none_for_header_or_out_of_bounds() { + // 30 cols wide, 10 rows tall, anchored at (5, 2). + let inner = Rect::new(5, 2, 30, 10); + // Header row at y=2. + assert_eq!(row_hit(inner, 0, 5, 10, 2), None); + // Outside x range. + assert_eq!(row_hit(inner, 0, 5, 4, 3), None); + assert_eq!(row_hit(inner, 0, 5, 35, 3), None); + // Outside y range. + assert_eq!(row_hit(inner, 0, 5, 10, 1), None); + assert_eq!(row_hit(inner, 0, 5, 10, 12), None); + } + + #[test] + fn row_hit_maps_data_lines_to_window_indices() { + let inner = Rect::new(0, 0, 20, 10); + // Window [10, 15): 5 data rows starting at y=1. + assert_eq!(row_hit(inner, 10, 15, 5, 1), Some(10)); + assert_eq!(row_hit(inner, 10, 15, 5, 2), Some(11)); + assert_eq!(row_hit(inner, 10, 15, 5, 5), Some(14)); + // y=6 lands past the visible window (only 5 data rows shown). + assert_eq!(row_hit(inner, 10, 15, 5, 6), None); + } + + #[test] + fn row_hit_handles_zero_dim_area() { + let zero_w = Rect::new(0, 0, 0, 10); + assert_eq!(row_hit(zero_w, 0, 5, 0, 1), None); + let zero_h = Rect::new(0, 0, 10, 0); + assert_eq!(row_hit(zero_h, 0, 5, 0, 0), None); + } + + #[test] + fn row_hit_handles_empty_window() { + let inner = Rect::new(0, 0, 10, 5); + // start == end → no data lines. + assert_eq!(row_hit(inner, 3, 3, 5, 1), None); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/chat.rs b/crates/rocm-dash-tui/src/ui/tabs/chat.rs new file mode 100644 index 00000000..501a6374 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/tabs/chat.rs @@ -0,0 +1,445 @@ +//! Chat tab — a TUI-local conversation surface. Phase 1 is render-only with a +//! local echo backend; later phases wire a Rig-built agent behind the +//! `AgentClient` trait. The transcript and input buffer are plain TUI state on +//! `AppState` (`rocm-dash-core` carries no chat types). + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use crate::app::{AppState, ChatConsent, ChatRole}; +use crate::ui::theme::Theme; + +/// Block glyph used to mark the text-entry caret while focused. +const CURSOR_GLYPH: &str = "▋"; + +pub fn draw(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + // Until the user consents to the detected endpoint, the tab shows a gate / + // empty-state instead of the transcript+input surface. + if state.chat_consent != ChatConsent::Accepted { + draw_consent(f, area, state, theme); + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + draw_transcript(f, rows[0], state, theme); + draw_input(f, rows[1], state, theme); +} + +/// Render the consent prompt / empty-state, depending on detection + decision. +fn draw_consent(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let endpoint = state + .chat_llm + .as_ref() + .map(|c| format!("{} (model: {})", c.base_url, c.model)); + + // Detect-flow states take over the gate: a probe in flight, then an offer. + let detect_hint = Line::from(vec![ + Span::styled( + "[d] ", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled("detect a local engine", Style::default().fg(theme.fg)), + ]); + + let (title, lines): (&str, Vec) = if state.chat_detecting { + ( + " Chat — detecting… ", + vec![Line::from(Span::styled( + "Probing for a local engine (Lemonade :13305 / vLLM :8000)…", + Style::default().fg(theme.fg), + ))], + ) + } else if let Some(offer) = state.chat_detect_offer.as_ref() { + ( + " Chat — use detected engine? ", + vec![ + Line::from(Span::styled( + "Detected a local engine:", + Style::default().fg(theme.fg), + )), + Line::raw(""), + Line::from(Span::styled( + format!("{} (model: {})", offer.base_url, offer.model), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(vec![ + Span::styled( + "[y] ", + Style::default().fg(theme.ok).add_modifier(Modifier::BOLD), + ), + Span::styled("use now ", Style::default().fg(theme.fg)), + Span::styled( + "[s] ", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled("use & save ", Style::default().fg(theme.fg)), + Span::styled( + "[n] ", + Style::default().fg(theme.err).add_modifier(Modifier::BOLD), + ), + Span::styled("dismiss", Style::default().fg(theme.fg)), + ]), + Line::raw(""), + Line::from(Span::styled( + "'use now' lasts this session; 'use & save' writes tui.chat_url to your config.", + Style::default().fg(theme.muted), + )), + ], + ) + } else { + // Normal consent gate, with a detect affordance + last-attempt message. + let (title, mut lines) = consent_gate_lines(state, theme, endpoint); + lines.push(Line::raw("")); + lines.push(detect_hint); + if let Some(m) = state.chat_detect_msg.as_ref() { + lines.push(Line::from(Span::styled( + m.clone(), + Style::default().fg(theme.warn), + ))); + } + (title, lines) + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let p = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + f.render_widget(p, area); +} + +/// The base consent-gate content for the current consent state (without the +/// detect affordance, which the caller appends). +fn consent_gate_lines<'a>( + state: &AppState, + theme: &Theme, + endpoint: Option, +) -> (&'a str, Vec>) { + match state.chat_consent { + ChatConsent::Pending => ( + " Chat — use this endpoint? ", + vec![ + Line::from(Span::styled( + "An LLM endpoint was detected for chat:", + Style::default().fg(theme.fg), + )), + Line::raw(""), + Line::from(Span::styled( + endpoint.clone().unwrap_or_else(|| "(unknown)".into()), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(vec![ + Span::styled( + "[y] ", + Style::default().fg(theme.ok).add_modifier(Modifier::BOLD), + ), + Span::styled("use it ", Style::default().fg(theme.fg)), + Span::styled( + "[n] ", + Style::default().fg(theme.err).add_modifier(Modifier::BOLD), + ), + Span::styled("not now", Style::default().fg(theme.fg)), + ]), + Line::raw(""), + Line::from(Span::styled( + "Your request only leaves this machine after you accept.", + Style::default().fg(theme.muted), + )), + ], + ), + ChatConsent::Declined => ( + " Chat — disabled ", + vec![ + Line::from(Span::styled( + "Chat is off. No requests will be sent.", + Style::default().fg(theme.fg), + )), + Line::raw(""), + Line::from(vec![ + Span::styled( + "[y] ", + Style::default().fg(theme.ok).add_modifier(Modifier::BOLD), + ), + Span::styled( + endpoint + .map(|e| format!("enable {e}")) + .unwrap_or_else(|| "enable".into()), + Style::default().fg(theme.fg), + ), + ]), + ], + ), + // Unavailable (or Accepted, which never reaches here). + _ => ( + " Chat — no endpoint detected ", + vec![ + Line::from(Span::styled( + "No LLM endpoint was detected.", + Style::default().fg(theme.fg), + )), + Line::raw(""), + Line::from(Span::styled( + "To enable chat, point it at an OpenAI-compatible endpoint:", + Style::default().fg(theme.muted), + )), + Line::from(Span::styled( + " • --chat-url http://host:port (or tui.chat_url in config)", + Style::default().fg(theme.muted), + )), + Line::from(Span::styled( + " • OPENAI_BASE_URL in the environment", + Style::default().fg(theme.muted), + )), + Line::from(Span::styled( + " • or run a local endpoint at 127.0.0.1:8000", + Style::default().fg(theme.muted), + )), + ], + ), + } +} + +/// Render the scrolling transcript. Empty state shows an actionable hint. +fn draw_transcript(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Chat ") + .border_style(theme.border_style()) + .title_style(theme.title_style()); + + let lines = if state.chat.is_empty() { + vec![ + Line::from(Span::styled( + "No messages yet.", + Style::default().fg(theme.muted), + )), + Line::raw(""), + Line::from(Span::styled( + "Press i (or Enter) to start typing, Enter to send, Esc to leave insert mode.", + Style::default().fg(theme.muted), + )), + ] + } else { + transcript_lines(state, theme) + }; + + let p = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((state.chat_scroll, 0)); + f.render_widget(p, area); +} + +/// Pure transcript → styled lines mapping. Each turn becomes a role-prefixed, +/// role-colored line. Kept free of `Frame` so it can be unit-tested. +pub fn transcript_lines<'a>(state: &'a AppState, theme: &Theme) -> Vec> { + let mut lines: Vec = Vec::with_capacity(state.chat.len()); + for turn in &state.chat { + let (prefix, color) = match turn.role { + ChatRole::User => ("you ", theme.accent), + ChatRole::Agent => ("rocm ", theme.fg), + ChatRole::Error => ("err ", theme.err), + }; + // Multi-line content (e.g. an answer plus a "⚙ via: …" Skill annotation) + // renders one terminal line per segment; continuation lines are indented + // to align under the first line's content. + for (i, seg) in turn.content.split('\n').enumerate() { + let lead = if i == 0 { prefix } else { " " }; + lines.push(Line::from(vec![ + Span::styled( + lead, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled(seg, Style::default().fg(color)), + ])); + } + } + lines +} + +/// Render the single-row input line. While focused, a caret glyph trails the +/// buffer; otherwise a muted hint invites focus. +fn draw_input(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + // While a request is in flight, show a spinner and suppress the caret — + // input is disabled until the reply or error turn lands. + if state.chat_sending { + let block = Block::default() + .borders(Borders::ALL) + .title(" Message (sending…) ") + .border_style(Style::default().fg(theme.warn)) + .title_style(Style::default().fg(theme.warn)); + let line = Line::from(Span::styled( + "⠿ waiting for the agent…", + Style::default().fg(theme.muted), + )); + f.render_widget(Paragraph::new(line).block(block), area); + return; + } + + let (title, border_color) = if state.chat_focused { + (" Message (insert) ", theme.accent) + } else { + (" Message ", theme.muted) + }; + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)) + .title_style(Style::default().fg(border_color)); + + let line = if state.chat_focused { + Line::from(vec![ + Span::styled(state.chat_input.as_str(), Style::default().fg(theme.fg)), + Span::styled(CURSOR_GLYPH, Style::default().fg(theme.accent)), + ]) + } else if state.chat_input.is_empty() { + Line::from(Span::styled( + "press i to type…", + Style::default().fg(theme.muted), + )) + } else { + Line::from(Span::styled( + state.chat_input.as_str(), + Style::default().fg(theme.muted), + )) + }; + + f.render_widget(Paragraph::new(line).block(block), area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppState, ChatTurn}; + + #[test] + fn transcript_lines_one_per_single_line_turn() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat.push(ChatTurn::user("hello")); + s.chat.push(ChatTurn::agent("echo: hello")); + s.chat.push(ChatTurn::error("boom")); + let theme = s.theme; + let lines = transcript_lines(&s, &theme); + assert_eq!(lines.len(), 3); + } + + #[test] + fn transcript_renders_skill_annotation_on_its_own_line() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // An agent reply carrying a tool-call surfacing annotation. + s.chat.push(ChatTurn::agent( + "GPU-2 is at 87% util, 71°C.\n⚙ via: gpu_status", + )); + let theme = s.theme; + let lines = transcript_lines(&s, &theme); + // The annotation splits into its own line and is visible in the render. + assert_eq!(lines.len(), 2, "answer + annotation render as two lines"); + assert!( + format!("{lines:?}").contains("gpu_status"), + "the fired Skill is surfaced in the transcript" + ); + } + + #[test] + fn draw_does_not_panic_across_consent_states() { + use crate::app::ChatConsent; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let render = |s: &AppState| { + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f, f.area(), s, &s.theme)).unwrap(); + }; + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + + // Unavailable empty-state. + render(&s); + + let llm = crate::llm::LlmConfig { + base_url: "http://127.0.0.1:8000".into(), + model: "m".into(), + api_key: None, + auth_header: None, + }; + // Pending consent prompt. + s.set_chat_config(Some(llm.clone()), false); + render(&s); + // Declined. + s.chat_consent = ChatConsent::Declined; + render(&s); + + // Accepted: populated transcript + focused input. + s.set_chat_config(Some(llm), true); + s.chat_focused = true; + s.chat_input = "what's GPU-2 doing?".into(); + s.chat.push(ChatTurn::user("hi")); + s.chat.push(ChatTurn::agent("echo: hi")); + render(&s); + } + + fn render_str(s: &AppState) -> String { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f, f.area(), s, &s.theme)).unwrap(); + let buf = term.backend().buffer().clone(); + buf.content().iter().map(|c| c.symbol()).collect() + } + + #[test] + fn gate_shows_detect_affordance_and_message() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + // Unavailable empty-state offers detection. + assert!(render_str(&s).contains("detect a local engine")); + // After a fruitless detect, the message shows. + s.set_detect_result(None); + assert!(render_str(&s).contains("no local engine found")); + } + + #[test] + fn detecting_state_renders_progress() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + s.request_detect(); + assert!(render_str(&s).contains("Probing for a local engine")); + } + + #[test] + fn offer_state_shows_endpoint_and_choices() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + s.set_detect_result(Some(crate::llm::detected_llm_config( + "http://localhost:13305/v1", + "Llama-3.2-3B", + ))); + let out = render_str(&s); + assert!(out.contains("Detected a local engine")); + assert!(out.contains("localhost:13305")); + assert!(out.contains("Llama-3.2-3B")); + assert!(out.contains("use now")); + assert!(out.contains("use & save")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/hardware.rs b/crates/rocm-dash-tui/src/ui/tabs/hardware.rs new file mode 100644 index 00000000..f0d06d01 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/tabs/hardware.rs @@ -0,0 +1,1246 @@ +//! Hardware tab — full-screen detail of host CPU/memory + per-GPU panels. +//! +//! Layout (vertical): +//! 1. CPU panel (~10 rows): aggregate braille sparkline over history plus +//! per-core bars at the bottom. +//! 2. Memory + Swap (~3 rows): two side-by-side gauges. +//! 3. Per-GPU panels (remaining): one bordered block per GPU with stats, +//! firmware/partition info, and a util sparkline. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use rocm_dash_core::metrics::{GpuMetrics, GpuSystemInfo, Snapshot}; + +use crate::app::AppState; +use crate::ui::core_bars::CoreBars; +use crate::ui::format; +use crate::ui::gradient::GradientGauge; +use crate::ui::sparkline::BrailleSparkline; +use crate::ui::theme::Theme; +use crate::ui::widgets::{ + POWER_CRIT_W, gpu_stats_line, instances_on_gpu, node_efficiency, power_style, + temperature_style, trunc, +}; + +/// Rows consumed above the GPU section in [`draw`] (CPU 10 + mem/swap 3 + I/O 3). +const ROWS_ABOVE_GPUS: u16 = 16; +/// Height of the GPU-section summary header (partition + efficiency). +const GPU_HEADER_H: u16 = 1; +/// Minimum slot height for a "full" GPU panel: border (2) + stats (1) + +/// info (1) + sparkline (≥2). +const FULL_PANEL_MIN_H: u16 = 6; + +pub fn draw(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let snap = match state.latest.as_ref() { + Some(s) => s, + None => { + let p = Paragraph::new(Line::from(Span::styled( + "waiting for first snapshot…", + Style::default().fg(theme.muted), + ))) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Hardware ") + .border_style(theme.border_style()) + .title_style(theme.title_style()), + ); + f.render_widget(p, area); + return; + } + }; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(10), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(area); + + draw_cpu(f, rows[0], state, snap, theme); + draw_memory_row(f, rows[1], snap, theme); + draw_io_row(f, rows[2], snap, theme); + draw_gpus(f, rows[3], state, snap, theme); +} + +/// Disk and network throughput, side by side. These four fields +/// (`disk_read_bps`, `disk_write_bps`, `net_rx_bps`, `net_tx_bps`) are +/// collected by the host sampler but were not surfaced anywhere in the UI. +fn draw_io_row(f: &mut Frame, area: Rect, snap: &Snapshot, theme: &Theme) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + draw_rate_block( + f, + cols[0], + " Disk ", + ("read", snap.host.disk_read_bps), + ("write", snap.host.disk_write_bps), + theme, + ); + draw_rate_block( + f, + cols[1], + " Net ", + ("rx", snap.host.net_rx_bps), + ("tx", snap.host.net_tx_bps), + theme, + ); +} + +/// A bordered block showing two labeled byte-rates on one line. +fn draw_rate_block( + f: &mut Frame, + area: Rect, + title: &str, + a: (&str, u64), + b: (&str, u64), + theme: &Theme, +) { + let block = Block::default() + .borders(Borders::ALL) + .title(title.to_string()) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + let line = Line::from(vec![ + Span::styled(format!("{} ", a.0), Style::default().fg(theme.muted)), + Span::styled(format::bps(a.1 as f64), Style::default().fg(theme.accent)), + Span::styled(format!(" {} ", b.0), Style::default().fg(theme.muted)), + Span::styled(format::bps(b.1 as f64), Style::default().fg(theme.accent)), + ]); + f.render_widget(Paragraph::new(line), inner); +} + +fn draw_cpu(f: &mut Frame, area: Rect, state: &AppState, snap: &Snapshot, theme: &Theme) { + let n_cores = snap.host.cpu_per_core_pct.len(); + let title = format!( + " CPU · {} · {} cores ", + format::pct(snap.host.cpu_overall_pct), + n_cores + ); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + + let split = if n_cores == 0 || inner.height < 4 { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1)]) + .split(inner) + } else { + let core_rows = 3u16.min(inner.height.saturating_sub(2)); + let agg_rows = inner.height.saturating_sub(core_rows).max(1); + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(agg_rows), Constraint::Length(core_rows)]) + .split(inner) + }; + + let data: Vec = state + .history + .iter() + .map(|s| s.host.cpu_overall_pct.clamp(0.0, 100.0) as u64) + .collect(); + let spark = BrailleSparkline::new(&data) + .max(100) + .style(Style::default().fg(theme.accent)) + .gradient(theme.ok, theme.warn, theme.err); + f.render_widget(spark, split[0]); + + if split.len() == 2 { + let bars = CoreBars::new(&snap.host.cpu_per_core_pct) + .max(100.0) + .style(Style::default().fg(theme.ok)) + .gradient(theme.ok, theme.warn, theme.err); + f.render_widget(bars, split[1]); + } +} + +fn draw_memory_row(f: &mut Frame, area: Rect, snap: &Snapshot, theme: &Theme) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + draw_gauge_block( + f, + cols[0], + " Memory ", + snap.host.memory_used_mb, + snap.host.memory_total_mb, + theme, + ); + draw_gauge_block( + f, + cols[1], + " Swap ", + snap.host.swap_used_mb, + snap.host.swap_total_mb, + theme, + ); +} + +fn draw_gauge_block( + f: &mut Frame, + area: Rect, + label: &str, + used_mb: u64, + total_mb: u64, + theme: &Theme, +) { + let (ratio, title) = if total_mb > 0 { + let r = (used_mb as f64 / total_mb as f64).clamp(0.0, 1.0); + ( + r, + format!("{label}· {} ", format::mib_pair(used_mb, total_mb)), + ) + } else { + // No total reported: render flat gauge with just the used value. + (0.0, format!("{label}· {} ", format::mib(used_mb))) + }; + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + let label = format::pct((ratio * 100.0) as f32); + let gauge = GradientGauge::new(ratio) + .stops(theme.ok, theme.warn, theme.err) + .track_bg(theme.surface_2) + .label(&label) + .label_fg(theme.fg); + f.render_widget(gauge, inner); +} + +fn draw_gpus(f: &mut Frame, area: Rect, state: &AppState, snap: &Snapshot, theme: &Theme) { + if snap.gpus.is_empty() { + let lines: Vec = if !snap.warnings.is_empty() { + snap.warnings + .iter() + .map(|w| Line::from(Span::styled(w.clone(), Style::default().fg(theme.warn)))) + .collect() + } else { + vec![Line::from(Span::styled( + "no GPUs reported", + Style::default().fg(theme.muted), + ))] + }; + let p = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(" GPUs ") + .border_style(theme.border_style()) + .title_style(theme.title_style()), + ); + f.render_widget(p, area); + return; + } + + let n = snap.gpus.len(); + let sel = state.gpu_sel.min(n - 1); + + // Reserve one row for the section header (partition + efficiency) when there + // is room; otherwise hand the whole area to the panels. + let body = if area.height >= 2 { + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(GPU_HEADER_H), Constraint::Min(0)]) + .split(area); + f.render_widget(Paragraph::new(gpu_section_header(snap, theme)), split[0]); + split[1] + } else { + area + }; + + let (full, visible) = plan_gpu_rows(body.height, n); + if full { + draw_gpus_full(f, body, state, snap, theme, sel); + } else { + draw_gpus_compact(f, body, snap, theme, sel, state.gpu_scroll, visible); + } +} + +/// One-line GPU-section summary: GPU count, physical/logical partitioning and +/// per-logical VRAM (#5), total board power, and node energy efficiency (#6). +fn gpu_section_header(snap: &Snapshot, theme: &Theme) -> Line<'static> { + let n = snap.gpus.len(); + let mut spans = vec![Span::styled( + format!("{n} GPU"), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + )]; + if let Some(si) = snap.gpu_system_info.as_ref() { + spans.push(Span::styled( + format!( + " · phys {} / logical {}", + si.physical_gpu_count, si.logical_gpu_count + ), + Style::default().fg(theme.muted), + )); + if si.vram_per_logical_gpu_mb > 0 { + spans.push(Span::styled( + format!(" · {}/logical", format::mib(si.vram_per_logical_gpu_mb)), + Style::default().fg(theme.muted), + )); + } + } + let total_power: f64 = snap.gpus.iter().map(|g| g.power_w as f64).sum(); + spans.push(Span::styled( + format!(" · {:.1} kW", total_power / 1000.0), + Style::default().fg(theme.fg), + )); + let eff = node_efficiency(snap); + spans.push(Span::styled( + format!(" · eff {}", format::tokens_per_watt(eff)), + Style::default().fg(if eff.is_some() { theme.ok } else { theme.muted }), + )); + Line::from(spans) +} + +/// Decide how the GPU section renders for a given section height and GPU count. +/// Returns `(full_panels, visible_rows)`: +/// - `full_panels == true` → every GPU gets a bordered panel (`visible == n`). +/// - `full_panels == false` → compact one-line rows; `visible_rows` is how many +/// GPU rows are shown (one row is reserved for the overflow indicator when +/// the list is longer than the section). +fn plan_gpu_rows(section_h: u16, n: usize) -> (bool, usize) { + if n == 0 { + return (true, 0); + } + if (n as u16).saturating_mul(FULL_PANEL_MIN_H) <= section_h { + return (true, n); + } + let cap = section_h as usize; + if n <= cap { + (false, n) // every GPU fits as a compact row — no scroll needed + } else { + (false, cap.saturating_sub(1).max(1)) // reserve one row for the indicator + } +} + +/// Visible compact-row count for the Hardware tab given the full body height. +/// Used by `AppState` to keep `gpu_scroll` in sync on navigation. Accounts for +/// both the rows above the GPU section and the section's own header row. +pub fn gpu_visible_count(body_h: u16, n: usize) -> usize { + let section = body_h.saturating_sub(ROWS_ABOVE_GPUS); + let panels = if section >= 2 { + section - GPU_HEADER_H + } else { + section + }; + plan_gpu_rows(panels, n).1 +} + +/// Advance/clamp a scroll offset so index `sel` lies within +/// `[scroll, scroll + visible)`. +pub fn scroll_to_show(sel: usize, scroll: usize, visible: usize) -> usize { + if visible == 0 { + 0 + } else if sel < scroll { + sel + } else if sel >= scroll + visible { + sel + 1 - visible + } else { + scroll + } +} + +/// A short inline utilization bar like `▕███░░░▏`. +fn util_bar(pct: f32, cells: usize) -> String { + let filled = (((pct.clamp(0.0, 100.0) / 100.0) * cells as f32).round() as usize).min(cells); + let mut s = String::with_capacity(cells + 2); + s.push('▕'); + for _ in 0..filled { + s.push('█'); + } + for _ in 0..cells - filled { + s.push('░'); + } + s.push('▏'); + s +} + +/// One dense line summarizing a GPU: selection marker, id, util bar, util%, +/// temperature and power (threshold-colored), and — when the width allows — +/// vram. Pure; used by the compact GPU layout. +pub fn gpu_compact_line( + g: &GpuMetrics, + theme: &Theme, + width: u16, + selected: bool, +) -> Line<'static> { + let marker = if selected { "▸" } else { " " }; + let id = trunc(&g.device_id, 7); + let id_style = if selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + let mut spans = vec![ + Span::styled(format!("{marker}{id:<7} "), id_style), + Span::styled( + format!("{} ", util_bar(g.gpu_utilization_pct, 6)), + Style::default().fg(theme.accent), + ), + Span::styled( + format!("{:>3.0}% ", g.gpu_utilization_pct), + Style::default().fg(theme.fg), + ), + Span::styled( + format!("{:>3.0}°C ", g.temperature_c), + temperature_style(g.temperature_c, theme), + ), + Span::styled( + format!("{:>4.0} W", g.power_w), + power_style(g.power_w, theme), + ), + ]; + let base_w: usize = spans.iter().map(|s| s.content.chars().count()).sum(); + let used_g = g.vram_used_mb as f64 / 1024.0; + let tot_g = g.vram_total_mb as f64 / 1024.0; + let vram = format!(" {used_g:.0}/{tot_g:.0} GB"); + if base_w + vram.chars().count() <= width as usize { + spans.push(Span::styled(vram, Style::default().fg(theme.muted))); + } + Line::from(spans) +} + +/// Full-panel GPU rendering: one bordered block per GPU, equal vertical split, +/// with a stats line, an info line, and a utilization sparkline. +fn draw_gpus_full( + f: &mut Frame, + area: Rect, + state: &AppState, + snap: &Snapshot, + theme: &Theme, + sel: usize, +) { + let n = snap.gpus.len() as u16; + let per_gpu = (area.height / n).max(1); + let constraints: Vec = (0..n).map(|_| Constraint::Length(per_gpu)).collect(); + let slots = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let sysinfo = snap.gpu_system_info.as_ref(); + + for (i, g) in snap.gpus.iter().enumerate() { + let slot = slots[i]; + if slot.height == 0 { + continue; + } + let selected = i == sel; + let border_style = if selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + theme.border_style() + }; + let title_style = if selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + theme.title_style() + }; + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" GPU {} ", g.device_id)) + .border_style(border_style) + .title_style(title_style); + let inner = block.inner(slot); + f.render_widget(block, slot); + if inner.height == 0 { + continue; + } + + let stats_line = gpu_stats_line(g, theme); + let info_line = gpu_info_line(g.clock_mhz, sysinfo, theme); + + if inner.height == 1 { + f.render_widget(Paragraph::new(stats_line), inner); + continue; + } + if inner.height == 2 { + f.render_widget(Paragraph::new(vec![stats_line, info_line]), inner); + continue; + } + + // Serving line (#4): which models run on this GPU. Shown only when the + // panel is tall enough to keep a ≥2-row sparkline beneath it. + let serving = serving_line( + &instances_on_gpu(&g.device_id, &snap.instances), + inner.width, + theme, + ); + let want_serving = serving.is_some() && inner.height >= 4; + + let constraints: &[Constraint] = if want_serving { + &[ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ] + } else { + &[ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ] + }; + let split = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints.to_vec()) + .split(inner); + f.render_widget(Paragraph::new(stats_line), split[0]); + f.render_widget(Paragraph::new(info_line), split[1]); + let spark_idx = if want_serving { + f.render_widget(Paragraph::new(serving.unwrap()), split[2]); + 3 + } else { + 2 + }; + + let history: Vec = state + .history + .iter() + .filter_map(|s| { + s.gpus + .get(i) + .map(|gpu| gpu.gpu_utilization_pct.clamp(0.0, 100.0) as u64) + }) + .collect(); + let spark = BrailleSparkline::new(&history) + .max(100) + .style(Style::default().fg(theme.accent)) + .gradient(theme.ok, theme.warn, theme.err); + f.render_widget(spark, split[spark_idx]); + } +} + +/// "serving: model[, model]" line for a GPU's instances, or `None` when the +/// GPU has no instances. Truncated to fit `width`. +fn serving_line( + insts: &[&rocm_dash_core::metrics::Instance], + width: u16, + theme: &Theme, +) -> Option> { + if insts.is_empty() { + return None; + } + let models = insts + .iter() + .map(|i| i.model_name.as_str()) + .collect::>() + .join(", "); + let budget = (width as usize).saturating_sub("serving: ".len()); + let models = trunc(&models, budget.max(1)); + Some(Line::from(vec![ + Span::styled("serving: ".to_string(), Style::default().fg(theme.muted)), + Span::styled(models, Style::default().fg(theme.fg)), + ])) +} + +/// Compact GPU rendering: one dense line per GPU within a scrolled window, +/// with an overflow indicator when GPUs are hidden above or below. +fn draw_gpus_compact( + f: &mut Frame, + area: Rect, + snap: &Snapshot, + theme: &Theme, + sel: usize, + scroll: usize, + visible: usize, +) { + let n = snap.gpus.len(); + let eff_scroll = scroll_to_show(sel, scroll, visible).min(n.saturating_sub(visible)); + let end = (eff_scroll + visible).min(n); + let hidden_above = eff_scroll; + let hidden_below = n - end; + + let mut y = area.y; + let bottom = area.y + area.height; + for i in eff_scroll..end { + if y >= bottom { + break; + } + let g = &snap.gpus[i]; + let line = gpu_compact_line(g, theme, area.width, i == sel); + f.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1)); + y += 1; + } + if (hidden_above > 0 || hidden_below > 0) && y < bottom { + let txt = format!(" ↑ {hidden_above} ↓ {hidden_below} more"); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + txt, + Style::default().fg(theme.muted), + ))), + Rect::new(area.x, y, area.width, 1), + ); + } +} + +fn gpu_info_line<'a>( + clock_mhz: Option, + sysinfo: Option<&'a GpuSystemInfo>, + theme: &Theme, +) -> Line<'a> { + let (compute, memory, rocm, driver) = match sysinfo { + Some(si) => ( + format!("{:?}", si.compute_partition_mode).to_uppercase(), + format!("{:?}", si.memory_partition_mode).to_uppercase(), + si.rocm_version.as_deref().unwrap_or("?").to_string(), + si.driver_version.as_deref().unwrap_or("?").to_string(), + ), + None => ("?".into(), "?".into(), "?".into(), "?".into()), + }; + let clk = match clock_mhz { + Some(v) => format::mhz(v.round() as u64), + None => "-".to_string(), + }; + Line::from(vec![Span::styled( + format!("partition: {compute}/{memory} · ROCm {rocm} · driver {driver} · clk {clk}"), + Style::default().fg(theme.muted), + )]) +} + +// ---- detail modal ----------------------------------------------------------- + +/// Heatmap redline for temperature (°C): a full bar means junction-redline-hot. +const HEATMAP_TEMP_MAX_C: f64 = 100.0; + +/// Largest of a fixed `floor` (the semantic redline) and the observed maximum +/// in `data`. Keeps a heatmap row normalized to a meaningful limit while still +/// growing if telemetry exceeds that limit. +fn semantic_max(floor: f64, data: &[f64]) -> f64 { + data.iter().copied().fold(floor, f64::max) +} + +/// The detail-modal "now" line, with temperature and power threshold-colored +/// via [`temperature_style`] / [`power_style`]. Pure. +fn detail_now_line(g: &GpuMetrics, theme: &Theme) -> Line<'static> { + let clk = match g.clock_mhz { + Some(v) => format::mhz(v.round() as u64), + None => "-".into(), + }; + Line::from(vec![ + Span::styled(format!("{:<12} ", "now"), Style::default().fg(theme.muted)), + Span::styled( + format!("util {} · ", format::pct(g.gpu_utilization_pct)), + Style::default().fg(theme.fg), + ), + Span::styled( + format::celsius(g.temperature_c), + temperature_style(g.temperature_c, theme), + ), + Span::styled(" · ".to_string(), Style::default().fg(theme.fg)), + Span::styled(format::watts(g.power_w), power_style(g.power_w, theme)), + Span::styled(format!(" · clk {clk}"), Style::default().fg(theme.fg)), + ]) +} + +/// Full-screen detail for the currently-selected GPU. Pulls per-tick samples +/// out of `state.history` to build a metric × time heatmap (util, temp, +/// power, vram%) alongside a summary header and a footer hint. +pub fn draw_detail(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + use crate::ui::heatmap::{Heatmap, HeatmapRow}; + use crate::ui::modal::{centered_rect, draw_popup_frame}; + + let popup = centered_rect(85, 85, 140, 32, area); + let snap = match state.latest.as_ref() { + Some(s) => s, + None => { + let inner = draw_popup_frame(f, popup, "GPU detail", theme); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "no snapshot yet", + Style::default().fg(theme.muted), + ))), + inner, + ); + return; + } + }; + if snap.gpus.is_empty() { + let inner = draw_popup_frame(f, popup, "GPU detail", theme); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "no GPUs reported", + Style::default().fg(theme.muted), + ))), + inner, + ); + return; + } + let i = state.gpu_sel.min(snap.gpus.len() - 1); + let g = &snap.gpus[i]; + + let title = format!(" GPU {} · detail ", g.device_id); + let inner = draw_popup_frame(f, popup, &title, theme); + if inner.height == 0 { + return; + } + + // Vertical layout: 4 summary lines + heatmap (Min) + 1 footer hint. + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), // gap + Constraint::Min(4), + Constraint::Length(1), + ]) + .split(inner); + + let sysinfo = snap.gpu_system_info.as_ref(); + let model = sysinfo.map(|si| si.gpu_model.as_str()).unwrap_or("?"); + let rocm = sysinfo + .and_then(|si| si.rocm_version.as_deref()) + .unwrap_or("?"); + let driver = sysinfo + .and_then(|si| si.driver_version.as_deref()) + .unwrap_or("?"); + let partitions = match sysinfo { + Some(si) => format!( + "{:?} / {:?}", + si.compute_partition_mode, si.memory_partition_mode + ) + .to_uppercase(), + None => "? / ?".into(), + }; + // Summary lines. + let kv = |k: &'static str, v: String, tone: Style| -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{k:<12} "), Style::default().fg(theme.muted)), + Span::styled(v, tone), + ]) + }; + f.render_widget( + Paragraph::new(vec![ + kv( + "device", + format!("{} · {model}", g.device_id), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + kv( + "vram", + format::mib_pair(g.vram_used_mb, g.vram_total_mb), + Style::default().fg(theme.fg), + ), + detail_now_line(g, theme), + kv( + "platform", + format!("partition {partitions} · ROCm {rocm} · driver {driver}"), + Style::default().fg(theme.muted), + ), + ]), + Rect::new(inner.x, inner.y, inner.width, 4), + ); + + // Heatmap rows derived from state.history. + let history = &state.history; + let util: Vec = history + .iter() + .filter_map(|s| s.gpus.get(i).map(|gpu| gpu.gpu_utilization_pct as f64)) + .collect(); + let temp: Vec = history + .iter() + .filter_map(|s| s.gpus.get(i).map(|gpu| gpu.temperature_c as f64)) + .collect(); + let power: Vec = history + .iter() + .filter_map(|s| s.gpus.get(i).map(|gpu| gpu.power_w as f64)) + .collect(); + let vram: Vec = history + .iter() + .filter_map(|s| { + s.gpus.get(i).map(|gpu| { + if gpu.vram_total_mb > 0 { + 100.0 * gpu.vram_used_mb as f64 / gpu.vram_total_mb as f64 + } else { + 0.0 + } + }) + }) + .collect(); + + // Normalize temp/power to fixed semantic redlines so a full bar means + // "near the limit", not "near the largest value seen this session". The + // row still grows if telemetry ever exceeds the redline. + let max_temp = semantic_max(HEATMAP_TEMP_MAX_C, &temp); + let max_power = semantic_max(POWER_CRIT_W as f64, &power); + let rows_vec = vec![ + HeatmapRow::new("util %", util, 100.0).stops(theme.ok, theme.warn, theme.err), + HeatmapRow::new("temp °C", temp, max_temp).stops(theme.ok, theme.warn, theme.err), + HeatmapRow::new("power W", power, max_power).stops(theme.ok, theme.warn, theme.err), + HeatmapRow::new("vram %", vram, 100.0).stops(theme.ok, theme.warn, theme.err), + ]; + let heat = Heatmap::new(&rows_vec) + .stops(theme.ok, theme.warn, theme.err) + .track_bg(theme.surface_2) + .label_style(Style::default().fg(theme.muted)) + .label_width(10); + f.render_widget(heat, rows[5]); + + // Footer hint. + f.render_widget( + Paragraph::new(Line::from(Span::styled( + " each row = a metric over the last N ticks · newest on the right · Esc close", + Style::default().fg(theme.muted), + ))), + rows[6], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ActiveTab, AppState}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use rocm_dash_core::metrics::{GpuSystemInfo, SystemMetrics}; + use rocm_dash_core::partition::{ComputePartitionMode, MemoryPartitionMode}; + + /// Render `draw` to a TestBackend and return the buffer as a flat string. + fn render_to_string(state: &AppState, cols: u16, rows: u16) -> String { + let backend = TestBackend::new(cols, rows); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f, f.area(), state, &state.theme)) + .unwrap(); + let buf = term.backend().buffer().clone(); + buf.content().iter().map(|c| c.symbol()).collect() + } + + fn state_with_snapshot(snap: Snapshot) -> AppState { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Hardware; + s.latest = Some(snap); + s + } + + #[test] + fn draw_renders_disk_and_net_io() { + let snap = Snapshot { + host: SystemMetrics { + disk_read_bps: 1_200_000, + disk_write_bps: 512, + net_rx_bps: 2_500_000, + net_tx_bps: 4_096, + ..Default::default() + }, + ..Default::default() + }; + let out = render_to_string(&state_with_snapshot(snap), 120, 30); + assert!(out.contains("Disk"), "missing Disk label: {out:?}"); + assert!(out.contains("Net"), "missing Net label"); + assert!(out.contains("rx"), "missing rx label"); + assert!(out.contains("tx"), "missing tx label"); + assert!(out.contains("read"), "missing read label"); + assert!(out.contains("write"), "missing write label"); + assert!(out.contains("/s"), "missing rate suffix"); + // a scaled value should be present + assert!(out.contains("M/s"), "missing M/s scaled rate: {out:?}"); + } + + #[test] + fn draw_does_not_panic_when_squeezed() { + let snap = Snapshot { + host: SystemMetrics { + disk_read_bps: 1_000, + net_rx_bps: 2_000, + ..Default::default() + }, + ..Default::default() + }; + let s = state_with_snapshot(snap); + // Heights where the I/O row gets squeezed to 0–1 inner rows. + for h in [1u16, 2, 3, 14, 16] { + let _ = render_to_string(&s, 80, h); + } + } + + fn mk_gpu(id: &str, util: f32, temp: f32, power: f32) -> GpuMetrics { + GpuMetrics { + device_id: id.into(), + vram_used_mb: 142 * 1024, + vram_total_mb: 192 * 1024, + gpu_utilization_pct: util, + temperature_c: temp, + power_w: power, + clock_mhz: Some(1850.0), + } + } + + fn snap_with_gpus(n: usize) -> Snapshot { + Snapshot { + gpus: (0..n) + .map(|i| mk_gpu(&format!("gpu-{i}"), 50.0 + i as f32, 65.0, 400.0)) + .collect(), + ..Default::default() + } + } + + #[test] + fn plan_full_panels_when_roomy() { + // 2 GPUs, plenty of height → full panels for all. + assert_eq!(plan_gpu_rows(40, 2), (true, 2)); + } + + #[test] + fn plan_compact_then_scroll_when_tight() { + // 8 GPUs, section height 8 → too tight for full panels, all fit compact. + assert_eq!(plan_gpu_rows(8, 8), (false, 8)); + // 8 GPUs, section height 4 → compact + scroll, one row reserved for indicator. + assert_eq!(plan_gpu_rows(4, 8), (false, 3)); + } + + #[test] + fn scroll_to_show_tracks_selection() { + // selection past the bottom of the window advances the scroll + assert_eq!(scroll_to_show(5, 0, 3), 3); + // selection above the window pulls the scroll up + assert_eq!(scroll_to_show(1, 4, 3), 1); + // selection already visible leaves scroll unchanged + assert_eq!(scroll_to_show(2, 1, 3), 1); + // degenerate visible + assert_eq!(scroll_to_show(9, 4, 0), 0); + } + + #[test] + fn appstate_navigation_advances_gpu_scroll() { + // 8 GPUs, a body height that forces a small compact window. + let mut s = state_with_snapshot(snap_with_gpus(8)); + // body height 20 → GPU section 4 → header 1 → compact window < n. + s.last_body_area = Some(ratatui::layout::Rect::new(0, 0, 80, 20)); + let visible = gpu_visible_count(20, 8); + assert!(visible > 0 && visible < 8, "unexpected visible: {visible}"); + assert_eq!(s.gpu_scroll, 0); + // jump to the last GPU; scroll must advance so it stays visible. + s.select_last(); + assert!( + s.gpu_scroll > 0, + "gpu_scroll did not advance: {}", + s.gpu_scroll + ); + assert!(s.gpu_sel >= s.gpu_scroll && s.gpu_sel < s.gpu_scroll + visible); + } + + #[test] + fn gpu_compact_line_colors_and_truncates() { + let theme = Theme::default_dark(); + // hot + high power → err color on both temp and power spans + let hot = mk_gpu("gpu-2", 95.0, 88.0, 740.0); + let line = gpu_compact_line(&hot, &theme, 120, true); + let temp_span = line + .spans + .iter() + .find(|s| s.content.contains("°C")) + .unwrap(); + let pow_span = line.spans.iter().find(|s| s.content.contains("W")).unwrap(); + assert_eq!(temp_span.style.fg, Some(theme.err)); + assert_eq!(pow_span.style.fg, Some(theme.err)); + // wide → vram segment included + assert!(line.spans.iter().any(|s| s.content.contains("GB"))); + // narrow → vram segment dropped (truncation by omission) + let narrow = gpu_compact_line(&hot, &theme, 24, false); + assert!(!narrow.spans.iter().any(|s| s.content.contains("GB"))); + } + + #[test] + fn draw_full_panels_render_all_gpus() { + let s = state_with_snapshot(snap_with_gpus(2)); + // tall terminal → full panels (each with a "GPU N" titled border) + let out = render_to_string(&s, 100, 40); + assert!(out.contains("GPU gpu-0"), "missing full panel 0: {out:?}"); + assert!(out.contains("GPU gpu-1"), "missing full panel 1"); + // full panels carry the info line (ROCm/partition), compact rows do not + assert!(out.contains("partition"), "full panel info line missing"); + } + + #[test] + fn draw_compact_rows_when_tight() { + let s = state_with_snapshot(snap_with_gpus(6)); + // short terminal → compact one-line rows for each GPU, no panic + let out = render_to_string(&s, 100, 24); + // compact rows show the util bar glyphs and °C/W on one line + assert!(out.contains('▕'), "missing util bar: {out:?}"); + assert!(out.contains("°C")); + } + + #[test] + fn draw_overflow_indicator_when_clipped() { + let mut s = state_with_snapshot(snap_with_gpus(12)); + s.last_body_area = Some(ratatui::layout::Rect::new(0, 0, 100, 22)); + // select a GPU near the end so the window scrolls and clips above + s.select_last(); + let out = render_to_string(&s, 100, 22); + assert!(out.contains("more"), "missing overflow affordance: {out:?}"); + } + + #[test] + fn semantic_max_uses_floor_then_grows() { + // all temps below the redline → max is the semantic floor + assert_eq!(semantic_max(HEATMAP_TEMP_MAX_C, &[60.0, 78.0, 95.0]), 100.0); + // an observed value above the floor wins + assert_eq!(semantic_max(HEATMAP_TEMP_MAX_C, &[60.0, 110.0]), 110.0); + // power: below critical → floor (POWER_CRIT_W) + assert_eq!(semantic_max(POWER_CRIT_W as f64, &[400.0, 690.0]), 700.0); + // power: above critical → observed + assert_eq!(semantic_max(POWER_CRIT_W as f64, &[400.0, 760.0]), 760.0); + // empty data → floor + assert_eq!(semantic_max(POWER_CRIT_W as f64, &[]), 700.0); + } + + #[test] + fn detail_now_line_threshold_colors_power_and_temp() { + let theme = Theme::default_dark(); + let hot = mk_gpu("gpu-0", 99.0, 88.0, 740.0); + let line = detail_now_line(&hot, &theme); + let temp_span = line + .spans + .iter() + .find(|s| s.content.contains("°C")) + .unwrap(); + let pow_span = line + .spans + .iter() + .find(|s| s.content.contains(" W")) + .unwrap(); + assert_eq!( + temp_span.style.fg, + Some(theme.err), + "hot temp not err-colored" + ); + assert_eq!( + pow_span.style.fg, + Some(theme.err), + ">700W power not err-colored" + ); + // a cool, low-power GPU is ok-colored + let cool = mk_gpu("gpu-1", 10.0, 45.0, 300.0); + let line2 = detail_now_line(&cool, &theme); + let p2 = line2 + .spans + .iter() + .find(|s| s.content.contains(" W")) + .unwrap(); + assert_eq!(p2.style.fg, Some(theme.ok)); + } + + #[test] + fn draw_detail_renders_without_panic() { + let mut s = state_with_snapshot(snap_with_gpus(4)); + // seed a little history so the heatmap has data + for _ in 0..5 { + s.history.push_back(snap_with_gpus(4)); + } + s.modal = crate::app::Modal::Detail; + let backend = TestBackend::new(140, 32); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw_detail(f, f.area(), &s, &s.theme)) + .unwrap(); + let buf = term.backend().buffer().clone(); + let out: String = buf.content().iter().map(|c| c.symbol()).collect(); + assert!(out.contains("detail"), "missing detail title: {out:?}"); + assert!(out.contains("power W"), "missing power heatmap row"); + } + + fn mk_instance( + name: &str, + gpu_ids: &[&str], + gen_tps: Option, + ) -> rocm_dash_core::metrics::Instance { + rocm_dash_core::metrics::Instance { + container_name: name.into(), + model_name: name.into(), + gpu_ids: gpu_ids.iter().map(|s| s.to_string()).collect(), + gen_tps, + ..Default::default() + } + } + + fn sysinfo_partitioned() -> GpuSystemInfo { + GpuSystemInfo { + rocm_version: Some("7.13.0".into()), + driver_version: Some("6.10.5".into()), + gpu_model: "MI355X".into(), + physical_gpu_count: 4, + logical_gpu_count: 8, + vram_per_logical_gpu_mb: 24 * 1024, + ..Default::default() + } + } + + #[test] + fn serving_line_lists_models_or_none() { + let theme = Theme::default_dark(); + assert!(serving_line(&[], 80, &theme).is_none()); + let a = mk_instance("llama-70b", &["3"], Some(100.0)); + let b = mk_instance("qwen-coder", &["3"], Some(50.0)); + let line = serving_line(&[&a, &b], 80, &theme).unwrap(); + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(text.contains("serving:")); + assert!(text.contains("llama-70b")); + assert!(text.contains("qwen-coder")); + } + + #[test] + fn gpu_section_header_shows_partition_and_efficiency() { + let theme = Theme::default_dark(); + let snap = Snapshot { + gpus: vec![mk_gpu("gpu-0", 50.0, 65.0, 500.0)], + gpu_system_info: Some(sysinfo_partitioned()), + instances: vec![mk_instance("a", &["0"], Some(250.0))], + ..Default::default() + }; + let line = gpu_section_header(&snap, &theme); + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(text.contains("logical 8"), "missing logical count: {text}"); + assert!(text.contains("phys 4"), "missing physical count"); + assert!(text.contains("/logical"), "missing per-logical vram"); + assert!(text.contains("kW"), "missing total power"); + // 250 tok / 500 W = 0.50 tok/W + assert!(text.contains("0.50 tok/W"), "missing efficiency: {text}"); + } + + #[test] + fn gpu_section_header_efficiency_dash_without_traffic() { + let theme = Theme::default_dark(); + let snap = Snapshot { + gpus: vec![mk_gpu("gpu-0", 5.0, 45.0, 300.0)], + gpu_system_info: Some(sysinfo_partitioned()), + instances: vec![mk_instance("a", &["0"], None)], // no gen_tps + ..Default::default() + }; + let text: String = gpu_section_header(&snap, &theme) + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(text.contains("eff -"), "expected dash efficiency: {text}"); + } + + #[test] + fn draw_serving_line_appears_for_right_gpu_end_to_end() { + // device_id "gpu-3" ↔ instance gpu_ids ["3"] normalization, rendered. + let mut snap = snap_with_gpus(4); + snap.gpu_system_info = Some(sysinfo_partitioned()); + snap.instances = vec![mk_instance("llama-70b", &["3"], Some(120.0))]; + let s = state_with_snapshot(snap); + let out = render_to_string(&s, 110, 44); // tall → full panels w/ serving + assert!( + out.contains("serving:"), + "no serving line rendered: {out:?}" + ); + assert!(out.contains("llama-70b"), "model not shown on its GPU"); + // header surfaces partition + efficiency end-to-end + assert!(out.contains("logical 8"), "header logical missing"); + assert!(out.contains("tok/W"), "header efficiency missing"); + } + + #[test] + fn draw_handles_degraded_and_empty_states() { + // 1) No snapshot at all → "waiting…" placeholder, no panic. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Hardware; + let out = render_to_string(&s, 100, 30); + assert!(out.contains("waiting"), "no waiting placeholder: {out:?}"); + + // 2) Snapshot with no GPUs → "no GPUs reported". + let no_gpus = state_with_snapshot(Snapshot::default()); + let out = render_to_string(&no_gpus, 100, 30); + assert!(out.contains("no GPUs reported"), "missing no-GPU notice"); + + // 3) GPUs but no sysinfo and no instances and zero power → header still + // renders with "eff -" and "?"-free crash-free panels. + let mut snap = snap_with_gpus(2); + for g in &mut snap.gpus { + g.power_w = 0.0; + } + let s3 = state_with_snapshot(snap); + let out = render_to_string(&s3, 100, 30); + assert!( + out.contains("eff -"), + "expected dash efficiency w/o power/traffic" + ); + assert!( + !out.contains("serving:"), + "no serving line without instances" + ); + + // 4) Tiny terminals must not panic across a range of heights/widths. + let s4 = state_with_snapshot(snap_with_gpus(8)); + for h in [1u16, 2, 3, 5, 8, 12, 18] { + for w in [10u16, 24, 40, 80] { + let _ = render_to_string(&s4, w, h); + } + } + } + + #[test] + fn info_line_renders_with_sysinfo() { + let si = GpuSystemInfo { + rocm_version: Some("6.2.0".into()), + driver_version: Some("6.10.5".into()), + compute_partition_mode: ComputePartitionMode::Spx, + memory_partition_mode: MemoryPartitionMode::Nps1, + ..Default::default() + }; + let theme = Theme::default_dark(); + let line = gpu_info_line(Some(1850.0), Some(&si), &theme); + let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(rendered.contains("partition: SPX/NPS1")); + assert!(rendered.contains("ROCm 6.2.0")); + assert!(rendered.contains("driver 6.10.5")); + assert!(rendered.contains("clk 1.85 GHz")); + } + + #[test] + fn info_line_uses_placeholders_without_sysinfo() { + let theme = Theme::default_dark(); + let line = gpu_info_line(None, None, &theme); + let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(rendered.contains("partition: ?/?")); + assert!(rendered.contains("ROCm ?")); + assert!(rendered.contains("driver ?")); + assert!(rendered.contains("clk -")); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/instances.rs b/crates/rocm-dash-tui/src/ui/tabs/instances.rs new file mode 100644 index 00000000..d25273dc --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/tabs/instances.rs @@ -0,0 +1,1029 @@ +//! Instances tab — full-screen instance grid with kv-cache / requests / args, +//! plus a detail modal showing model / partition / launch_args / env / log. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use rocm_dash_core::metrics::{Instance, InstanceStatus}; + +use crate::app::{AppState, ConnState, KeyAction}; +use crate::ui::format; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; +use crate::ui::widgets::trunc; + +pub fn draw(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + if state.instances.is_empty() { + draw_empty(f, area, state, theme); + return; + } + + let instances = sorted_instances(&state.instances); + let sel = clamp_sel(state.instance_sel, instances.len()); + + // When we have ≥2 instances AND ≥3 snapshots in history, surface a + // kv-cache × time heatmap above the card grid. Single-instance / cold- + // start cases skip the heatmap so we don't waste rows on something + // tautological. + let show_heatmap = instances.len() >= 2 && state.history.len() >= 3; + let (heatmap_area, grid_area) = if show_heatmap { + let heatmap_rows = compute_heatmap_height(instances.len(), area.height); + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(heatmap_rows), Constraint::Min(0)]) + .split(area); + (Some(split[0]), split[1]) + } else { + (None, area) + }; + + if let Some(heat_area) = heatmap_area { + draw_kv_heatmap(f, heat_area, state, &instances, theme); + } + draw_card_grid(f, grid_area, &instances, sel, theme); +} + +/// How tall to make the heatmap block. +/// Each row of the table = one instance; +2 for borders + 1 for footer hint. +fn compute_heatmap_height(n_instances: usize, total_height: u16) -> u16 { + // Cap at total/2 so the card grid still gets meaningful space. + let max = (total_height / 2).max(5); + (n_instances as u16 + 3).min(max).max(5) +} + +fn draw_card_grid(f: &mut Frame, area: Rect, instances: &[&Instance], sel: usize, theme: &Theme) { + let cols = pick_cols(area.width); + let rows = instances.len().div_ceil(cols); + if rows == 0 { + return; + } + let row_constraints: Vec = (0..rows) + .map(|_| Constraint::Ratio(1, rows as u32)) + .collect(); + let row_slots = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(area); + + for (row_idx, row_slot) in row_slots.iter().enumerate() { + let col_constraints: Vec = (0..cols) + .map(|_| Constraint::Ratio(1, cols as u32)) + .collect(); + let col_slots = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints) + .split(*row_slot); + for (col_idx, cell) in col_slots.iter().enumerate() { + let idx = row_idx * cols + col_idx; + if let Some(inst) = instances.get(idx) { + draw_card(f, *cell, inst, theme, idx == sel); + } + } + } +} + +fn draw_kv_heatmap( + f: &mut Frame, + area: Rect, + state: &AppState, + instances: &[&Instance], + theme: &Theme, +) { + use crate::ui::heatmap::Heatmap; + + let block = Block::default() + .borders(Borders::ALL) + .title(format!( + " kv-cache % · {} instances · last {} ticks ", + instances.len(), + state.history.len(), + )) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + + let rows = build_kv_heatmap_rows(&state.history, instances); + if rows.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "no kv-cache samples yet — start a vLLM container and wait a tick", + Style::default().fg(theme.muted), + ))), + inner, + ); + return; + } + + let heat = Heatmap::new(&rows) + .stops(theme.ok, theme.warn, theme.err) + .track_bg(theme.surface_2) + .label_style(Style::default().fg(theme.muted)) + .label_width(label_width_for(instances)); + f.render_widget(heat, inner); +} + +/// Width budget for instance labels in the heatmap, capped so the data +/// region stays usable on narrow terminals. +fn label_width_for(instances: &[&Instance]) -> u16 { + instances + .iter() + .map(|i| i.container_name.chars().count() as u16) + .max() + .unwrap_or(0) + .saturating_add(1) + .clamp(8, 20) +} + +/// Build one heatmap row per instance: kv-cache % over each snapshot in +/// history. Instances missing from a given snapshot contribute 0 at that +/// column (renders as `track_bg` because of the zero-value guard). +/// Pure — exposed for tests. +pub(crate) fn build_kv_heatmap_rows( + history: &std::collections::VecDeque, + instances: &[&Instance], +) -> Vec { + use crate::ui::heatmap::HeatmapRow; + + let mut out = Vec::with_capacity(instances.len()); + for inst in instances { + let id = &inst.container_id; + let label = inst.container_name.clone(); + let data: Vec = history + .iter() + .map(|snap| { + snap.instances + .iter() + .find(|i| &i.container_id == id) + .and_then(|i| i.kv_cache_usage_pct.map(|v| v as f64)) + .unwrap_or(0.0) + }) + .collect(); + out.push(HeatmapRow::new(label, data, 100.0)); + } + out +} + +fn pick_cols(width: u16) -> usize { + if width >= 160 { + 3 + } else if width >= 100 { + 2 + } else { + 1 + } +} + +/// Sort instances deterministically by container_name so that an index from +/// AppState always maps to the same card in the grid. +fn sorted_instances(instances: &std::collections::HashMap) -> Vec<&Instance> { + let mut v: Vec<&Instance> = instances.values().collect(); + v.sort_by(|a, b| a.container_name.cmp(&b.container_name)); + v +} + +/// Clamp a selection index into `[0, len)`. Returns 0 for an empty list. +fn clamp_sel(sel: usize, len: usize) -> usize { + if len == 0 { 0 } else { sel.min(len - 1) } +} + +fn status_meta(status: InstanceStatus, theme: &Theme) -> (ratatui::style::Color, &'static str) { + match status { + InstanceStatus::Running => (theme.ok, "RUNNING"), + InstanceStatus::Starting => (theme.warn, "STARTING"), + InstanceStatus::Stopped => (theme.err, "STOPPED"), + InstanceStatus::Error => (theme.err, "ERROR"), + InstanceStatus::Unknown => (theme.muted, "UNKNOWN"), + } +} + +fn draw_empty(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let body = match &state.conn { + ConnState::Connected { .. } => { + "no instances · start daemon with --enable-docker on a host with vLLM containers" + } + _ => "waiting for daemon…", + }; + let block = Block::default() + .borders(Borders::ALL) + .title(" Instances ") + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let p = Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(theme.muted), + ))) + .block(block); + f.render_widget(p, area); +} + +fn draw_card(f: &mut Frame, area: Rect, inst: &Instance, theme: &Theme, selected: bool) { + let (status_color, status_text) = status_meta(inst.status, theme); + let name = trunc(&inst.container_name, 24); + let title = format!(" {name} · {status_text} "); + + let border_style = if selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + theme.border_style() + }; + let mut title_style = Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD); + if selected { + title_style = title_style.add_modifier(Modifier::BOLD); + } + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style) + .title_style(title_style); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 || inner.width == 0 { + return; + } + + let inner_w = inner.width as usize; + + // Too short -> compact one-liner. Highlight still flows through the border. + if inner.height < 5 { + let line = compact_line(inst, theme, inner_w); + f.render_widget(Paragraph::new(line), inner); + return; + } + + let port_str = inst + .port + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()); + let gpus_str = if inst.gpu_ids.is_empty() { + "-".to_string() + } else if inst.gpu_ids.len() == 1 { + inst.gpu_ids[0].clone() + } else { + inst.gpu_ids.join(",") + }; + + let kv = format::pct_opt(inst.kv_cache_usage_pct); + let run = format::reqs_opt(inst.running_reqs); + let wait = format::reqs_opt(inst.waiting_reqs); + + let mut lines: Vec = Vec::with_capacity(8); + + // 1. model + lines.push(Line::from(vec![ + Span::styled("model ", Style::default().fg(theme.muted)), + Span::styled( + trunc(&inst.model_name, inner_w.saturating_sub(6).max(1)), + Style::default().fg(theme.fg), + ), + ])); + + // 2. port · tp · gpus + lines.push(Line::from(vec![ + Span::styled(format!("port {port_str}"), Style::default().fg(theme.fg)), + Span::styled(" · ", Style::default().fg(theme.muted)), + Span::styled( + format!("tp {}", inst.tensor_parallel_size), + Style::default().fg(theme.fg), + ), + Span::styled(" · ", Style::default().fg(theme.muted)), + Span::styled( + format!("gpus {}", trunc(&gpus_str, 20)), + Style::default().fg(theme.accent), + ), + ])); + + // 3. kv_cache / run / wait + lines.push(Line::from(vec![ + Span::styled("kv_cache ", Style::default().fg(theme.muted)), + Span::styled(kv, Style::default().fg(theme.fg)), + Span::styled(" · run ", Style::default().fg(theme.muted)), + Span::styled(run, Style::default().fg(theme.fg)), + Span::styled(" · wait ", Style::default().fg(theme.muted)), + Span::styled(wait, Style::default().fg(theme.fg)), + ])); + + // 4. efficiency: tok/W · gen throughput + lines.push(Line::from(vec![ + Span::styled("tok/W ", Style::default().fg(theme.muted)), + Span::styled( + format::tokens_per_watt(inst.tokens_per_watt), + Style::default().fg(theme.accent), + ), + Span::styled(" · gen ", Style::default().fg(theme.muted)), + Span::styled(format::tps_opt(inst.gen_tps), Style::default().fg(theme.fg)), + ])); + + // 5. vram (only if total > 0) + if inst.vram_total_mb > 0 { + lines.push(Line::from(vec![ + Span::styled("vram ", Style::default().fg(theme.muted)), + Span::styled( + format::mib_pair(inst.vram_used_mb, inst.vram_total_mb), + Style::default().fg(theme.fg), + ), + ])); + } + + // 5. args + let args_joined = inst + .launch_args + .iter() + .take(4) + .cloned() + .collect::>() + .join(" "); + let args_display = if args_joined.is_empty() { + "(none)".to_string() + } else { + trunc(&args_joined, inner_w.saturating_sub(6).max(1)) + }; + lines.push(Line::from(vec![ + Span::styled("args: ", Style::default().fg(theme.muted)), + Span::styled(args_display, Style::default().fg(theme.muted)), + ])); + + // 6. env count + lines.push(Line::from(vec![ + Span::styled("env: ", Style::default().fg(theme.muted)), + Span::styled( + format!("{} vars", inst.env_vars.len()), + Style::default().fg(theme.muted), + ), + ])); + + // 7. log file (optional) + if let Some(log) = inst.log_file.as_deref() { + lines.push(Line::from(vec![ + Span::styled("log: ", Style::default().fg(theme.muted)), + Span::styled( + trunc(log, inner_w.saturating_sub(5).max(1)), + Style::default().fg(theme.muted), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), inner); +} + +fn compact_line<'a>(inst: &'a Instance, theme: &Theme, max_w: usize) -> Line<'a> { + let (status_color, _) = status_meta(inst.status, theme); + let port = inst + .port + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()); + let gpus = if inst.gpu_ids.is_empty() { + "-".to_string() + } else { + inst.gpu_ids.join(",") + }; + let raw = format!( + "{} · {} · :{} · tp{} · gpus {}", + inst.container_name, inst.model_name, port, inst.tensor_parallel_size, gpus + ); + Line::from(Span::styled( + trunc(&raw, max_w), + Style::default().fg(status_color), + )) +} + +/// Detail modal: summary + launch_args + env_vars + log footer. +/// Resolve a click at `(x, y)` inside the Instances tab body. Returns a +/// `KeyAction` to dispatch, or `None` when the click misses everything +/// actionable. +/// +/// Re-runs the same Layout split as `draw` so card rects line up exactly with +/// what the user sees. Clicking the already-selected card opens the detail +/// modal (acts as a double-click affordance); clicking any other card moves +/// the selection cursor by the delta to that card. +pub fn hit_test(area: Rect, x: u16, y: u16, state: &AppState) -> Option { + if state.instances.is_empty() { + return None; + } + if x < area.x || x >= area.x + area.width || y < area.y || y >= area.y + area.height { + return None; + } + + let instances = sorted_instances(&state.instances); + let cols = pick_cols(area.width); + let rows = instances.len().div_ceil(cols); + if rows == 0 || cols == 0 { + return None; + } + + let sel = clamp_sel(state.instance_sel, instances.len()); + + let row_constraints: Vec = (0..rows) + .map(|_| Constraint::Ratio(1, rows as u32)) + .collect(); + let row_slots = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(area); + + for (row_idx, row_slot) in row_slots.iter().enumerate() { + let col_constraints: Vec = (0..cols) + .map(|_| Constraint::Ratio(1, cols as u32)) + .collect(); + let col_slots = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints) + .split(*row_slot); + for (col_idx, cell) in col_slots.iter().enumerate() { + let idx = row_idx * cols + col_idx; + if idx >= instances.len() { + continue; + } + if point_in_rect(*cell, x, y) { + if idx == sel { + return Some(KeyAction::OpenDetail); + } + let delta = idx as isize - sel as isize; + return Some(KeyAction::Move(delta)); + } + } + } + None +} + +/// Pure point-in-rect check using half-open coordinates (right/bottom edges +/// are exclusive), matching ratatui's own rect semantics. +fn point_in_rect(r: Rect, x: u16, y: u16) -> bool { + x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height +} + +pub fn draw_detail(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let popup = centered_rect(85, 85, 120, 36, area); + + if state.instances.is_empty() { + let inner = draw_popup_frame(f, popup, " Instance · (no selection) ", theme); + let p = Paragraph::new(Line::from(Span::styled( + "no instances to show", + Style::default().fg(theme.muted), + ))); + f.render_widget(p, inner); + return; + } + + let instances = sorted_instances(&state.instances); + let sel = clamp_sel(state.instance_sel, instances.len()); + let Some(inst) = instances.get(sel) else { + let inner = draw_popup_frame(f, popup, " Instance · (no selection) ", theme); + let p = Paragraph::new(Line::from(Span::styled( + "no selection", + Style::default().fg(theme.muted), + ))); + f.render_widget(p, inner); + return; + }; + + let title = format!(" Instance · {} ", inst.container_name); + let inner = draw_popup_frame(f, popup, &title, theme); + if inner.height == 0 || inner.width == 0 { + return; + } + + // Vertical: summary (3) | body (min) | footer (1) + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(inner); + + render_summary(f, chunks[0], inst, theme); + render_body(f, chunks[1], inst, theme); + render_footer(f, chunks[2], inst, theme); +} + +fn render_summary(f: &mut Frame, area: Rect, inst: &Instance, theme: &Theme) { + let (status_color, status_text) = status_meta(inst.status, theme); + let muted = Style::default().fg(theme.muted); + let fg = Style::default().fg(theme.fg); + + let id_w = (area.width as usize).saturating_sub(16).max(8); + let container_id = trunc(&inst.container_id, id_w); + let partition = inst.partition_info.as_deref().unwrap_or("-"); + let quant = inst.quantization.as_deref().unwrap_or("-"); + let port = inst + .port + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()); + let gpus = if inst.gpu_ids.is_empty() { + "-".to_string() + } else { + inst.gpu_ids.join(",") + }; + + let lines = vec![ + Line::from(vec![ + Span::styled( + format!(" {status_text} "), + Style::default() + .fg(theme.bg) + .bg(status_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("id: ", muted), + Span::styled(container_id, fg), + Span::raw(" "), + Span::styled("port: ", muted), + Span::styled(port, fg), + Span::raw(" "), + Span::styled("tp: ", muted), + Span::styled(inst.tensor_parallel_size.to_string(), fg), + ]), + Line::from(vec![ + Span::styled("model: ", muted), + Span::styled(inst.model_name.clone(), fg), + Span::raw(" "), + Span::styled("gpus: ", muted), + Span::styled(gpus, Style::default().fg(theme.accent)), + Span::raw(" "), + Span::styled("tok/W: ", muted), + Span::styled( + format::tokens_per_watt(inst.tokens_per_watt), + Style::default().fg(theme.accent), + ), + Span::raw(" "), + Span::styled("gen: ", muted), + Span::styled(format::tps_opt(inst.gen_tps), fg), + ]), + Line::from(vec![ + Span::styled("partition: ", muted), + Span::styled(partition.to_string(), fg), + Span::raw(" "), + Span::styled("quantization: ", muted), + Span::styled(quant.to_string(), fg), + Span::raw(" "), + Span::styled("vram: ", muted), + Span::styled(format::mib_pair(inst.vram_used_mb, inst.vram_total_mb), fg), + ]), + ]; + + let p = Paragraph::new(lines).wrap(Wrap { trim: false }); + f.render_widget(p, area); +} + +fn render_body(f: &mut Frame, area: Rect, inst: &Instance, theme: &Theme) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(area); + + // launch_args (left) + let args_block = Block::default() + .borders(Borders::ALL) + .title(" launch_args ") + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let args_inner = args_block.inner(chunks[0]); + f.render_widget(args_block, chunks[0]); + + let args_lines: Vec = if inst.launch_args.is_empty() { + vec![Line::from(Span::styled( + "(none)", + Style::default().fg(theme.muted), + ))] + } else { + inst.launch_args + .iter() + .map(|a| Line::from(Span::styled(a.clone(), Style::default().fg(theme.fg)))) + .collect() + }; + f.render_widget( + Paragraph::new(args_lines).wrap(Wrap { trim: false }), + args_inner, + ); + + // env_vars (right). BTreeMap iterates sorted by key. + let env_block = Block::default() + .borders(Borders::ALL) + .title(" env_vars ") + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let env_inner = env_block.inner(chunks[1]); + f.render_widget(env_block, chunks[1]); + + let env_lines: Vec = if inst.env_vars.is_empty() { + vec![Line::from(Span::styled( + "(none)", + Style::default().fg(theme.muted), + ))] + } else { + inst.env_vars + .iter() + .map(|(k, v)| { + Line::from(vec![ + Span::styled(k.clone(), Style::default().fg(theme.accent)), + Span::styled("=", Style::default().fg(theme.muted)), + Span::styled(v.clone(), Style::default().fg(theme.fg)), + ]) + }) + .collect() + }; + f.render_widget( + Paragraph::new(env_lines).wrap(Wrap { trim: false }), + env_inner, + ); +} + +fn render_footer(f: &mut Frame, area: Rect, inst: &Instance, theme: &Theme) { + let log = inst.log_file.as_deref().unwrap_or("-"); + let p = Paragraph::new(Line::from(vec![ + Span::styled("log: ", Style::default().fg(theme.muted)), + Span::styled(log.to_string(), Style::default().fg(theme.muted)), + ])); + f.render_widget(p, area); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, HashMap}; + + fn mk_inst(name: &str) -> Instance { + Instance { + container_id: format!("id-{name}"), + container_name: name.to_string(), + status: InstanceStatus::Running, + model_name: "m".into(), + gpu_ids: vec!["0".into()], + partition_info: None, + quantization: None, + tensor_parallel_size: 1, + port: Some(8000), + vram_used_mb: 0, + vram_total_mb: 0, + kv_cache_usage_pct: None, + running_reqs: None, + waiting_reqs: None, + gen_tps: None, + tokens_per_watt: None, + launch_args: vec![], + env_vars: BTreeMap::new(), + log_file: None, + } + } + + fn map_with(names: &[&str]) -> HashMap { + let mut m = HashMap::new(); + for n in names { + let inst = mk_inst(n); + m.insert(inst.container_id.clone(), inst); + } + m + } + + #[test] + fn pick_cols_scales_with_width() { + assert_eq!(pick_cols(60), 1); + assert_eq!(pick_cols(99), 1); + assert_eq!(pick_cols(100), 2); + assert_eq!(pick_cols(159), 2); + assert_eq!(pick_cols(160), 3); + assert_eq!(pick_cols(300), 3); + } + + #[test] + fn status_meta_maps_each_variant() { + let theme = Theme::default_dark(); + assert_eq!(status_meta(InstanceStatus::Running, &theme).1, "RUNNING"); + assert_eq!(status_meta(InstanceStatus::Starting, &theme).1, "STARTING"); + assert_eq!(status_meta(InstanceStatus::Stopped, &theme).1, "STOPPED"); + assert_eq!(status_meta(InstanceStatus::Error, &theme).1, "ERROR"); + assert_eq!(status_meta(InstanceStatus::Unknown, &theme).1, "UNKNOWN"); + } + + #[test] + fn clamp_sel_clamps_into_bounds() { + assert_eq!(clamp_sel(0, 0), 0); + assert_eq!(clamp_sel(7, 0), 0); + assert_eq!(clamp_sel(0, 1), 0); + assert_eq!(clamp_sel(0, 3), 0); + assert_eq!(clamp_sel(2, 3), 2); + assert_eq!(clamp_sel(99, 3), 2); + } + + #[test] + fn sorted_instances_orders_by_container_name() { + let m = map_with(&["charlie", "alpha", "bravo"]); + let v = sorted_instances(&m); + assert_eq!( + v.iter() + .map(|i| i.container_name.as_str()) + .collect::>(), + vec!["alpha", "bravo", "charlie"], + ); + } + + #[test] + fn selected_instance_lookup_by_sorted_index() { + // HashMap insertion order is irrelevant — sorted_instances orders by name. + let m = map_with(&["zeta", "alpha", "mu"]); + let v = sorted_instances(&m); + let sel = clamp_sel(1, v.len()); + assert_eq!(v[sel].container_name, "mu"); + + // Out-of-range cursor clamps to the last item. + let sel = clamp_sel(99, v.len()); + assert_eq!(v[sel].container_name, "zeta"); + } + + #[test] + fn point_in_rect_uses_half_open_semantics() { + let r = Rect::new(10, 5, 4, 3); + assert!(point_in_rect(r, 10, 5)); + assert!(point_in_rect(r, 13, 7)); + // Right and bottom edges are exclusive. + assert!(!point_in_rect(r, 14, 5)); + assert!(!point_in_rect(r, 10, 8)); + // Outside. + assert!(!point_in_rect(r, 9, 5)); + assert!(!point_in_rect(r, 10, 4)); + } + + fn mk_state(instances: HashMap, sel: usize) -> AppState { + AppState { + connect: "test".into(), + conn: ConnState::Initial, + latest: None, + history: std::collections::VecDeque::new(), + bench_rows: std::collections::VecDeque::new(), + instances, + active_tab: crate::app::ActiveTab::Instances, + modal: crate::app::Modal::None, + instance_sel: sel, + bench_sel: 0, + gpu_sel: 0, + gpu_scroll: 0, + theme_name: "default-dark".into(), + theme: Theme::default_dark(), + theme_picker_sel: 0, + bench_detail_scroll: 0, + chat: Vec::new(), + chat_input: String::new(), + chat_sending: false, + chat_dispatch: false, + chat_focused: false, + chat_scroll: 0, + chat_llm: None, + chat_consent: crate::app::ChatConsent::Unavailable, + chat_detect_offer: None, + chat_detecting: false, + chat_detect_dispatch: false, + chat_detect_msg: None, + chat_persist_dispatch: false, + replay: None, + last_body_area: None, + last_tab_bar_area: None, + jobs: rocm_dash_core::state::State::default(), + services: None, + serve_wizard: None, + engine_manager: None, + doctor_manager: None, + update_manager: None, + install_manager: None, + logs_view: None, + runtime_manager: None, + onboarding: None, + automations_manager: None, + command_screen: None, + config_manager: None, + model_recipes: Vec::new(), + runtimes: Vec::new(), + automations: Vec::new(), + } + } + + fn mk_state_with(names: &[&str], sel: usize) -> AppState { + mk_state(map_with(names), sel) + } + + #[test] + fn hit_test_returns_none_when_empty() { + let s = mk_state(HashMap::new(), 0); + let area = Rect::new(0, 0, 80, 20); + assert_eq!(hit_test(area, 5, 5, &s), None); + } + + #[test] + fn hit_test_returns_none_when_outside_area() { + let s = mk_state_with(&["a", "b"], 0); + let area = Rect::new(10, 5, 80, 20); + // Above area. + assert_eq!(hit_test(area, 50, 4, &s), None); + // Left of area. + assert_eq!(hit_test(area, 9, 10, &s), None); + // Right edge exclusive. + assert_eq!(hit_test(area, 90, 10, &s), None); + // Bottom edge exclusive. + assert_eq!(hit_test(area, 50, 25, &s), None); + } + + #[test] + fn hit_test_on_selected_card_opens_detail() { + // Narrow width forces 1 col; two cards stack vertically. + // sel = 0 → click the first (top) card. + let s = mk_state_with(&["alpha", "bravo"], 0); + let area = Rect::new(0, 0, 60, 20); // width 60 → cols=1, rows=2 + let action = hit_test(area, 10, 2, &s); + assert_eq!(action, Some(KeyAction::OpenDetail)); + } + + #[test] + fn hit_test_on_other_card_returns_move_with_delta() { + // Two cards stacked vertically; sel=0 → clicking bottom card moves +1. + let s = mk_state_with(&["alpha", "bravo"], 0); + let area = Rect::new(0, 0, 60, 20); // cols=1, rows=2 → each row ~10 tall + let action = hit_test(area, 10, 15, &s); + assert_eq!(action, Some(KeyAction::Move(1))); + } + + #[test] + fn hit_test_returns_negative_delta_when_clicking_earlier_card() { + // sel=1 (bravo) → clicking alpha at top yields delta -1. + let s = mk_state_with(&["alpha", "bravo"], 1); + let area = Rect::new(0, 0, 60, 20); + let action = hit_test(area, 10, 2, &s); + assert_eq!(action, Some(KeyAction::Move(-1))); + } + + #[test] + fn hit_test_grid_layout_2_cols_picks_correct_card() { + // width 120 → cols=2; 4 instances → rows=2. + let s = mk_state_with(&["a", "b", "c", "d"], 0); + let area = Rect::new(0, 0, 120, 20); + // Top-right card is index 1 → delta +1 from sel=0. + let action = hit_test(area, 90, 2, &s); + assert_eq!(action, Some(KeyAction::Move(1))); + // Bottom-left card is index 2 → delta +2. + let action = hit_test(area, 10, 15, &s); + assert_eq!(action, Some(KeyAction::Move(2))); + // Bottom-right card is index 3 → delta +3. + let action = hit_test(area, 90, 15, &s); + assert_eq!(action, Some(KeyAction::Move(3))); + } + + fn mk_inst_kv(id: &str, name: &str, kv: Option) -> Instance { + Instance { + container_id: id.into(), + container_name: name.into(), + kv_cache_usage_pct: kv, + ..Default::default() + } + } + + fn mk_snap(insts: Vec) -> rocm_dash_core::metrics::Snapshot { + rocm_dash_core::metrics::Snapshot { + instances: insts, + ..Default::default() + } + } + + #[test] + fn heatmap_rows_track_kv_cache_per_snapshot() { + let i_a = mk_inst_kv("a", "alpha", Some(10.0)); + let i_b = mk_inst_kv("b", "beta", Some(20.0)); + let history: std::collections::VecDeque<_> = vec![ + mk_snap(vec![ + mk_inst_kv("a", "alpha", Some(5.0)), + mk_inst_kv("b", "beta", Some(15.0)), + ]), + mk_snap(vec![ + mk_inst_kv("a", "alpha", Some(50.0)), + mk_inst_kv("b", "beta", Some(80.0)), + ]), + ] + .into_iter() + .collect(); + let live = vec![&i_a, &i_b]; + let rows = build_kv_heatmap_rows(&history, &live); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].label, "alpha"); + assert_eq!(rows[0].data, vec![5.0, 50.0]); + assert_eq!(rows[1].label, "beta"); + assert_eq!(rows[1].data, vec![15.0, 80.0]); + // Max is fixed at 100 — kv-cache is already a percentage. + assert_eq!(rows[0].max, 100.0); + } + + #[test] + fn heatmap_row_missing_instance_in_snap_yields_zero() { + // Instance b only existed in the second snapshot. + let i_a = mk_inst_kv("a", "alpha", Some(0.0)); + let i_b = mk_inst_kv("b", "beta", Some(0.0)); + let history: std::collections::VecDeque<_> = vec![ + mk_snap(vec![mk_inst_kv("a", "alpha", Some(10.0))]), + mk_snap(vec![ + mk_inst_kv("a", "alpha", Some(20.0)), + mk_inst_kv("b", "beta", Some(30.0)), + ]), + ] + .into_iter() + .collect(); + let live = vec![&i_a, &i_b]; + let rows = build_kv_heatmap_rows(&history, &live); + assert_eq!(rows[0].data, vec![10.0, 20.0]); + // b is missing from snap[0] → 0.0 padding. + assert_eq!(rows[1].data, vec![0.0, 30.0]); + } + + #[test] + fn heatmap_row_none_kv_cache_yields_zero() { + let i_a = mk_inst_kv("a", "alpha", None); + let history: std::collections::VecDeque<_> = + vec![mk_snap(vec![mk_inst_kv("a", "alpha", None)])] + .into_iter() + .collect(); + let live = vec![&i_a]; + let rows = build_kv_heatmap_rows(&history, &live); + assert_eq!(rows[0].data, vec![0.0]); + } + + #[test] + fn label_width_caps_to_useful_range() { + let short_id = mk_inst_kv("a", "x", None); + let long_id = mk_inst_kv("b", "this-is-a-very-long-container-name", None); + assert_eq!(label_width_for(&[&short_id]), 8); + assert_eq!(label_width_for(&[&long_id]), 20); + } + + #[test] + fn heatmap_height_caps_to_half_panel() { + assert_eq!(compute_heatmap_height(4, 30), 7); // 4+3 + assert_eq!(compute_heatmap_height(20, 16), 8); // capped at half + assert_eq!(compute_heatmap_height(1, 30), 5); // floor at 5 + } + + /// Flatten a rendered TestBackend buffer into one newline-joined string so + /// substring assertions can confirm what reached the screen. + fn buffer_text(term: &ratatui::Terminal) -> String { + let buf = term.backend().buffer(); + let area = buf.area; + let mut out = String::new(); + for y in 0..area.height { + for x in 0..area.width { + out.push_str(buf.cell((x, y)).unwrap().symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn render_shows_quantization_and_vram_for_populated_instance() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + // Populate the previously-dead fields: quantization + per-instance VRAM. + let mut inst = mk_inst("vllm"); + inst.quantization = Some("fp8".into()); + inst.vram_used_mb = 49152; + inst.vram_total_mb = 196608; // mib_pair → "48.0 / 192.0 GiB" + let vram = format::mib_pair(inst.vram_used_mb, inst.vram_total_mb); + assert_eq!(vram, "48.0 / 192.0 GiB"); // sanity on the expected string + + let mut m = HashMap::new(); + m.insert(inst.container_id.clone(), inst); + let state = mk_state(m, 0); + + // Card grid: the VRAM line fires only because vram_total_mb > 0. + let mut term = Terminal::new(TestBackend::new(160, 48)).unwrap(); + term.draw(|f| draw(f, f.area(), &state, &state.theme)) + .unwrap(); + let grid = buffer_text(&term); + assert!( + grid.contains(&vram), + "card grid must render the used / total MiB VRAM string; got:\n{grid}" + ); + + // Detail modal: shows the quantization value and the VRAM pair. + let mut term = Terminal::new(TestBackend::new(160, 48)).unwrap(); + term.draw(|f| draw_detail(f, f.area(), &state, &state.theme)) + .unwrap(); + let detail = buffer_text(&term); + assert!( + detail.contains("fp8"), + "detail modal must render the quantization value; got:\n{detail}" + ); + assert!( + detail.contains(&vram), + "detail modal must render the used / total MiB VRAM string; got:\n{detail}" + ); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/mod.rs b/crates/rocm-dash-tui/src/ui/tabs/mod.rs new file mode 100644 index 00000000..54452693 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/tabs/mod.rs @@ -0,0 +1,138 @@ +//! Tabbed view modes. Each tab module exposes a single +//! `pub fn draw(f, area, state, theme)` so they can be implemented in parallel +//! without touching shared files. + +pub mod bench; +pub mod chat; +pub mod hardware; +pub mod instances; +pub mod overview; + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use crate::app::ActiveTab; +use crate::ui::theme::Theme; + +pub const TAB_LABELS: [(ActiveTab, &str, char); 5] = [ + (ActiveTab::Overview, "Overview", '1'), + (ActiveTab::Hardware, "Hardware", '2'), + (ActiveTab::Instances, "Instances", '3'), + (ActiveTab::Bench, "Bench", '4'), + (ActiveTab::Chat, "Chat", '5'), +]; + +/// A single tab chip's screen extent. `x_start..x_end` are absolute columns +/// (end-exclusive) on the tab-bar row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TabChip { + pub tab: ActiveTab, + pub x_start: u16, + pub x_end: u16, +} + +/// Compute the per-chip bounding boxes for a tab bar starting at `bar_x`. +/// +/// Mirrors `draw_tab_bar` exactly: a 3-char digit chip (" 1 "), then a +/// `" Label "` chip (`label.len() + 2`), separated between chips by `" · "` +/// (3 chars). Pure — both `draw_tab_bar` and `hit_test` route through this +/// so they cannot drift. +pub fn compute_chip_layout(bar_x: u16) -> [TabChip; 5] { + let mut out = [TabChip { + tab: ActiveTab::Overview, + x_start: 0, + x_end: 0, + }; 5]; + let mut x = bar_x; + for (i, (tab, label, _key)) in TAB_LABELS.iter().enumerate() { + if i > 0 { + x = x.saturating_add(3); // " · " + } + let chip_w = 3u16 + label.len() as u16 + 2; // " 1 " + " Label " + out[i] = TabChip { + tab: *tab, + x_start: x, + x_end: x.saturating_add(chip_w), + }; + x = x.saturating_add(chip_w); + } + out +} + +/// Render the segmented tab bar. One row tall. +pub fn draw_tab_bar(f: &mut Frame, area: Rect, active: ActiveTab, theme: &Theme) { + let mut spans: Vec = Vec::with_capacity(TAB_LABELS.len() * 3); + for (i, (tab, label, key)) in TAB_LABELS.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::default().fg(theme.muted))); + } + let style = if *tab == active { + Style::default() + .bg(theme.accent) + .fg(theme.surface_2) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + spans.push(Span::styled( + format!(" {key} "), + Style::default().fg(theme.muted), + )); + spans.push(Span::styled(format!(" {label} "), style)); + } + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Common stub renderer used by tabs that are not yet implemented. +pub fn draw_placeholder(f: &mut Frame, area: Rect, title: &str, body: &str, theme: &Theme) { + use ratatui::widgets::{Block, Borders}; + let p = Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(theme.muted), + ))) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(theme.border_style()) + .title_style(theme.title_style()), + ); + f.render_widget(p, area); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chip_layout_matches_draw_widths() { + let chips = compute_chip_layout(0); + // " 1 " (3) + " Overview " (10) = 13 + assert_eq!(chips[0].x_start, 0); + assert_eq!(chips[0].x_end, 13); + // separator (3) then " 2 " (3) + " Hardware " (10) = 16..29 + assert_eq!(chips[1].x_start, 16); + assert_eq!(chips[1].x_end, 29); + // " 3 " + " Instances " (11) = 32..46 + assert_eq!(chips[2].x_start, 32); + assert_eq!(chips[2].x_end, 46); + // " 4 " + " Bench " (7) = 49..59 + assert_eq!(chips[3].x_start, 49); + assert_eq!(chips[3].x_end, 59); + // separator (3) then " 5 " (3) + " Chat " (6) = 62..71 + assert_eq!(chips[4].x_start, 62); + assert_eq!(chips[4].x_end, 71); + assert_eq!(chips[4].tab, ActiveTab::Chat); + } + + #[test] + fn chip_layout_honors_bar_x_offset() { + let chips = compute_chip_layout(100); + assert_eq!(chips[0].x_start, 100); + // Full bar (Overview start → Chat end) spans 71 columns. + assert_eq!(chips[4].x_end - chips[0].x_start, 71); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/overview.rs b/crates/rocm-dash-tui/src/ui/tabs/overview.rs new file mode 100644 index 00000000..8b75cfe9 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/tabs/overview.rs @@ -0,0 +1,427 @@ +//! Overview tab — the original 60/40 split layout. Preserved verbatim so +//! switching tabs always lands back on the familiar view. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{AppState, ConnState}; +use crate::ui::core_bars::CoreBars; +use crate::ui::format; +use crate::ui::gradient::GradientGauge; +use crate::ui::sparkline::BrailleSparkline; +use crate::ui::theme::Theme; +use crate::ui::widgets::{gpu_stats_line, trunc}; + +pub fn draw(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(12), + Constraint::Length(4), + Constraint::Min(0), + ]) + .split(cols[0]); + + draw_cpu(f, left[0], state, theme); + draw_memory(f, left[1], state, theme); + draw_host(f, left[2], state, theme); + + let n_gpus = state + .latest + .as_ref() + .map(|s| s.gpus.len().max(1)) + .unwrap_or(1); + let gpu_height = (n_gpus as u16 * 4 + 2).clamp(8, 20); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(gpu_height), + Constraint::Min(6), + Constraint::Length(10), + ]) + .split(cols[1]); + + draw_gpu(f, right[0], state, theme); + draw_instances(f, right[1], state, theme); + draw_bench(f, right[2], state, theme); +} + +fn draw_cpu(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let n_cores = state + .latest + .as_ref() + .map(|s| s.host.cpu_per_core_pct.len()) + .unwrap_or(0); + let title = match state.latest.as_ref() { + Some(s) => format!( + " CPU · {} · {} cores ", + format::pct(s.host.cpu_overall_pct), + n_cores + ), + None => " CPU ".to_string(), + }; + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + + let split = if n_cores == 0 || inner.height < 4 { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1)]) + .split(inner) + } else { + let agg = inner.height.saturating_sub(2).max(2); + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(agg), Constraint::Min(1)]) + .split(inner) + }; + + let data: Vec = state + .history + .iter() + .map(|s| s.host.cpu_overall_pct.clamp(0.0, 100.0) as u64) + .collect(); + let spark = BrailleSparkline::new(&data) + .max(100) + .style(Style::default().fg(theme.accent)) + .gradient(theme.ok, theme.warn, theme.err); + f.render_widget(spark, split[0]); + + if split.len() == 2 + && let Some(s) = state.latest.as_ref() + { + let bars = CoreBars::new(&s.host.cpu_per_core_pct) + .max(100.0) + .style(Style::default().fg(theme.ok)) + .gradient(theme.ok, theme.warn, theme.err); + f.render_widget(bars, split[1]); + } +} + +fn draw_memory(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let (used, total, ratio) = match state.latest.as_ref() { + Some(s) => { + let used = s.host.memory_used_mb; + let total = s.host.memory_total_mb.max(1); + (used, total, (used as f64 / total as f64).clamp(0.0, 1.0)) + } + None => (0, 1, 0.0), + }; + let title = format!(" Memory · {} ", format::mib_pair(used, total)); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + + let label = format::pct((ratio * 100.0) as f32); + let gauge = GradientGauge::new(ratio) + .stops(theme.ok, theme.warn, theme.err) + .track_bg(theme.surface_2) + .label(&label) + .label_fg(theme.fg); + f.render_widget(gauge, inner); +} + +fn draw_host(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let lines: Vec = match state.latest.as_ref() { + Some(s) => vec![ + Line::from(Span::styled( + format!("swap_used_mb {}", format::mib(s.host.swap_used_mb)), + Style::default().fg(theme.fg), + )), + Line::from(Span::styled( + format!("cpu_per_core_pct [{}]", s.host.cpu_per_core_pct.len()), + Style::default().fg(theme.muted), + )), + Line::from(Span::styled( + format!("snapshots_in_buf {}", state.history.len()), + Style::default().fg(theme.muted), + )), + Line::from(Span::styled( + format!("ts_latest {}", s.timestamp.format("%H:%M:%S UTC")), + Style::default().fg(theme.muted), + )), + ], + None => vec![Line::from(Span::styled( + "waiting for first snapshot…", + Style::default().fg(theme.muted), + ))], + }; + let p = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(" Host ") + .border_style(theme.border_style()) + .title_style(theme.title_style()), + ); + f.render_widget(p, area); +} + +fn draw_gpu(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let title = match state + .latest + .as_ref() + .and_then(|s| s.gpu_system_info.as_ref()) + { + Some(si) => format!( + " GPU · {} × {} · ROCm {} ", + si.physical_gpu_count, + si.gpu_model, + si.rocm_version.as_deref().unwrap_or("?") + ), + None => " GPU ".to_string(), + }; + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height == 0 { + return; + } + + let snap = match state.latest.as_ref() { + Some(s) => s, + None => { + let p = Paragraph::new(Line::from(Span::styled( + "waiting for first snapshot…", + Style::default().fg(theme.muted), + ))); + f.render_widget(p, inner); + return; + } + }; + + if snap.gpus.is_empty() { + let lines: Vec = if !snap.warnings.is_empty() { + snap.warnings + .iter() + .map(|w| Line::from(Span::styled(w.clone(), Style::default().fg(theme.warn)))) + .collect() + } else { + vec![Line::from(Span::styled( + "no GPUs reported", + Style::default().fg(theme.muted), + ))] + }; + f.render_widget(Paragraph::new(lines), inner); + return; + } + + let n = snap.gpus.len() as u16; + let per_gpu = (inner.height / n).max(1); + let constraints: Vec = (0..n).map(|_| Constraint::Length(per_gpu)).collect(); + let slots = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + for (i, g) in snap.gpus.iter().enumerate() { + let slot = slots[i]; + if slot.height == 0 { + continue; + } + let stats_line = gpu_stats_line(g, theme); + + if slot.height < 2 { + f.render_widget(Paragraph::new(stats_line), slot); + continue; + } + + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(slot); + + f.render_widget(Paragraph::new(stats_line), split[0]); + + let history: Vec = state + .history + .iter() + .filter_map(|s| { + s.gpus + .get(i) + .map(|g| g.gpu_utilization_pct.clamp(0.0, 100.0) as u64) + }) + .collect(); + let spark = BrailleSparkline::new(&history) + .max(100) + .style(Style::default().fg(theme.accent)) + .gradient(theme.ok, theme.warn, theme.err); + f.render_widget(spark, split[1]); + } +} + +fn draw_instances(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let count = state.instances.len(); + let title = format!(" Instances · {count} "); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + + if state.instances.is_empty() { + let body = match &state.conn { + ConnState::Connected { .. } => { + "no instances · start daemon with --enable-docker on a host with vLLM containers" + } + _ => "waiting for daemon…", + }; + let p = Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(theme.muted), + ))); + f.render_widget(p, inner); + return; + } + + let header = Line::from(vec![Span::styled( + format!( + "{:<18} {:<20} {:>5} {:>3} {:<10}", + "name", "model", "port", "tp", "gpus" + ), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::BOLD), + )]); + + let mut instances: Vec<&rocm_dash_core::metrics::Instance> = state.instances.values().collect(); + instances.sort_by(|a, b| a.container_name.cmp(&b.container_name)); + + let rows_shown = (inner.height as usize).saturating_sub(1); + let mut lines: Vec = Vec::with_capacity(rows_shown + 1); + lines.push(header); + for inst in instances.into_iter().take(rows_shown) { + let status_color = match inst.status { + rocm_dash_core::metrics::InstanceStatus::Running => theme.ok, + rocm_dash_core::metrics::InstanceStatus::Starting => theme.warn, + rocm_dash_core::metrics::InstanceStatus::Stopped + | rocm_dash_core::metrics::InstanceStatus::Error => theme.err, + rocm_dash_core::metrics::InstanceStatus::Unknown => theme.muted, + }; + let name = trunc(&inst.container_name, 18); + let model = trunc(&inst.model_name, 20); + let port = inst + .port + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()); + let gpus = if inst.gpu_ids.is_empty() { + "-".to_string() + } else { + inst.gpu_ids.join(",") + }; + let gpus = trunc(&gpus, 10); + lines.push(Line::from(vec![ + Span::styled(format!("{name:<18} "), Style::default().fg(status_color)), + Span::styled(format!("{model:<20} "), Style::default().fg(theme.fg)), + Span::styled( + format!("{port:>5} {:>3} ", inst.tensor_parallel_size), + Style::default().fg(theme.muted), + ), + Span::styled(format!("{gpus:<10}"), Style::default().fg(theme.accent)), + ])); + } + f.render_widget(Paragraph::new(lines), inner); +} + +fn draw_bench(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let count = state.bench_rows.len(); + let title = format!(" Bench rows · {count} "); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(theme.border_style()) + .title_style(theme.title_style()); + let inner = block.inner(area); + f.render_widget(block, area); + + if state.bench_rows.is_empty() { + let p = Paragraph::new(Line::from(Span::styled( + "no rows · start daemon with --bench-csv ", + Style::default().fg(theme.muted), + ))); + f.render_widget(p, inner); + return; + } + + let header = Line::from(vec![Span::styled( + format!( + "{:<10} {:>3} {:<20} {:>12} {:>12} {:<8}", + "cell", "run", "model", "pTPS", "gTPS", "verdict" + ), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::BOLD), + )]); + + let rows_shown = (inner.height as usize).saturating_sub(1); + let start = state.bench_rows.len().saturating_sub(rows_shown); + let mut lines: Vec = Vec::with_capacity(rows_shown + 1); + lines.push(header); + for r in state.bench_rows.iter().skip(start) { + let verdict_color = match r.pass_fail { + rocm_dash_core::bench_schema::PassFail::Pass => theme.ok, + rocm_dash_core::bench_schema::PassFail::Fail => theme.err, + rocm_dash_core::bench_schema::PassFail::Unknown => match r.judge_pass_fail { + rocm_dash_core::bench_schema::PassFail::Pass => theme.ok, + rocm_dash_core::bench_schema::PassFail::Fail => theme.err, + rocm_dash_core::bench_schema::PassFail::Unknown => theme.muted, + }, + }; + let verdict_text = match (r.pass_fail, r.judge_pass_fail) { + (rocm_dash_core::bench_schema::PassFail::Unknown, j) => format!("{j:?}"), + (p, _) => format!("{p:?}"), + }; + let model = r.model.as_deref().unwrap_or("?"); + let model_trunc = if model.len() > 20 { + &model[..20] + } else { + model + }; + let cell = if r.cell.len() > 10 { + &r.cell[..10] + } else { + r.cell.as_str() + }; + lines.push(Line::from(vec![ + Span::styled( + format!("{cell:<10} {:>3} {model_trunc:<20} ", r.run), + Style::default().fg(theme.fg), + ), + Span::styled( + format!( + "{:>12} {:>12} ", + format::tps_opt(r.prompt_tps), + format::tps_opt(r.gen_tps) + ), + Style::default().fg(theme.muted), + ), + Span::styled(verdict_text, Style::default().fg(verdict_color)), + ])); + } + f.render_widget(Paragraph::new(lines), inner); +} diff --git a/crates/rocm-dash-tui/src/ui/theme.rs b/crates/rocm-dash-tui/src/ui/theme.rs new file mode 100644 index 00000000..ce0f5192 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/theme.rs @@ -0,0 +1,600 @@ +//! Semantic theme. +//! +//! Two construction paths: +//! 1. Bespoke constructors (`default_dark`, `default_light`) that don't fit a +//! 16-color ANSI mapping cleanly. +//! 2. `from_palette(&Palette16)` for canonical terminal palettes. Hex values +//! sourced from each project's published palette. Theme catalogue +//! inspired by [ansicolor.com](https://ansicolor.com/). +//! +//! Pattern borrowed from ctux (see `../../../wiki/sources/ctux.md`). + +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Debug, Clone, Copy)] +pub struct Theme { + pub bg: Color, + pub surface: Color, + pub surface_2: Color, + pub fg: Color, + pub muted: Color, + pub accent: Color, + pub accent_2: Color, + pub ok: Color, + pub warn: Color, + pub err: Color, + pub border: Color, +} + +#[derive(Debug, Clone, Copy)] +pub enum StatusTone { + Neutral, + Muted, + Info, + Accent, + Warning, + Success, + Error, + Alert, +} + +/// Canonical 16-color ANSI palette plus default background/foreground. +/// Used as the source-of-truth shape for imported palettes; `Theme::from_palette` +/// reduces it to our 11-slot semantic palette. +#[derive(Debug, Clone, Copy)] +pub struct Palette16 { + pub bg: Color, + pub fg: Color, + pub black: Color, + pub red: Color, + pub green: Color, + pub yellow: Color, + pub blue: Color, + pub magenta: Color, + pub cyan: Color, + pub white: Color, + pub br_black: Color, + pub br_red: Color, + pub br_green: Color, + pub br_yellow: Color, + pub br_blue: Color, + pub br_magenta: Color, + pub br_cyan: Color, + pub br_white: Color, +} + +/// Registry entry. (name, constructor). First entry is the default. +type ThemeCtor = fn() -> Theme; + +const REGISTRY: &[(&str, ThemeCtor)] = &[ + ("default-dark", Theme::default_dark), + ("default-light", Theme::default_light), + ("solarized-dark", Theme::solarized_dark), + ("tokyo-night", Theme::tokyo_night), + ("tokyo-night-light", Theme::tokyo_night_light), + ("dracula", Theme::dracula), + ("gruvbox-dark", Theme::gruvbox_dark), + ("nord", Theme::nord), + ("one-dark", Theme::one_dark), + ("monokai", Theme::monokai), + ("catppuccin-mocha", Theme::catppuccin_mocha), + ("catppuccin-latte", Theme::catppuccin_latte), + ("ayu-dark", Theme::ayu_dark), + ("ayu-light", Theme::ayu_light), + ("github-dark", Theme::github_dark), +]; + +/// All registered theme names, in cycle order. +pub fn theme_names() -> Vec<&'static str> { + REGISTRY.iter().map(|(n, _)| *n).collect() +} + +impl Theme { + // --- bespoke palettes (don't fit a clean ANSI mapping) ----------------- + + pub fn default_dark() -> Self { + Self { + bg: rgb(0x13, 0x14, 0x16), + surface: rgb(0x1d, 0x1f, 0x21), + surface_2: rgb(0x27, 0x27, 0x2a), + fg: rgb(0xea, 0xeb, 0xec), + muted: rgb(0xb4, 0xb9, 0xbc), + accent: rgb(0x00, 0xc2, 0xde), + accent_2: rgb(0x00, 0x7d, 0xb8), + ok: rgb(0x1a, 0xa0, 0x1a), + warn: rgb(0xf5, 0x9e, 0x0b), + err: rgb(0xed, 0x1c, 0x24), + border: rgb(0x65, 0x6b, 0x72), + } + } + + pub fn default_light() -> Self { + Self { + bg: rgb(0xfa, 0xfa, 0xfb), + surface: rgb(0xf2, 0xf3, 0xf5), + surface_2: rgb(0xe4, 0xe7, 0xea), + fg: rgb(0x17, 0x1a, 0x1d), + muted: rgb(0x55, 0x5a, 0x60), + accent: rgb(0x00, 0x76, 0x9b), + accent_2: rgb(0x00, 0x4f, 0x71), + ok: rgb(0x14, 0x80, 0x14), + warn: rgb(0xb2, 0x6b, 0x00), + err: rgb(0xc4, 0x18, 0x1f), + border: rgb(0xa8, 0xae, 0xb4), + } + } + + pub fn solarized_dark() -> Self { + Self::from_palette(&palettes::SOLARIZED_DARK) + } + + // --- imported via Palette16 ------------------------------------------- + + pub fn tokyo_night() -> Self { + Self::from_palette(&palettes::TOKYO_NIGHT) + } + pub fn tokyo_night_light() -> Self { + Self::from_palette(&palettes::TOKYO_NIGHT_LIGHT) + } + pub fn dracula() -> Self { + Self::from_palette(&palettes::DRACULA) + } + pub fn gruvbox_dark() -> Self { + Self::from_palette(&palettes::GRUVBOX_DARK) + } + pub fn nord() -> Self { + Self::from_palette(&palettes::NORD) + } + pub fn one_dark() -> Self { + Self::from_palette(&palettes::ONE_DARK) + } + pub fn monokai() -> Self { + Self::from_palette(&palettes::MONOKAI) + } + pub fn catppuccin_mocha() -> Self { + Self::from_palette(&palettes::CATPPUCCIN_MOCHA) + } + pub fn catppuccin_latte() -> Self { + Self::from_palette(&palettes::CATPPUCCIN_LATTE) + } + pub fn ayu_dark() -> Self { + Self::from_palette(&palettes::AYU_DARK) + } + pub fn ayu_light() -> Self { + Self::from_palette(&palettes::AYU_LIGHT) + } + pub fn github_dark() -> Self { + Self::from_palette(&palettes::GITHUB_DARK) + } + + /// Reduce a 16-color ANSI palette to our semantic theme. + /// + /// Mapping (held constant for every imported palette so themes feel coherent): + /// - `bg/fg` — palette default background / foreground + /// - `surface` — `bg` (we differentiate via borders, not fills) + /// - `surface_2` — `br_black` (subtle non-bg backdrop) + /// - `muted` — `br_black` + /// - `accent` — `br_cyan` (the chip / sparkline color) + /// - `accent_2` — `blue` + /// - `ok` — `green` (or `br_green` if green looks washed) + /// - `warn` — `yellow` + /// - `err` — `red` + /// - `border` — `br_black` + pub fn from_palette(p: &Palette16) -> Self { + Self { + bg: p.bg, + surface: p.bg, + surface_2: p.br_black, + fg: p.fg, + muted: p.br_black, + accent: p.br_cyan, + accent_2: p.blue, + ok: p.green, + warn: p.yellow, + err: p.red, + border: p.br_black, + } + } + + /// Resolve a theme by name. Unknown names fall back to `default_dark` + /// with a tracing warning. + pub fn from_name(name: &str) -> Self { + for (n, ctor) in REGISTRY { + if *n == name { + return ctor(); + } + } + tracing::warn!( + requested = name, + fallback = "default-dark", + available = ?theme_names(), + "unknown theme; using default-dark" + ); + Self::default_dark() + } + + /// Cycle through the registry — wraps at the end. + pub fn next_name(current: &str) -> &'static str { + let idx = REGISTRY + .iter() + .position(|(n, _)| *n == current) + .unwrap_or(0); + REGISTRY[(idx + 1) % REGISTRY.len()].0 + } + + pub fn border_style(&self) -> Style { + Style::default().fg(self.border) + } + + pub fn title_style(&self) -> Style { + Style::default().fg(self.muted).add_modifier(Modifier::BOLD) + } + + pub fn tone_color(&self, tone: StatusTone) -> Color { + match tone { + StatusTone::Neutral => self.fg, + StatusTone::Muted => self.muted, + StatusTone::Info => self.accent_2, + StatusTone::Accent => self.accent, + StatusTone::Warning => self.warn, + StatusTone::Success => self.ok, + StatusTone::Error | StatusTone::Alert => self.err, + } + } +} + +impl Default for Theme { + fn default() -> Self { + Self::default_dark() + } +} + +const fn rgb(r: u8, g: u8, b: u8) -> Color { + Color::Rgb(r, g, b) +} + +/// 16-color ANSI palettes. Hex values from each project's canonical source; +/// the catalogue itself is inspired by . +mod palettes { + use super::{Palette16, rgb}; + + /// Solarized Dark — Ethan Schoonover, . + pub const SOLARIZED_DARK: Palette16 = Palette16 { + bg: rgb(0x00, 0x2b, 0x36), + fg: rgb(0xee, 0xe8, 0xd5), + black: rgb(0x07, 0x36, 0x42), + red: rgb(0xdc, 0x32, 0x2f), + green: rgb(0x85, 0x99, 0x00), + yellow: rgb(0xb5, 0x89, 0x00), + blue: rgb(0x26, 0x8b, 0xd2), + magenta: rgb(0xd3, 0x36, 0x82), + cyan: rgb(0x2a, 0xa1, 0x98), + white: rgb(0xee, 0xe8, 0xd5), + br_black: rgb(0x58, 0x6e, 0x75), + br_red: rgb(0xcb, 0x4b, 0x16), + br_green: rgb(0x58, 0x6e, 0x75), + br_yellow: rgb(0x65, 0x7b, 0x83), + br_blue: rgb(0x83, 0x94, 0x96), + br_magenta: rgb(0x6c, 0x71, 0xc4), + br_cyan: rgb(0x93, 0xa1, 0xa1), + br_white: rgb(0xfd, 0xf6, 0xe3), + }; + + /// Tokyo Night — enkia, . + pub const TOKYO_NIGHT: Palette16 = Palette16 { + bg: rgb(0x1a, 0x1b, 0x26), + fg: rgb(0xc0, 0xca, 0xf5), + black: rgb(0x15, 0x16, 0x1e), + red: rgb(0xf7, 0x76, 0x8e), + green: rgb(0x9e, 0xce, 0x6a), + yellow: rgb(0xe0, 0xaf, 0x68), + blue: rgb(0x7a, 0xa2, 0xf7), + magenta: rgb(0xbb, 0x9a, 0xf7), + cyan: rgb(0x7d, 0xcf, 0xff), + white: rgb(0xa9, 0xb1, 0xd6), + br_black: rgb(0x41, 0x48, 0x68), + br_red: rgb(0xf7, 0x76, 0x8e), + br_green: rgb(0x9e, 0xce, 0x6a), + br_yellow: rgb(0xe0, 0xaf, 0x68), + br_blue: rgb(0x7a, 0xa2, 0xf7), + br_magenta: rgb(0xbb, 0x9a, 0xf7), + br_cyan: rgb(0x7d, 0xcf, 0xff), + br_white: rgb(0xc0, 0xca, 0xf5), + }; + + pub const TOKYO_NIGHT_LIGHT: Palette16 = Palette16 { + bg: rgb(0xd5, 0xd6, 0xdb), + fg: rgb(0x34, 0x3b, 0x58), + black: rgb(0x0f, 0x0f, 0x14), + red: rgb(0x8c, 0x43, 0x51), + green: rgb(0x33, 0x63, 0x5c), + yellow: rgb(0x8f, 0x5e, 0x15), + blue: rgb(0x34, 0x54, 0x8a), + magenta: rgb(0x5a, 0x4a, 0x78), + cyan: rgb(0x0f, 0x4b, 0x6e), + white: rgb(0x68, 0x6c, 0x83), + br_black: rgb(0x4c, 0x50, 0x5e), + br_red: rgb(0x8c, 0x43, 0x51), + br_green: rgb(0x33, 0x63, 0x5c), + br_yellow: rgb(0x8f, 0x5e, 0x15), + br_blue: rgb(0x34, 0x54, 0x8a), + br_magenta: rgb(0x5a, 0x4a, 0x78), + br_cyan: rgb(0x0f, 0x4b, 0x6e), + br_white: rgb(0x34, 0x3b, 0x58), + }; + + /// Dracula — . + pub const DRACULA: Palette16 = Palette16 { + bg: rgb(0x28, 0x2a, 0x36), + fg: rgb(0xf8, 0xf8, 0xf2), + black: rgb(0x21, 0x22, 0x2c), + red: rgb(0xff, 0x55, 0x55), + green: rgb(0x50, 0xfa, 0x7b), + yellow: rgb(0xf1, 0xfa, 0x8c), + blue: rgb(0xbd, 0x93, 0xf9), + magenta: rgb(0xff, 0x79, 0xc6), + cyan: rgb(0x8b, 0xe9, 0xfd), + white: rgb(0xf8, 0xf8, 0xf2), + br_black: rgb(0x62, 0x72, 0xa4), + br_red: rgb(0xff, 0x6e, 0x6e), + br_green: rgb(0x69, 0xff, 0x94), + br_yellow: rgb(0xff, 0xff, 0xa5), + br_blue: rgb(0xd6, 0xac, 0xff), + br_magenta: rgb(0xff, 0x92, 0xdf), + br_cyan: rgb(0xa4, 0xff, 0xff), + br_white: rgb(0xff, 0xff, 0xff), + }; + + /// Gruvbox Dark — morhetz, . + pub const GRUVBOX_DARK: Palette16 = Palette16 { + bg: rgb(0x28, 0x28, 0x28), + fg: rgb(0xeb, 0xdb, 0xb2), + black: rgb(0x28, 0x28, 0x28), + red: rgb(0xcc, 0x24, 0x1d), + green: rgb(0x98, 0x97, 0x1a), + yellow: rgb(0xd7, 0x99, 0x21), + blue: rgb(0x45, 0x85, 0x88), + magenta: rgb(0xb1, 0x62, 0x86), + cyan: rgb(0x68, 0x9d, 0x6a), + white: rgb(0xa8, 0x99, 0x84), + br_black: rgb(0x92, 0x83, 0x74), + br_red: rgb(0xfb, 0x49, 0x34), + br_green: rgb(0xb8, 0xbb, 0x26), + br_yellow: rgb(0xfa, 0xbd, 0x2f), + br_blue: rgb(0x83, 0xa5, 0x98), + br_magenta: rgb(0xd3, 0x86, 0x9b), + br_cyan: rgb(0x8e, 0xc0, 0x7c), + br_white: rgb(0xeb, 0xdb, 0xb2), + }; + + /// Nord — Arctic Ice Studio, . + pub const NORD: Palette16 = Palette16 { + bg: rgb(0x2e, 0x34, 0x40), + fg: rgb(0xd8, 0xde, 0xe9), + black: rgb(0x3b, 0x42, 0x52), + red: rgb(0xbf, 0x61, 0x6a), + green: rgb(0xa3, 0xbe, 0x8c), + yellow: rgb(0xeb, 0xcb, 0x8b), + blue: rgb(0x81, 0xa1, 0xc1), + magenta: rgb(0xb4, 0x8e, 0xad), + cyan: rgb(0x88, 0xc0, 0xd0), + white: rgb(0xe5, 0xe9, 0xf0), + br_black: rgb(0x4c, 0x56, 0x6a), + br_red: rgb(0xbf, 0x61, 0x6a), + br_green: rgb(0xa3, 0xbe, 0x8c), + br_yellow: rgb(0xeb, 0xcb, 0x8b), + br_blue: rgb(0x81, 0xa1, 0xc1), + br_magenta: rgb(0xb4, 0x8e, 0xad), + br_cyan: rgb(0x8f, 0xbc, 0xbb), + br_white: rgb(0xec, 0xef, 0xf4), + }; + + /// One Dark — Atom, . + pub const ONE_DARK: Palette16 = Palette16 { + bg: rgb(0x28, 0x2c, 0x34), + fg: rgb(0xab, 0xb2, 0xbf), + black: rgb(0x28, 0x2c, 0x34), + red: rgb(0xe0, 0x6c, 0x75), + green: rgb(0x98, 0xc3, 0x79), + yellow: rgb(0xe5, 0xc0, 0x7b), + blue: rgb(0x61, 0xaf, 0xef), + magenta: rgb(0xc6, 0x78, 0xdd), + cyan: rgb(0x56, 0xb6, 0xc2), + white: rgb(0xab, 0xb2, 0xbf), + br_black: rgb(0x5c, 0x63, 0x70), + br_red: rgb(0xe0, 0x6c, 0x75), + br_green: rgb(0x98, 0xc3, 0x79), + br_yellow: rgb(0xe5, 0xc0, 0x7b), + br_blue: rgb(0x61, 0xaf, 0xef), + br_magenta: rgb(0xc6, 0x78, 0xdd), + br_cyan: rgb(0x56, 0xb6, 0xc2), + br_white: rgb(0xff, 0xff, 0xff), + }; + + /// Monokai — Wimer Hazenberg. + pub const MONOKAI: Palette16 = Palette16 { + bg: rgb(0x27, 0x28, 0x22), + fg: rgb(0xf8, 0xf8, 0xf2), + black: rgb(0x27, 0x28, 0x22), + red: rgb(0xf9, 0x26, 0x72), + green: rgb(0xa6, 0xe2, 0x2e), + yellow: rgb(0xf4, 0xbf, 0x75), + blue: rgb(0x66, 0xd9, 0xef), + magenta: rgb(0xae, 0x81, 0xff), + cyan: rgb(0xa1, 0xef, 0xe4), + white: rgb(0xf8, 0xf8, 0xf2), + br_black: rgb(0x75, 0x71, 0x5e), + br_red: rgb(0xf9, 0x26, 0x72), + br_green: rgb(0xa6, 0xe2, 0x2e), + br_yellow: rgb(0xf4, 0xbf, 0x75), + br_blue: rgb(0x66, 0xd9, 0xef), + br_magenta: rgb(0xae, 0x81, 0xff), + br_cyan: rgb(0xa1, 0xef, 0xe4), + br_white: rgb(0xf9, 0xf8, 0xf5), + }; + + /// Catppuccin Mocha — . + pub const CATPPUCCIN_MOCHA: Palette16 = Palette16 { + bg: rgb(0x1e, 0x1e, 0x2e), + fg: rgb(0xcd, 0xd6, 0xf4), + black: rgb(0x45, 0x47, 0x5a), + red: rgb(0xf3, 0x8b, 0xa8), + green: rgb(0xa6, 0xe3, 0xa1), + yellow: rgb(0xf9, 0xe2, 0xaf), + blue: rgb(0x89, 0xb4, 0xfa), + magenta: rgb(0xf5, 0xc2, 0xe7), + cyan: rgb(0x94, 0xe2, 0xd5), + white: rgb(0xba, 0xc2, 0xde), + br_black: rgb(0x58, 0x5b, 0x70), + br_red: rgb(0xf3, 0x8b, 0xa8), + br_green: rgb(0xa6, 0xe3, 0xa1), + br_yellow: rgb(0xf9, 0xe2, 0xaf), + br_blue: rgb(0x89, 0xb4, 0xfa), + br_magenta: rgb(0xf5, 0xc2, 0xe7), + br_cyan: rgb(0x94, 0xe2, 0xd5), + br_white: rgb(0xa6, 0xad, 0xc8), + }; + + /// Catppuccin Latte (light variant). + pub const CATPPUCCIN_LATTE: Palette16 = Palette16 { + bg: rgb(0xef, 0xf1, 0xf5), + fg: rgb(0x4c, 0x4f, 0x69), + black: rgb(0x5c, 0x5f, 0x77), + red: rgb(0xd2, 0x0f, 0x39), + green: rgb(0x40, 0xa0, 0x2b), + yellow: rgb(0xdf, 0x8e, 0x1d), + blue: rgb(0x1e, 0x66, 0xf5), + magenta: rgb(0xea, 0x76, 0xcb), + cyan: rgb(0x17, 0x92, 0x99), + white: rgb(0xac, 0xb0, 0xbe), + br_black: rgb(0x6c, 0x6f, 0x85), + br_red: rgb(0xd2, 0x0f, 0x39), + br_green: rgb(0x40, 0xa0, 0x2b), + br_yellow: rgb(0xdf, 0x8e, 0x1d), + br_blue: rgb(0x1e, 0x66, 0xf5), + br_magenta: rgb(0xea, 0x76, 0xcb), + br_cyan: rgb(0x17, 0x92, 0x99), + br_white: rgb(0xbc, 0xc0, 0xcc), + }; + + /// Ayu Dark — . + pub const AYU_DARK: Palette16 = Palette16 { + bg: rgb(0x0a, 0x0e, 0x14), + fg: rgb(0xb3, 0xb1, 0xad), + black: rgb(0x01, 0x06, 0x0e), + red: rgb(0xea, 0x6c, 0x73), + green: rgb(0x91, 0xb3, 0x62), + yellow: rgb(0xf9, 0xaf, 0x4f), + blue: rgb(0x53, 0xbd, 0xfa), + magenta: rgb(0xfa, 0xe9, 0x94), + cyan: rgb(0x90, 0xe1, 0xc6), + white: rgb(0xc7, 0xc7, 0xc7), + br_black: rgb(0x68, 0x68, 0x68), + br_red: rgb(0xf0, 0x71, 0x78), + br_green: rgb(0xc2, 0xd9, 0x4c), + br_yellow: rgb(0xff, 0xb4, 0x54), + br_blue: rgb(0x59, 0xc2, 0xff), + br_magenta: rgb(0xff, 0xee, 0x99), + br_cyan: rgb(0x95, 0xe6, 0xcb), + br_white: rgb(0xff, 0xff, 0xff), + }; + + pub const AYU_LIGHT: Palette16 = Palette16 { + bg: rgb(0xfa, 0xfa, 0xfa), + fg: rgb(0x5c, 0x61, 0x66), + black: rgb(0x00, 0x00, 0x00), + red: rgb(0xf5, 0x18, 0x18), + green: rgb(0x86, 0xb3, 0x00), + yellow: rgb(0xf2, 0x97, 0x18), + blue: rgb(0x41, 0xa6, 0xd9), + magenta: rgb(0xf0, 0x71, 0x78), + cyan: rgb(0x4c, 0xbf, 0x99), + white: rgb(0xfc, 0xfc, 0xfc), + br_black: rgb(0x82, 0x8c, 0x99), + br_red: rgb(0xf0, 0x71, 0x71), + br_green: rgb(0x86, 0xb3, 0x00), + br_yellow: rgb(0xf2, 0xae, 0x49), + br_blue: rgb(0x55, 0xb4, 0xd4), + br_magenta: rgb(0xa3, 0x7a, 0xcc), + br_cyan: rgb(0x4c, 0xbf, 0x99), + br_white: rgb(0xff, 0xff, 0xff), + }; + + /// GitHub Dark — primer/primitives. + pub const GITHUB_DARK: Palette16 = Palette16 { + bg: rgb(0x0d, 0x11, 0x17), + fg: rgb(0xc9, 0xd1, 0xd9), + black: rgb(0x48, 0x4f, 0x58), + red: rgb(0xff, 0x7b, 0x72), + green: rgb(0x3f, 0xb9, 0x50), + yellow: rgb(0xd2, 0x99, 0x22), + blue: rgb(0x58, 0xa6, 0xff), + magenta: rgb(0xbc, 0x8c, 0xff), + cyan: rgb(0x39, 0xc5, 0xcf), + white: rgb(0xb1, 0xba, 0xc4), + br_black: rgb(0x6e, 0x76, 0x81), + br_red: rgb(0xff, 0xa1, 0x98), + br_green: rgb(0x56, 0xd3, 0x64), + br_yellow: rgb(0xe3, 0xb3, 0x41), + br_blue: rgb(0x79, 0xc0, 0xff), + br_magenta: rgb(0xd2, 0xa8, 0xff), + br_cyan: rgb(0x56, 0xd4, 0xdd), + br_white: rgb(0xf0, 0xf6, 0xfc), + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_at_least_a_dozen_themes() { + assert!(theme_names().len() >= 12); + assert_eq!(theme_names()[0], "default-dark"); + } + + #[test] + fn known_names_resolve_to_distinct_themes() { + let mut bgs: Vec<_> = theme_names() + .into_iter() + .map(|n| format!("{:?}", Theme::from_name(n).bg)) + .collect(); + bgs.sort(); + let n = bgs.len(); + bgs.dedup(); + assert_eq!(bgs.len(), n, "two themes share the same bg color"); + } + + #[test] + fn unknown_name_falls_back_to_default_dark() { + let t = Theme::from_name("nope"); + let d = Theme::default_dark(); + assert_eq!(t.bg, d.bg); + assert_eq!(t.accent, d.accent); + } + + #[test] + fn next_name_cycles_through_registry() { + let names = theme_names(); + for i in 0..names.len() { + let expected = names[(i + 1) % names.len()]; + assert_eq!(Theme::next_name(names[i]), expected); + } + } + + #[test] + fn next_name_treats_unknown_as_first() { + assert_eq!(Theme::next_name("does-not-exist"), theme_names()[1]); + } + + #[test] + fn from_palette_maps_semantic_slots() { + let p = palettes::DRACULA; + let t = Theme::from_palette(&p); + assert_eq!(t.bg, p.bg); + assert_eq!(t.fg, p.fg); + assert_eq!(t.muted, p.br_black); + assert_eq!(t.accent, p.br_cyan); + assert_eq!(t.err, p.red); + } +} diff --git a/crates/rocm-dash-tui/src/ui/update_manager.rs b/crates/rocm-dash-tui/src/ui/update_manager.rs new file mode 100644 index 00000000..57d564e2 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/update_manager.rs @@ -0,0 +1,443 @@ +//! Update overlay (Phase 3 Wave 2). +//! +//! Wraps `rocm update`: a read-only check (and a `--apply --dry-run` preview) +//! that run straight through the job-bridge, plus the mutating `--apply` / +//! `--apply --activate` actions that route through the approval gate first. +//! This is the report-with-gated-apply archetype. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; + +use rocm_dash_core::state::{SideEffect, State, StateEvent}; + +use crate::ui::approval::{ + ApprovalChoice, ApprovalRequest, ApprovalVerdict, approval_key, draw_approval, +}; +use crate::ui::exec::{exe_label, resolve_exe}; +use crate::ui::job_console::{ConsoleOutcome, draw_job_console, on_console_key}; +use crate::ui::modal::{centered_rect, draw_popup_frame}; +use crate::ui::theme::Theme; + +/// The update menu actions, in display order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// `rocm update` — check only (read-only). + Check, + /// `rocm update --apply --dry-run` — show the plan (read-only). + Preview, + /// `rocm update --apply` — install (mutating → approval). + Apply, + /// `rocm update --apply --activate` — install + use as default (mutating). + ApplyActivate, +} + +/// Menu order; `state.selected` indexes this. +pub const ACTIONS: &[UpdateAction] = &[ + UpdateAction::Check, + UpdateAction::Preview, + UpdateAction::Apply, + UpdateAction::ApplyActivate, +]; + +impl UpdateAction { + pub fn label(self) -> &'static str { + match self { + UpdateAction::Check => "Check for updates", + UpdateAction::Preview => "Preview the update (dry-run)", + UpdateAction::Apply => "Apply the update", + UpdateAction::ApplyActivate => "Apply and use as default", + } + } + + /// `rocm` argv (after the binary) for this action. + fn args(self) -> Vec { + match self { + UpdateAction::Check => vec!["update".into()], + UpdateAction::Preview => vec!["update".into(), "--apply".into(), "--dry-run".into()], + UpdateAction::Apply => vec!["update".into(), "--apply".into()], + UpdateAction::ApplyActivate => { + vec!["update".into(), "--apply".into(), "--activate".into()] + } + } + } + + /// Whether this action changes the system (and so needs approval). + fn is_mutating(self) -> bool { + matches!(self, UpdateAction::Apply | UpdateAction::ApplyActivate) + } + + /// Stable job id (one console per action kind). + fn job_id(self) -> String { + match self { + UpdateAction::Check => "update-check", + UpdateAction::Preview => "update-preview", + UpdateAction::Apply => "update-apply", + UpdateAction::ApplyActivate => "update-apply-activate", + } + .to_string() + } +} + +/// An approved-but-not-yet-run mutating update op. +#[derive(Debug, Clone)] +pub struct PendingUpdate { + pub action: UpdateAction, + pub cmd: String, + pub request: ApprovalRequest, + pub choice: ApprovalChoice, +} + +/// Overlay state. `None` on `AppState` means the overlay is closed. +#[derive(Debug, Clone, Default)] +pub struct UpdateManagerState { + pub selected: usize, + pub approval: Option, + pub active_job: Option, + pub message: Option, +} + +/// Handle a key while the overlay is open. +pub fn on_key( + update: &mut Option, + jobs: &mut State, + key: KeyEvent, +) -> Vec { + let Some(u) = update.as_mut() else { + return Vec::new(); + }; + + // 1) Approval modal has focus. + if let Some(pending) = u.approval.as_mut() { + let (choice, verdict) = approval_key(key.code, pending.choice); + pending.choice = choice; + match verdict { + Some(ApprovalVerdict::Approve) => { + if let Some(pending) = u.approval.take() { + return spawn_update(u, jobs, pending.action, pending.cmd); + } + } + Some(ApprovalVerdict::Deny) | Some(ApprovalVerdict::Cancel) => u.approval = None, + None => {} + } + return Vec::new(); + } + + // 2) A job is showing in the console. + if let Some(job_id) = u.active_job.clone() { + match on_console_key(&job_id, jobs, key) { + ConsoleOutcome::Cancelled(fx) => return fx, + ConsoleOutcome::Closed => *update = None, + ConsoleOutcome::Dismissed => { + u.active_job = None; + u.message = None; + } + ConsoleOutcome::Unhandled => {} + } + return Vec::new(); + } + + // 3) Menu navigation + action. + match key.code { + KeyCode::Esc | KeyCode::Char('q') => *update = None, + KeyCode::Up | KeyCode::Char('k') => u.selected = u.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + u.selected = (u.selected + 1).min(ACTIONS.len() - 1); + } + KeyCode::Enter => return activate_selected(u, jobs), + _ => {} + } + Vec::new() +} + +/// Run the selected action: read-only ones spawn immediately; mutating ones +/// stage an approval first. +fn activate_selected(u: &mut UpdateManagerState, jobs: &mut State) -> Vec { + let action = ACTIONS[u.selected.min(ACTIONS.len() - 1)]; + let cmd = resolve_exe(); + if action.is_mutating() { + let request = ApprovalRequest::new( + action.label().to_string(), + vec![ + format!("{} {}", exe_label(&cmd), action.args().join(" ")), + String::new(), + "This installs a ROCm package update on this machine.".to_string(), + ], + ); + u.message = None; + u.approval = Some(PendingUpdate { + action, + cmd, + request, + choice: ApprovalChoice::default(), + }); + Vec::new() + } else { + spawn_update(u, jobs, action, cmd) + } +} + +/// Spawn the update job for `action`. +fn spawn_update( + u: &mut UpdateManagerState, + jobs: &mut State, + action: UpdateAction, + cmd: String, +) -> Vec { + let id = action.job_id(); + let fx = jobs.apply(StateEvent::StartJob { + id: id.clone(), + cmd, + args: action.args(), + }); + if fx.is_empty() { + u.message = Some(format!("“{}” is already running", action.label())); + return fx; + } + u.active_job = Some(id); + fx +} + +/// Render the overlay (menu, or the approval modal, or the job console). +pub fn draw_update_manager( + f: &mut Frame, + area: Rect, + u: &UpdateManagerState, + jobs: &State, + theme: &Theme, +) { + if let Some(job_id) = &u.active_job + && let Some(job) = jobs.job(job_id) + { + draw_job_console(f, area, job, 0, theme); + return; + } + + let popup = centered_rect(70, 60, 84, 16, area); + let inner = draw_popup_frame(f, popup, "Update — ROCm packages", theme); + if inner.height == 0 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let items: Vec = ACTIONS + .iter() + .map(|a| { + let tag = if a.is_mutating() { + " (needs approval)" + } else { + "" + }; + ListItem::new(Line::from(vec![ + Span::styled(a.label().to_string(), Style::default().fg(theme.fg)), + Span::styled(tag, Style::default().fg(theme.warn)), + ])) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(u.selected.min(ACTIONS.len() - 1))); + let list = List::new(items).highlight_style( + Style::default() + .bg(theme.surface_2) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, rows[0], &mut ls); + + let msg = u.message.as_deref().unwrap_or(""); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.to_string(), + Style::default().fg(theme.err), + ))), + rows[1], + ); + + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑↓ select · Enter run · Esc close", + Style::default().fg(theme.muted), + ))), + rows[2], + ); + + if let Some(pending) = &u.approval { + draw_approval(f, area, &pending.request, pending.choice, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(c: KeyCode) -> KeyEvent { + KeyEvent::new(c, KeyModifiers::NONE) + } + + #[test] + fn check_is_read_only_and_spawns_without_approval() { + let mut u = Some(UpdateManagerState::default()); // selected 0 = Check + let mut jobs = State::default(); + let fx = on_key(&mut u, &mut jobs, key(KeyCode::Enter)); + assert_eq!(fx.len(), 1); + assert!( + u.as_ref().unwrap().approval.is_none(), + "no gate for read-only" + ); + assert_eq!( + u.as_ref().unwrap().active_job.as_deref(), + Some("update-check") + ); + } + + #[test] + fn preview_is_read_only() { + let mut u = Some(UpdateManagerState::default()); + let mut jobs = State::default(); + u.as_mut().unwrap().selected = ACTIONS + .iter() + .position(|a| *a == UpdateAction::Preview) + .unwrap(); + let fx = on_key(&mut u, &mut jobs, key(KeyCode::Enter)); + assert_eq!(fx.len(), 1); + assert!(u.as_ref().unwrap().approval.is_none()); + assert_eq!( + u.as_ref().unwrap().active_job.as_deref(), + Some("update-preview") + ); + } + + #[test] + fn apply_is_gated_then_spawns() { + let mut u = Some(UpdateManagerState::default()); + let mut jobs = State::default(); + u.as_mut().unwrap().selected = ACTIONS + .iter() + .position(|a| *a == UpdateAction::Apply) + .unwrap(); + // Enter stages approval, NO job. + let fx = on_key(&mut u, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty()); + assert!(u.as_ref().unwrap().approval.is_some()); + assert!(jobs.jobs.is_empty()); + // Approve → spawns. + let fx = on_key(&mut u, &mut jobs, key(KeyCode::Char('y'))); + assert_eq!(fx.len(), 1); + assert_eq!( + u.as_ref().unwrap().active_job.as_deref(), + Some("update-apply") + ); + } + + #[test] + fn apply_args_carry_apply_and_activate() { + assert_eq!(UpdateAction::Apply.args(), vec!["update", "--apply"]); + assert_eq!( + UpdateAction::ApplyActivate.args(), + vec!["update", "--apply", "--activate"] + ); + assert_eq!( + UpdateAction::Preview.args(), + vec!["update", "--apply", "--dry-run"] + ); + assert_eq!(UpdateAction::Check.args(), vec!["update"]); + } + + #[test] + fn deny_cancels_without_spawning() { + let mut u = Some(UpdateManagerState::default()); + let mut jobs = State::default(); + u.as_mut().unwrap().selected = ACTIONS + .iter() + .position(|a| *a == UpdateAction::Apply) + .unwrap(); + on_key(&mut u, &mut jobs, key(KeyCode::Enter)); + let fx = on_key(&mut u, &mut jobs, key(KeyCode::Char('n'))); + assert!(fx.is_empty()); + assert!(u.as_ref().unwrap().approval.is_none()); + assert!(jobs.jobs.is_empty()); + } + + #[test] + fn navigation_clamps() { + let mut u = Some(UpdateManagerState::default()); + let mut jobs = State::default(); + for _ in 0..10 { + on_key(&mut u, &mut jobs, key(KeyCode::Down)); + } + assert_eq!(u.as_ref().unwrap().selected, ACTIONS.len() - 1); + for _ in 0..10 { + on_key(&mut u, &mut jobs, key(KeyCode::Up)); + } + assert_eq!(u.as_ref().unwrap().selected, 0); + } + + #[test] + fn q_escapes_overlay_while_job_runs() { + let mut u = Some(UpdateManagerState::default()); + let mut jobs = State::default(); + on_key(&mut u, &mut jobs, key(KeyCode::Enter)); // Check spawns + on_key(&mut u, &mut jobs, key(KeyCode::Char('q'))); + assert!(u.is_none()); + } + + #[test] + fn snapshot_lists_actions_and_gated_tags() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let theme = Theme::from_name("default-dark"); + let backend = TestBackend::new(90, 18); + let mut term = Terminal::new(backend).unwrap(); + let u = UpdateManagerState::default(); + let jobs = State::default(); + term.draw(|f| draw_update_manager(f, f.area(), &u, &jobs, &theme)) + .unwrap(); + let out: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(out.contains("Update")); + assert!(out.contains("Check for updates")); + assert!(out.contains("needs approval")); + } + + #[test] + fn relaunch_while_job_running_surfaces_message_not_stale_console() { + // Mirrors engine_manager: a StartJob for a still-running id no-ops, so + // spawn_update must surface a message and NOT point active_job at it. + let mut jobs = State::default(); + let mut u1 = Some(UpdateManagerState::default()); // Check + on_key(&mut u1, &mut jobs, key(KeyCode::Enter)); + assert_eq!( + u1.as_ref().unwrap().active_job.as_deref(), + Some("update-check") + ); + // Fresh overlay, same read-only Check while the prior one still runs. + let mut u2 = Some(UpdateManagerState::default()); + let fx = on_key(&mut u2, &mut jobs, key(KeyCode::Enter)); + assert!(fx.is_empty(), "no double-spawn for a running id"); + let s = u2.as_ref().unwrap(); + assert!(s.active_job.is_none(), "must not point at the stale job"); + assert!( + s.message + .as_deref() + .unwrap_or("") + .contains("already running") + ); + assert_eq!(jobs.jobs.len(), 1); + } +} diff --git a/crates/rocm-dash-tui/src/ui/widgets.rs b/crates/rocm-dash-tui/src/ui/widgets.rs new file mode 100644 index 00000000..98680461 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/widgets.rs @@ -0,0 +1,252 @@ +//! Small UI helpers shared across tabs. + +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use rocm_dash_core::metrics::{GpuMetrics, Instance, Snapshot}; + +use crate::ui::theme::Theme; + +/// Temperature warning threshold (°C). At or above → warn color. +pub const TEMP_WARN_C: f32 = 60.0; +/// Temperature critical threshold (°C). At or above → err color. +pub const TEMP_CRIT_C: f32 = 80.0; + +/// Board-power warning threshold (W). At or above → warn color. +/// +/// Fixed semantic thresholds make heatmap/gauge colors mean "near the limit" +/// rather than "near the largest value seen this session". Tuned for +/// MI355X-class parts (TDP ~750 W); the demo generator peaks ~740 W at 100% +/// util. No per-GPU TDP exists in the data model, so these are constants. +pub const POWER_WARN_W: f32 = 525.0; +/// Board-power critical threshold (W). At or above → err color. ~TDP-adjacent. +pub const POWER_CRIT_W: f32 = 700.0; + +/// Truncate `s` to at most `n` characters (not bytes). +pub fn trunc(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + s.chars().take(n).collect() + } +} + +/// Color a temperature value per the instinct-dash conventions. +pub fn temperature_style(c: f32, theme: &Theme) -> Style { + let color = if c >= TEMP_CRIT_C { + theme.err + } else if c >= TEMP_WARN_C { + theme.warn + } else { + theme.ok + }; + Style::default().fg(color) +} + +/// Color a board-power value against the fixed [`POWER_WARN_W`] / +/// [`POWER_CRIT_W`] thresholds, mirroring [`temperature_style`]. +pub fn power_style(w: f32, theme: &Theme) -> Style { + let color = if w >= POWER_CRIT_W { + theme.err + } else if w >= POWER_WARN_W { + theme.warn + } else { + theme.ok + }; + Style::default().fg(color) +} + +/// Trailing run of ASCII digits in `s` (e.g. `"gpu-3"` → `"3"`, `"3"` → `"3"`). +/// Returns `None` when `s` has no trailing digits. +fn trailing_digits(s: &str) -> Option<&str> { + let start = s.len() - s.chars().rev().take_while(|c| c.is_ascii_digit()).count(); + if start == s.len() { + None + } else { + Some(&s[start..]) + } +} + +/// Instances scheduled on the GPU identified by `device_id`. +/// +/// GPU `device_id` (`"gpu-3"`) and `Instance.gpu_ids` (`"3"`) use different +/// shapes, so matching normalizes both to their trailing digit run before +/// comparing. A bare `"3"` device_id also matches `"3"`. +pub fn instances_on_gpu<'a>(device_id: &str, instances: &'a [Instance]) -> Vec<&'a Instance> { + let want = trailing_digits(device_id); + instances + .iter() + .filter(|inst| { + inst.gpu_ids.iter().any(|gid| { + // match on normalized trailing digits, falling back to raw eq + match (want, trailing_digits(gid)) { + (Some(a), Some(b)) => a == b, + _ => gid == device_id, + } + }) + }) + .collect() +} + +/// Node-level energy efficiency: total generation throughput divided by total +/// board power, in tokens per watt. `None` when there is no traffic +/// (`sum gen_tps == 0`) or no power telemetry (`sum power_w == 0`), or when the +/// result is non-finite. +pub fn node_efficiency(snap: &Snapshot) -> Option { + let tps: f64 = snap.instances.iter().filter_map(|i| i.gen_tps).sum(); + let power: f64 = snap.gpus.iter().map(|g| g.power_w as f64).sum(); + if tps > 0.0 && power > 0.0 { + let eff = tps / power; + eff.is_finite().then_some(eff) + } else { + None + } +} + +/// One-line GPU stats: id, util, vram, temp, power. +pub fn gpu_stats_line<'a>(g: &'a GpuMetrics, theme: &Theme) -> Line<'a> { + let vram_pct = if g.vram_total_mb > 0 { + 100.0 * g.vram_used_mb as f64 / g.vram_total_mb as f64 + } else { + 0.0 + }; + Line::from(vec![ + Span::styled( + format!("{:<8}", g.device_id), + Style::default().fg(theme.accent), + ), + Span::styled( + format!(" util {:5.1}%", g.gpu_utilization_pct), + Style::default().fg(theme.fg), + ), + Span::styled( + format!( + " vram {:>5}/{:<5} MB ({:4.1}%)", + g.vram_used_mb, g.vram_total_mb, vram_pct + ), + Style::default().fg(theme.muted), + ), + Span::styled( + format!(" {:>5.1}°C {:>5.1} W", g.temperature_c, g.power_w), + temperature_style(g.temperature_c, theme), + ), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inst(name: &str, gpu_ids: &[&str], gen_tps: Option) -> Instance { + Instance { + container_name: name.into(), + model_name: name.into(), + gpu_ids: gpu_ids.iter().map(|s| s.to_string()).collect(), + gen_tps, + ..Default::default() + } + } + + fn gpu(device_id: &str, power_w: f32) -> GpuMetrics { + GpuMetrics { + device_id: device_id.into(), + power_w, + ..Default::default() + } + } + + #[test] + fn trunc_keeps_short_strings() { + assert_eq!(trunc("abc", 5), "abc"); + assert_eq!(trunc("abcde", 5), "abcde"); + } + + #[test] + fn trunc_cuts_long_strings_by_chars() { + assert_eq!(trunc("abcdefgh", 5), "abcde"); + assert_eq!(trunc("αβγδεζη", 3), "αβγ"); + } + + #[test] + fn power_style_uses_fixed_thresholds() { + let theme = Theme::default_dark(); + assert_eq!(power_style(720.0, &theme).fg, Some(theme.err)); + assert_eq!(power_style(600.0, &theme).fg, Some(theme.warn)); + assert_eq!(power_style(300.0, &theme).fg, Some(theme.ok)); + // boundaries are inclusive at the threshold + assert_eq!(power_style(POWER_CRIT_W, &theme).fg, Some(theme.err)); + assert_eq!(power_style(POWER_WARN_W, &theme).fg, Some(theme.warn)); + } + + #[test] + fn temperature_style_uses_named_thresholds() { + let theme = Theme::default_dark(); + assert_eq!(temperature_style(85.0, &theme).fg, Some(theme.err)); + assert_eq!(temperature_style(65.0, &theme).fg, Some(theme.warn)); + assert_eq!(temperature_style(40.0, &theme).fg, Some(theme.ok)); + } + + #[test] + fn trailing_digits_extracts_index() { + assert_eq!(trailing_digits("gpu-3"), Some("3")); + assert_eq!(trailing_digits("3"), Some("3")); + assert_eq!(trailing_digits("gpu-12"), Some("12")); + assert_eq!(trailing_digits("gpu"), None); + assert_eq!(trailing_digits(""), None); + } + + #[test] + fn instances_on_gpu_matches_normalized_index() { + let xs = vec![ + inst("vllm-a", &["0", "1"], Some(10.0)), + inst("vllm-b", &["3"], Some(20.0)), + ]; + // "gpu-3" device_id normalizes to "3" → matches vllm-b + let on3 = instances_on_gpu("gpu-3", &xs); + assert_eq!(on3.len(), 1); + assert_eq!(on3[0].model_name, "vllm-b"); + // bare "0" matches the "0" gpu_id + let on0 = instances_on_gpu("0", &xs); + assert_eq!(on0.len(), 1); + assert_eq!(on0[0].model_name, "vllm-a"); + } + + #[test] + fn instances_on_gpu_empty_when_no_match() { + let xs = vec![inst("vllm-a", &["0"], Some(10.0))]; + assert!(instances_on_gpu("gpu-7", &xs).is_empty()); + assert!(instances_on_gpu("gpu-7", &[]).is_empty()); + } + + #[test] + fn node_efficiency_divides_tps_by_power() { + let snap = Snapshot { + gpus: vec![gpu("gpu-0", 400.0), gpu("gpu-1", 600.0)], + instances: vec![ + inst("a", &["0"], Some(300.0)), + inst("b", &["1"], Some(200.0)), + ], + ..Default::default() + }; + // (300 + 200) / (400 + 600) = 0.5 tok/W + let eff = node_efficiency(&snap).expect("some"); + assert!((eff - 0.5).abs() < 1e-9, "got {eff}"); + } + + #[test] + fn node_efficiency_none_without_power_or_traffic() { + // no power + let no_power = Snapshot { + gpus: vec![gpu("gpu-0", 0.0)], + instances: vec![inst("a", &["0"], Some(100.0))], + ..Default::default() + }; + assert_eq!(node_efficiency(&no_power), None); + // no traffic + let no_traffic = Snapshot { + gpus: vec![gpu("gpu-0", 500.0)], + instances: vec![inst("a", &["0"], None)], + ..Default::default() + }; + assert_eq!(node_efficiency(&no_traffic), None); + } +} diff --git a/crates/rocm-dash-tui/tests/fixtures/lemonade_release_latest.json b/crates/rocm-dash-tui/tests/fixtures/lemonade_release_latest.json new file mode 100644 index 00000000..aaa0b3bf --- /dev/null +++ b/crates/rocm-dash-tui/tests/fixtures/lemonade_release_latest.json @@ -0,0 +1,45 @@ +{ + "tag_name": "v10.6.0", + "name": "Lemonade v10.6.0", + "html_url": "https://github.com/lemonade-sdk/lemonade/releases/tag/v10.6.0", + "draft": false, + "prerelease": false, + "assets": [ + { + "name": "Lemonade-10.6.0-Darwin.pkg", + "content_type": "application/octet-stream", + "size": 9868497, + "browser_download_url": "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/Lemonade-10.6.0-Darwin.pkg" + }, + { + "name": "lemonade-embeddable-10.6.0-macos-arm64.tar.gz", + "content_type": "application/gzip", + "size": 2827730, + "browser_download_url": "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade-embeddable-10.6.0-macos-arm64.tar.gz" + }, + { + "name": "lemonade-embeddable-10.6.0-ubuntu-x64.tar.gz", + "content_type": "application/gzip", + "size": 3425958, + "browser_download_url": "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade-embeddable-10.6.0-ubuntu-x64.tar.gz" + }, + { + "name": "lemonade-embeddable-10.6.0-windows-x64.zip", + "content_type": "application/zip", + "size": 2891846, + "browser_download_url": "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade-embeddable-10.6.0-windows-x64.zip" + }, + { + "name": "lemonade-server-10.6.0-fc44.x86_64.rpm", + "content_type": "application/x-redhat-package-manager", + "size": 3879912, + "browser_download_url": "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade-server-10.6.0-fc44.x86_64.rpm" + }, + { + "name": "lemonade.msi", + "content_type": "application/x-msdownload", + "size": 8052736, + "browser_download_url": "https://github.com/lemonade-sdk/lemonade/releases/download/v10.6.0/lemonade.msi" + } + ] +} diff --git a/crates/rocm-dash-tui/tests/wave0_job_bridge.rs b/crates/rocm-dash-tui/tests/wave0_job_bridge.rs new file mode 100644 index 00000000..774aa3a2 --- /dev/null +++ b/crates/rocm-dash-tui/tests/wave0_job_bridge.rs @@ -0,0 +1,183 @@ +//! Wave 0 exit gate (Phase 3 / EAI-6871). +//! +//! Proves the job-bridge spine end-to-end: a real long-running child process is +//! launched through the reducer's [`SideEffect::SpawnJob`], its output streams +//! back as [`StateEvent::JobLine`] events that the pure reducer accumulates, and +//! a [`StateEvent::CancelJob`] tears the process down via the shared +//! `Arc`. Plus `TestBackend` buffer snapshots of the two Wave-0 +//! render seams (job console + approval gate). + +use std::time::Duration; + +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use rocm_dash_core::state::{JobStatus, State, StateEvent}; +use rocm_dash_tui::jobs; +use rocm_dash_tui::ui::approval::{ApprovalChoice, ApprovalRequest, draw_approval}; +use rocm_dash_tui::ui::job_console::draw_job_console; +use rocm_dash_tui::ui::theme::Theme; +use tokio::sync::mpsc; + +/// Drive a real long-running command through the bridge, stream a few lines, +/// then cancel it. The job must end `Cancelled` with output captured. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn long_job_streams_then_cancels() { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut state = State::default(); + + // A shell loop that prints 200 lines slowly — long enough to cancel mid-run. + let fx = state.apply(StateEvent::StartJob { + id: "demo".into(), + cmd: "sh".into(), + args: vec![ + "-c".into(), + "i=0; while [ $i -lt 200 ]; do echo line$i; i=$((i+1)); sleep 0.05; done".into(), + ], + }); + // The runtime interprets SpawnJob: launches the child, streams lines back. + jobs::run_effects(fx, &tx); + + // Collect streamed lines until we have several, then cancel. + let mut seen = 0; + while seen < 3 { + let ev = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("job produced output before timeout") + .expect("channel open"); + if matches!(ev, StateEvent::JobLine { .. }) { + seen += 1; + } + state.apply(ev); + } + assert!(seen >= 3, "streamed at least three lines"); + assert_eq!(state.job("demo").unwrap().status, JobStatus::Running); + + // Cancel: flips the shared flag (reducer) → runtime kills the child. + state.apply(StateEvent::CancelJob("demo".into())); + assert_eq!(state.job("demo").unwrap().status, JobStatus::Cancelled); + + // Drain any in-flight events; the process should stop producing shortly. + // A racing JobDone/JobLine must not resurrect or grow the cancelled job. + let lines_at_cancel = state.job("demo").unwrap().output.len(); + while let Ok(Some(ev)) = tokio::time::timeout(Duration::from_millis(400), rx.recv()).await { + state.apply(ev); + } + let job = state.job("demo").unwrap(); + assert_eq!(job.status, JobStatus::Cancelled, "stays cancelled"); + assert_eq!( + job.output.len(), + lines_at_cancel, + "no lines accepted after cancel" + ); + assert!(lines_at_cancel >= 3, "captured the streamed output"); +} + +/// A spawn of a non-existent binary surfaces a `JobErr` → `Failed` status. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_binary_fails_cleanly() { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut state = State::default(); + let fx = state.apply(StateEvent::StartJob { + id: "nope".into(), + cmd: "this-binary-does-not-exist-rocmdash".into(), + args: vec![], + }); + jobs::run_effects(fx, &tx); + + let ev = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("error reported before timeout") + .expect("channel open"); + state.apply(ev); + assert!(matches!( + state.job("nope").unwrap().status, + JobStatus::Failed { .. } + )); +} + +/// A short command that exits on its own reaches `Done { code: 0 }` and all of +/// its output is drained before completion is reported. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn short_job_completes_with_all_output() { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut state = State::default(); + let fx = state.apply(StateEvent::StartJob { + id: "echo".into(), + cmd: "sh".into(), + args: vec!["-c".into(), "printf 'a\\nb\\nc\\n'".into()], + }); + jobs::run_effects(fx, &tx); + + loop { + let ev = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("completes before timeout") + .expect("channel open"); + let done = matches!(ev, StateEvent::JobDone { .. }); + state.apply(ev); + if done { + break; + } + } + let job = state.job("echo").unwrap(); + assert_eq!(job.status, JobStatus::Done { code: 0 }); + let out: Vec<&str> = job.output.iter().map(String::as_str).collect(); + assert_eq!(out, vec!["a", "b", "c"], "all lines drained before done"); +} + +fn render(cols: u16, rows: u16, draw: F) -> String { + let backend = TestBackend::new(cols, rows); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f)).unwrap(); + let buf = term.backend().buffer().clone(); + buf.content().iter().map(|c| c.symbol()).collect() +} + +#[test] +fn job_console_snapshot_renders_status_and_output() { + let theme = Theme::from_name("default-dark"); + let mut state = State::default(); + state.apply(StateEvent::StartJob { + id: "serve".into(), + cmd: "rocm".into(), + args: vec!["serve".into(), "llama3".into()], + }); + for i in 0..4 { + state.apply(StateEvent::JobLine { + id: "serve".into(), + line: format!("loading shard {i}"), + }); + } + let job = state.job("serve").unwrap(); + let out = render(140, 32, |f| draw_job_console(f, f.area(), job, 0, &theme)); + + assert!(out.contains("rocm serve llama3"), "title shows the command"); + assert!(out.contains("status"), "status badge present"); + assert!(out.contains("running"), "running status label"); + assert!(out.contains("loading shard 0"), "streamed output rendered"); + assert!(out.contains("Ctrl+C cancel"), "cancel hint while running"); +} + +#[test] +fn approval_snapshot_renders_request_and_buttons() { + let theme = Theme::from_name("default-dark"); + let req = ApprovalRequest::new( + "serve llama3 (managed)", + vec![ + "rocm serve llama3 --engine vllm --port 8000 --managed".into(), + "Starts a managed vLLM service and registers it.".into(), + ], + ); + let out = render(120, 26, |f| { + draw_approval(f, f.area(), &req, ApprovalChoice::Approve, &theme) + }); + + assert!( + out.contains("Review: serve llama3"), + "title with screen name" + ); + assert!(out.contains("rocm serve llama3"), "command preview shown"); + assert!(out.contains("Approve"), "approve button"); + assert!(out.contains("Deny"), "deny button"); + assert!(out.contains("Esc/q cancel"), "cancel hint"); +} diff --git a/docs/rocm-dash-merge-status.md b/docs/rocm-dash-merge-status.md new file mode 100644 index 00000000..a94fda1f --- /dev/null +++ b/docs/rocm-dash-merge-status.md @@ -0,0 +1,57 @@ +# rocm-dash → rocm-cli merge — status & handoff (EAI-6871) + +> Branch: `integration/rocm-dash-merge` (local; **never pushed** to the ROCm org). +> Base: `837067f` ("Import rocm-cli"). Source: `~/git/rocm-dash/app` @ `main`. +> Scope of this branch: **Phase 1 (foundation)** of `wiki/plans/rocm-cli-unification.md` +> (that plan lives in the rocm-dash repo). Pure-engineering; governance/legal gates +> (G1 license, G3/G4/G5) are tracked out-of-band. + +## What landed (Phase 1 — green) + +The four rocm-dash **library** crates are first-class workspace members under `crates/`: + +| Crate | Tests | Notes | +|---|---|---| +| `rocm-dash-core` | 46 | pure-reducer types; no tokio/ratatui at the boundary (invariant LRN-20260405-004) | +| `rocm-dash-collectors` | 54 (+2 ign) +2 integ | amd-smi / Docker / vLLM-Prom / Lemonade / per-proc VRAM; reqwest **0.12** | +| `rocm-dash-daemon` | 19 +3 integ | telemetry runner + snapshot ring | +| `rocm-dash-tui` | 201 (+2 ign) | ratatui **0.30** dashboard base; rig-core 0.38.1 chat; reqwest **0.13** | + +- **Edition 2024 / rust 1.88 / Apache-2.0** (inherited from the workspace). `cargo fix --edition` + needed **zero** source changes; clippy `--fix` applied `collapsible_if`→let-chains; fmt clean. +- **ratatui majors coexist, confined per crate:** `apps/rocm` `tui.rs` keeps **0.29**; `rocm-dash-tui` + uses **0.30**. Each crate `use`s only its own major. +- **HTTP partition:** ureq stays the rocm-cli sync default everywhere; reqwest confined to + `rocm-dash-collectors` (0.12) + `rocm-dash-tui` (0.13, via Rig). +- **Vendor not promoted:** the rocm-dash `crates/vendor/*` (stale March snapshot) and the rocm-dash + `rocm` bin were intentionally **not** copied. `apps/rocm` remains the sole `rocm` bin. The bin's + vendor→real-crate reconcile + verb fold is **Phase 2**. +- Verified: `cargo build --workspace --all-targets` green; the 4 crates at **exact parity** with rocm-dash. + +## Pre-existing `apps/rocm` test failures (NOT caused by this merge) + +`cargo test --workspace` shows 3 failures, all in `apps/rocm` (code this merge never touched). +Diagnosed 2026-06-11; none are GPU-related, none are merge regressions: + +1. `tui::tests::served_model_chat_accepts_typed_messages_and_uses_selected_model` +2. `tui::tests::assistant_tui_support_prompts_reach_validated_local_model` + → both use an in-process **fake HTTP chat server** with a 5 s `recv_timeout`; they are + **parallelism/timing flakes** — they **pass when run single-threaded** (`--test-threads=1`). +3. `therock::tests::python_launcher_prefers_path_python_before_saved_managed_python` + **and** `therock::tests::python_launcher_skips_path_python_without_pip_ready_venv` + → **environmental** (two tests, same root cause): `resolve_python_launcher` executes a generated + fake-python stub to check it can build a pip venv; on a host where the stub can't run, the + resolver skips it. Both fail deterministically on this machine **independent of the merge** + (guarded by `PYTHON_RESOLVER_TEST_ENV_LOCK`, i.e. the authors already treat these as + env-sensitive). (Originally only the first was named here; verified 2026-06-11 that both fail on + the pre-merge HEAD, so the honest pre-existing count is **4**: 2 flaky chat + 2 env python-launcher.) + +These belong to the rocm-cli side and predate the merge. Do **not** treat them as merge regressions. + +## Deferred (intentionally NOT in Phase 1) + +- **crossterm 0.28→0.29 unification** + port `app.rs` off the crossterm-0.28 `EventStream`. The two + versions coexist cleanly today; unification is a nicety, sequenced with the TUI work. +- **collectors reqwest 0.12→0.13 unification** (cosmetic; both coexist). +- Everything in **Phase 2** (config/engines/daemon/dispatch + bin fold + vendor→real reconcile) and + **Phase 3** (unified-TUI screen merge + chat split). See `wiki/plans/rocm-cli-unification*.md`.