max / makenotwork
13 files changed,
+11 insertions,
-2939 deletions
| @@ -1,3634 +0,0 @@ | |||
| 1 | - | # This file is automatically @generated by Cargo. | |
| 2 | - | # It is not intended for manual editing. | |
| 3 | - | version = 4 | |
| 4 | - | ||
| 5 | - | [[package]] | |
| 6 | - | name = "ahash" | |
| 7 | - | version = "0.8.12" | |
| 8 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | - | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 10 | - | dependencies = [ | |
| 11 | - | "cfg-if", | |
| 12 | - | "const-random", | |
| 13 | - | "getrandom 0.3.4", | |
| 14 | - | "once_cell", | |
| 15 | - | "version_check", | |
| 16 | - | "zerocopy", | |
| 17 | - | ] | |
| 18 | - | ||
| 19 | - | [[package]] | |
| 20 | - | name = "aho-corasick" | |
| 21 | - | version = "1.1.4" | |
| 22 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 23 | - | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 24 | - | dependencies = [ | |
| 25 | - | "memchr", | |
| 26 | - | ] | |
| 27 | - | ||
| 28 | - | [[package]] | |
| 29 | - | name = "allocator-api2" | |
| 30 | - | version = "0.2.21" | |
| 31 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 32 | - | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 33 | - | ||
| 34 | - | [[package]] | |
| 35 | - | name = "android_system_properties" | |
| 36 | - | version = "0.1.5" | |
| 37 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 38 | - | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 39 | - | dependencies = [ | |
| 40 | - | "libc", | |
| 41 | - | ] | |
| 42 | - | ||
| 43 | - | [[package]] | |
| 44 | - | name = "anyhow" | |
| 45 | - | version = "1.0.102" | |
| 46 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 47 | - | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | |
| 48 | - | ||
| 49 | - | [[package]] | |
| 50 | - | name = "async-trait" | |
| 51 | - | version = "0.1.89" | |
| 52 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 53 | - | checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | |
| 54 | - | dependencies = [ | |
| 55 | - | "proc-macro2", | |
| 56 | - | "quote", | |
| 57 | - | "syn", | |
| 58 | - | ] | |
| 59 | - | ||
| 60 | - | [[package]] | |
| 61 | - | name = "atoi" | |
| 62 | - | version = "2.0.0" | |
| 63 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 64 | - | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 65 | - | dependencies = [ | |
| 66 | - | "num-traits", | |
| 67 | - | ] | |
| 68 | - | ||
| 69 | - | [[package]] | |
| 70 | - | name = "atomic-waker" | |
| 71 | - | version = "1.1.2" | |
| 72 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 73 | - | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 74 | - | ||
| 75 | - | [[package]] | |
| 76 | - | name = "autocfg" | |
| 77 | - | version = "1.5.1" | |
| 78 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 79 | - | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 80 | - | ||
| 81 | - | [[package]] | |
| 82 | - | name = "aws-lc-rs" | |
| 83 | - | version = "1.17.3" | |
| 84 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 85 | - | checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" | |
| 86 | - | dependencies = [ | |
| 87 | - | "aws-lc-sys", | |
| 88 | - | "zeroize", | |
| 89 | - | ] | |
| 90 | - | ||
| 91 | - | [[package]] | |
| 92 | - | name = "aws-lc-sys" | |
| 93 | - | version = "0.43.0" | |
| 94 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 95 | - | checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" | |
| 96 | - | dependencies = [ | |
| 97 | - | "cc", | |
| 98 | - | "cmake", | |
| 99 | - | "dunce", | |
| 100 | - | "fs_extra", | |
| 101 | - | "pkg-config", | |
| 102 | - | ] | |
| 103 | - | ||
| 104 | - | [[package]] | |
| 105 | - | name = "axum" | |
| 106 | - | version = "0.8.9" | |
| 107 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 108 | - | checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" | |
| 109 | - | dependencies = [ | |
| 110 | - | "axum-core", | |
| 111 | - | "axum-macros", | |
| 112 | - | "base64", | |
| 113 | - | "bytes", | |
| 114 | - | "form_urlencoded", | |
| 115 | - | "futures-util", | |
| 116 | - | "http", | |
| 117 | - | "http-body", | |
| 118 | - | "http-body-util", | |
| 119 | - | "hyper", | |
| 120 | - | "hyper-util", | |
| 121 | - | "itoa", | |
| 122 | - | "matchit", | |
| 123 | - | "memchr", | |
| 124 | - | "mime", | |
| 125 | - | "percent-encoding", | |
| 126 | - | "pin-project-lite", | |
| 127 | - | "serde_core", | |
| 128 | - | "serde_json", | |
| 129 | - | "serde_path_to_error", | |
| 130 | - | "serde_urlencoded", | |
| 131 | - | "sha1 0.10.6", | |
| 132 | - | "sync_wrapper", | |
| 133 | - | "tokio", | |
| 134 | - | "tokio-tungstenite 0.29.0", | |
| 135 | - | "tower", | |
| 136 | - | "tower-layer", | |
| 137 | - | "tower-service", | |
| 138 | - | "tracing", | |
| 139 | - | ] | |
| 140 | - | ||
| 141 | - | [[package]] | |
| 142 | - | name = "axum-core" | |
| 143 | - | version = "0.5.6" | |
| 144 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 145 | - | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" | |
| 146 | - | dependencies = [ | |
| 147 | - | "bytes", | |
| 148 | - | "futures-core", | |
| 149 | - | "http", | |
| 150 | - | "http-body", | |
| 151 | - | "http-body-util", | |
| 152 | - | "mime", | |
| 153 | - | "pin-project-lite", | |
| 154 | - | "sync_wrapper", | |
| 155 | - | "tower-layer", | |
| 156 | - | "tower-service", | |
| 157 | - | "tracing", | |
| 158 | - | ] | |
| 159 | - | ||
| 160 | - | [[package]] | |
| 161 | - | name = "axum-macros" | |
| 162 | - | version = "0.5.1" | |
| 163 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 164 | - | checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" | |
| 165 | - | dependencies = [ | |
| 166 | - | "proc-macro2", | |
| 167 | - | "quote", | |
| 168 | - | "syn", | |
| 169 | - | ] | |
| 170 | - | ||
| 171 | - | [[package]] | |
| 172 | - | name = "base64" | |
| 173 | - | version = "0.22.1" | |
| 174 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 175 | - | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 176 | - | ||
| 177 | - | [[package]] | |
| 178 | - | name = "bento-daemon" | |
| 179 | - | version = "0.1.0" | |
| 180 | - | dependencies = [ | |
| 181 | - | "anyhow", | |
| 182 | - | "axum", | |
| 183 | - | "chrono", | |
| 184 | - | "metrics", | |
| 185 | - | "metrics-exporter-prometheus", | |
| 186 | - | "ops-core", | |
| 187 | - | "ops-exec", | |
| 188 | - | "ops-status", | |
| 189 | - | "rhai", | |
| 190 | - | "semver", | |
| 191 | - | "serde", | |
| 192 | - | "serde_json", | |
| 193 | - | "sqlx", | |
| 194 | - | "thiserror", | |
| 195 | - | "tokio", | |
| 196 | - | "tokio-stream", | |
| 197 | - | "toml", | |
| 198 | - | "tracing", | |
| 199 | - | "tracing-subscriber", | |
| 200 | - | ] | |
| 201 | - | ||
| 202 | - | [[package]] | |
| 203 | - | name = "bento-tui" | |
| 204 | - | version = "0.1.0" | |
| 205 | - | dependencies = [ | |
| 206 | - | "anyhow", | |
| 207 | - | "bento-daemon", | |
| 208 | - | "chrono", | |
| 209 | - | "crossterm", | |
| 210 | - | "futures-util", | |
| 211 | - | "ratatui", | |
| 212 | - | "reqwest", | |
| 213 | - | "serde", | |
| 214 | - | "serde_json", | |
| 215 | - | "tokio", | |
| 216 | - | "tokio-tungstenite 0.30.0", | |
| 217 | - | ] | |
| 218 | - | ||
| 219 | - | [[package]] | |
| 220 | - | name = "bitflags" | |
| 221 | - | version = "2.13.0" | |
| 222 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 223 | - | checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" | |
| 224 | - | dependencies = [ | |
| 225 | - | "serde_core", | |
| 226 | - | ] | |
| 227 | - | ||
| 228 | - | [[package]] | |
| 229 | - | name = "block-buffer" | |
| 230 | - | version = "0.10.4" | |
| 231 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 232 | - | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 233 | - | dependencies = [ | |
| 234 | - | "generic-array", | |
| 235 | - | ] | |
| 236 | - | ||
| 237 | - | [[package]] | |
| 238 | - | name = "block-buffer" | |
| 239 | - | version = "0.12.1" | |
| 240 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 241 | - | checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" | |
| 242 | - | dependencies = [ | |
| 243 | - | "hybrid-array", | |
| 244 | - | ] | |
| 245 | - | ||
| 246 | - | [[package]] | |
| 247 | - | name = "bumpalo" | |
| 248 | - | version = "3.20.3" | |
| 249 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 250 | - | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 251 | - | ||
| 252 | - | [[package]] | |
| 253 | - | name = "byteorder" | |
| 254 | - | version = "1.5.0" | |
| 255 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 256 | - | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 257 | - | ||
| 258 | - | [[package]] | |
| 259 | - | name = "bytes" | |
| 260 | - | version = "1.11.1" | |
| 261 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 262 | - | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 263 | - | ||
| 264 | - | [[package]] | |
| 265 | - | name = "cassowary" | |
| 266 | - | version = "0.3.0" | |
| 267 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 268 | - | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" | |
| 269 | - | ||
| 270 | - | [[package]] | |
| 271 | - | name = "castaway" | |
| 272 | - | version = "0.2.4" | |
| 273 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 274 | - | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | |
| 275 | - | dependencies = [ | |
| 276 | - | "rustversion", | |
| 277 | - | ] | |
| 278 | - | ||
| 279 | - | [[package]] | |
| 280 | - | name = "cc" | |
| 281 | - | version = "1.2.64" | |
| 282 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | - | checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" | |
| 284 | - | dependencies = [ | |
| 285 | - | "find-msvc-tools", | |
| 286 | - | "jobserver", | |
| 287 | - | "libc", | |
| 288 | - | "shlex", | |
| 289 | - | ] | |
| 290 | - | ||
| 291 | - | [[package]] | |
| 292 | - | name = "cfg-if" | |
| 293 | - | version = "1.0.4" | |
| 294 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 295 | - | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 296 | - | ||
| 297 | - | [[package]] | |
| 298 | - | name = "cfg_aliases" | |
| 299 | - | version = "0.2.1" | |
| 300 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 301 | - | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | |
| 302 | - | ||
| 303 | - | [[package]] | |
| 304 | - | name = "chacha20" | |
| 305 | - | version = "0.10.1" | |
| 306 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 307 | - | checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" | |
| 308 | - | dependencies = [ | |
| 309 | - | "cfg-if", | |
| 310 | - | "cpufeatures 0.3.0", | |
| 311 | - | "rand_core 0.10.1", | |
| 312 | - | ] | |
| 313 | - | ||
| 314 | - | [[package]] | |
| 315 | - | name = "chrono" | |
| 316 | - | version = "0.4.45" | |
| 317 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 318 | - | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 319 | - | dependencies = [ | |
| 320 | - | "iana-time-zone", | |
| 321 | - | "js-sys", | |
| 322 | - | "num-traits", | |
| 323 | - | "serde", | |
| 324 | - | "wasm-bindgen", | |
| 325 | - | "windows-link", | |
| 326 | - | ] | |
| 327 | - | ||
| 328 | - | [[package]] | |
| 329 | - | name = "cmake" | |
| 330 | - | version = "0.1.58" | |
| 331 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 332 | - | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | |
| 333 | - | dependencies = [ | |
| 334 | - | "cc", | |
| 335 | - | ] | |
| 336 | - | ||
| 337 | - | [[package]] | |
| 338 | - | name = "cmov" | |
| 339 | - | version = "0.5.4" | |
| 340 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 341 | - | checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" | |
| 342 | - | ||
| 343 | - | [[package]] | |
| 344 | - | name = "combine" | |
| 345 | - | version = "4.6.7" | |
| 346 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 347 | - | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" | |
| 348 | - | dependencies = [ | |
| 349 | - | "bytes", | |
| 350 | - | "memchr", | |
| 351 | - | ] | |
| 352 | - | ||
| 353 | - | [[package]] | |
| 354 | - | name = "compact_str" | |
| 355 | - | version = "0.8.2" | |
| 356 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 357 | - | checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" | |
| 358 | - | dependencies = [ | |
| 359 | - | "castaway", | |
| 360 | - | "cfg-if", | |
| 361 | - | "itoa", | |
| 362 | - | "rustversion", | |
| 363 | - | "ryu", | |
| 364 | - | "static_assertions", | |
| 365 | - | ] | |
| 366 | - | ||
| 367 | - | [[package]] | |
| 368 | - | name = "concurrent-queue" | |
| 369 | - | version = "2.5.0" | |
| 370 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 371 | - | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 372 | - | dependencies = [ | |
| 373 | - | "crossbeam-utils", | |
| 374 | - | ] | |
| 375 | - | ||
| 376 | - | [[package]] | |
| 377 | - | name = "const-oid" | |
| 378 | - | version = "0.10.2" | |
| 379 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 380 | - | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 381 | - | ||
| 382 | - | [[package]] | |
| 383 | - | name = "const-random" | |
| 384 | - | version = "0.1.18" | |
| 385 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 386 | - | checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" | |
| 387 | - | dependencies = [ | |
| 388 | - | "const-random-macro", | |
| 389 | - | ] | |
| 390 | - | ||
| 391 | - | [[package]] | |
| 392 | - | name = "const-random-macro" | |
| 393 | - | version = "0.1.16" | |
| 394 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 395 | - | checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" | |
| 396 | - | dependencies = [ | |
| 397 | - | "getrandom 0.2.17", | |
| 398 | - | "once_cell", | |
| 399 | - | "tiny-keccak", | |
| 400 | - | ] | |
| 401 | - | ||
| 402 | - | [[package]] | |
| 403 | - | name = "core-foundation" | |
| 404 | - | version = "0.10.1" | |
| 405 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 406 | - | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 407 | - | dependencies = [ | |
| 408 | - | "core-foundation-sys", | |
| 409 | - | "libc", | |
| 410 | - | ] | |
| 411 | - | ||
| 412 | - | [[package]] | |
| 413 | - | name = "core-foundation-sys" | |
| 414 | - | version = "0.8.7" | |
| 415 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 416 | - | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 417 | - | ||
| 418 | - | [[package]] | |
| 419 | - | name = "cpufeatures" | |
| 420 | - | version = "0.2.17" | |
| 421 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 422 | - | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 423 | - | dependencies = [ | |
| 424 | - | "libc", | |
| 425 | - | ] | |
| 426 | - | ||
| 427 | - | [[package]] | |
| 428 | - | name = "cpufeatures" | |
| 429 | - | version = "0.3.0" | |
| 430 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 431 | - | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 432 | - | dependencies = [ | |
| 433 | - | "libc", | |
| 434 | - | ] | |
| 435 | - | ||
| 436 | - | [[package]] | |
| 437 | - | name = "crc" | |
| 438 | - | version = "3.4.0" | |
| 439 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 440 | - | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | |
| 441 | - | dependencies = [ | |
| 442 | - | "crc-catalog", | |
| 443 | - | ] | |
| 444 | - | ||
| 445 | - | [[package]] | |
| 446 | - | name = "crc-catalog" | |
| 447 | - | version = "2.5.0" | |
| 448 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 449 | - | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | |
| 450 | - | ||
| 451 | - | [[package]] | |
| 452 | - | name = "crossbeam-epoch" | |
| 453 | - | version = "0.9.18" | |
| 454 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 455 | - | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" | |
| 456 | - | dependencies = [ | |
| 457 | - | "crossbeam-utils", | |
| 458 | - | ] | |
| 459 | - | ||
| 460 | - | [[package]] | |
| 461 | - | name = "crossbeam-queue" | |
| 462 | - | version = "0.3.12" | |
| 463 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 464 | - | checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" | |
| 465 | - | dependencies = [ | |
| 466 | - | "crossbeam-utils", | |
| 467 | - | ] | |
| 468 | - | ||
| 469 | - | [[package]] | |
| 470 | - | name = "crossbeam-utils" | |
| 471 | - | version = "0.8.21" | |
| 472 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 473 | - | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | |
| 474 | - | ||
| 475 | - | [[package]] | |
| 476 | - | name = "crossterm" | |
| 477 | - | version = "0.28.1" | |
| 478 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 479 | - | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | |
| 480 | - | dependencies = [ | |
| 481 | - | "bitflags", | |
| 482 | - | "crossterm_winapi", | |
| 483 | - | "mio", | |
| 484 | - | "parking_lot", | |
| 485 | - | "rustix", | |
| 486 | - | "signal-hook", | |
| 487 | - | "signal-hook-mio", | |
| 488 | - | "winapi", | |
| 489 | - | ] | |
| 490 | - | ||
| 491 | - | [[package]] | |
| 492 | - | name = "crossterm_winapi" | |
| 493 | - | version = "0.9.1" | |
| 494 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 495 | - | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" | |
| 496 | - | dependencies = [ | |
| 497 | - | "winapi", | |
| 498 | - | ] | |
| 499 | - | ||
| 500 | - | [[package]] |
Lines truncated
| @@ -1,22 +0,0 @@ | |||
| 1 | - | [package] | |
| 2 | - | name = "bento-tui" | |
| 3 | - | version = "0.1.0" | |
| 4 | - | edition = "2024" | |
| 5 | - | license = "MIT" | |
| 6 | - | ||
| 7 | - | [[bin]] | |
| 8 | - | name = "bento" | |
| 9 | - | path = "src/main.rs" | |
| 10 | - | ||
| 11 | - | [dependencies] | |
| 12 | - | bento-daemon = { path = "../daemon" } | |
| 13 | - | ratatui = "0.29" | |
| 14 | - | crossterm = "0.28" | |
| 15 | - | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } | |
| 16 | - | tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } | |
| 17 | - | futures-util = { version = "0.3", default-features = false } | |
| 18 | - | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 19 | - | serde = { version = "1.0.228", features = ["derive"] } | |
| 20 | - | serde_json = "1" | |
| 21 | - | anyhow = "1.0.102" | |
| 22 | - | chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } |
| @@ -1,842 +0,0 @@ | |||
| 1 | - | //! bento-tui — operator front-end for bentod. | |
| 2 | - | //! | |
| 3 | - | //! Layout (top to bottom): | |
| 4 | - | //! | |
| 5 | - | //! ┌ daemon ─────────────────────────────────────────────────────┐ | |
| 6 | - | //! │ bento -> http://...:7800 (ws ok) build: goingson 0.4.1 │ | |
| 7 | - | //! ├ matrix ── target x step (↑/↓ select target) ────────────────│ | |
| 8 | - | //! │ target chk pre bld sgn ntz stp vfy pkg pub col │ | |
| 9 | - | //! │>macos/aarch64 O O > . . . . . . . │ | |
| 10 | - | //! │ linux/x86_64 O O O - - O - - O O │ | |
| 11 | - | //! ├ events (WS /events) ────────────────────────────────────────│ | |
| 12 | - | //! │ 09:21:03 target_start linux/x86_64 │ | |
| 13 | - | //! ├ tail [42] linux/x86_64 build — in-flight ───────────────────│ | |
| 14 | - | //! │ Compiling goingson v0.4.1 │ | |
| 15 | - | //! ├ status / keys ──────────────────────────────────────────────│ | |
| 16 | - | //! │ [b] build [R] retry target [↑↓] select [[/]] tail [q] quit│ | |
| 17 | - | //! └─────────────────────────────────────────────────────────────┘ | |
| 18 | - | //! | |
| 19 | - | //! Design: `bento-overview` in the shared code wiki (`~/Code/_private/wiki/`). | |
| 20 | - | //! <!-- wiki: bento-overview --> | |
| 21 | - | ||
| 22 | - | use anyhow::{Context, Result}; | |
| 23 | - | use bento_daemon::domain::{Step, StepRunId}; | |
| 24 | - | use bento_daemon::events::{Event, EventEnvelope}; | |
| 25 | - | use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers}; | |
| 26 | - | use crossterm::terminal::{ | |
| 27 | - | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, | |
| 28 | - | }; | |
| 29 | - | use futures_util::StreamExt; | |
| 30 | - | use ratatui::prelude::*; | |
| 31 | - | use ratatui::widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}; | |
| 32 | - | use serde::Deserialize; | |
| 33 | - | use std::collections::{BTreeMap, VecDeque}; | |
| 34 | - | use std::io; | |
| 35 | - | use std::sync::{Arc, Mutex}; | |
| 36 | - | use std::time::Duration; | |
| 37 | - | use tokio::sync::mpsc; | |
| 38 | - | ||
| 39 | - | // ---------- daemon types (subset of GET /state) ---------- | |
| 40 | - | ||
| 41 | - | #[derive(Clone, Debug, Deserialize)] | |
| 42 | - | struct StateView { | |
| 43 | - | build: Option<BuildView>, | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | #[derive(Clone, Debug, Deserialize)] | |
| 47 | - | struct BuildView { | |
| 48 | - | #[allow(dead_code)] | |
| 49 | - | id: i64, | |
| 50 | - | app: String, | |
| 51 | - | version: String, | |
| 52 | - | status: String, | |
| 53 | - | targets: Vec<TargetView>, | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | #[derive(Clone, Debug, Deserialize)] | |
| 57 | - | struct TargetView { | |
| 58 | - | target: String, | |
| 59 | - | status: String, | |
| 60 | - | #[allow(dead_code)] | |
| 61 | - | current_step: Option<String>, | |
| 62 | - | error: Option<String>, | |
| 63 | - | steps: Vec<StepView>, | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | #[derive(Clone, Debug, Deserialize)] | |
| 67 | - | struct StepView { | |
| 68 | - | #[allow(dead_code)] | |
| 69 | - | run_id: i64, | |
| 70 | - | step: String, | |
| 71 | - | status: String, | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | /// Short column headers for the matrix, in canonical step order. | |
| 75 | - | const STEP_COLS: [(Step, &str); 10] = [ | |
| 76 | - | (Step::Checkout, "chk"), | |
| 77 | - | (Step::Prebuild, "pre"), | |
| 78 | - | (Step::Build, "bld"), | |
| 79 | - | (Step::Sign, "sgn"), | |
| 80 | - | (Step::Notarize, "ntz"), | |
| 81 | - | (Step::Staple, "stp"), | |
| 82 | - | (Step::Verify, "vfy"), | |
| 83 | - | (Step::Package, "pkg"), | |
| 84 | - | (Step::Publish, "pub"), | |
| 85 | - | (Step::Collect, "col"), | |
| 86 | - | ]; | |
| 87 | - | ||
| 88 | - | // ---------- shared app state ---------- | |
| 89 | - | ||
| 90 | - | #[derive(Default)] | |
| 91 | - | struct Shared { | |
| 92 | - | state: Option<StateView>, | |
| 93 | - | last_err: Option<String>, | |
| 94 | - | events: VecDeque<String>, | |
| 95 | - | ws_ok: bool, | |
| 96 | - | selected: usize, | |
| 97 | - | notice: Option<String>, | |
| 98 | - | tails: BTreeMap<StepRunId, StepTail>, | |
| 99 | - | focus_run: Option<StepRunId>, | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | const EVENTS_CAP: usize = 200; | |
| 103 | - | const TAILS_CAP: usize = 10; | |
| 104 | - | const TAIL_LINES_CAP: usize = 200; | |
| 105 | - | ||
| 106 | - | /// Per-step live-tail buffer. Receives `StepLogChunk` text and presents it as a | |
| 107 | - | /// line-aware ring; chunks are not line-aligned at the transport, so a trailing | |
| 108 | - | /// partial line is buffered across chunks. | |
| 109 | - | struct StepTail { | |
| 110 | - | target: String, | |
| 111 | - | step: String, | |
| 112 | - | lines: VecDeque<String>, | |
| 113 | - | partial: String, | |
| 114 | - | status: TailStatus, | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 118 | - | enum TailStatus { | |
| 119 | - | InFlight, | |
| 120 | - | Finished(String), | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | impl StepTail { | |
| 124 | - | fn new(target: String, step: String) -> Self { | |
| 125 | - | Self { | |
| 126 | - | target, | |
| 127 | - | step, | |
| 128 | - | lines: VecDeque::new(), | |
| 129 | - | partial: String::new(), | |
| 130 | - | status: TailStatus::InFlight, | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | fn push_chunk(&mut self, text: &str) { | |
| 135 | - | let combined = std::mem::take(&mut self.partial) + text; | |
| 136 | - | let mut rest = combined.as_str(); | |
| 137 | - | while let Some(idx) = rest.find('\n') { | |
| 138 | - | let (line, after) = rest.split_at(idx); | |
| 139 | - | self.push_line(line.trim_end_matches('\r').to_string()); | |
| 140 | - | rest = &after[1..]; | |
| 141 | - | } | |
| 142 | - | self.partial = rest.to_string(); | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | fn push_line(&mut self, line: String) { | |
| 146 | - | if self.lines.len() >= TAIL_LINES_CAP { | |
| 147 | - | self.lines.pop_front(); | |
| 148 | - | } | |
| 149 | - | self.lines.push_back(line); | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | fn finalize(&mut self, status_word: String) { | |
| 153 | - | let trailing = std::mem::take(&mut self.partial); | |
| 154 | - | if !trailing.is_empty() { | |
| 155 | - | self.push_line(trailing); | |
| 156 | - | } | |
| 157 | - | self.status = TailStatus::Finished(status_word); | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | impl Shared { | |
| 162 | - | fn push_event(&mut self, line: String) { | |
| 163 | - | if self.events.len() >= EVENTS_CAP { | |
| 164 | - | self.events.pop_front(); | |
| 165 | - | } | |
| 166 | - | self.events.push_back(line); | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | fn open_tail(&mut self, run_id: StepRunId, target: String, step: String) { | |
| 170 | - | if self.tails.len() >= TAILS_CAP | |
| 171 | - | && let Some((&oldest, _)) = self.tails.iter().next() | |
| 172 | - | { | |
| 173 | - | self.tails.remove(&oldest); | |
| 174 | - | } | |
| 175 | - | self.tails.insert(run_id, StepTail::new(target, step)); | |
| 176 | - | self.focus_run = Some(run_id); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | fn push_tail_chunk(&mut self, run_id: StepRunId, text: &str) { | |
| 180 | - | if let Some(t) = self.tails.get_mut(&run_id) { | |
| 181 | - | t.push_chunk(text); | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | fn finalize_tail(&mut self, run_id: StepRunId, status_word: String) { | |
| 186 | - | if let Some(t) = self.tails.get_mut(&run_id) { | |
| 187 | - | t.finalize(status_word); | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | /// App to act on: the latest build's app, else the env default. | |
| 192 | - | fn app_name(&self, default_app: &str) -> String { | |
| 193 | - | self.state | |
| 194 | - | .as_ref() | |
| 195 | - | .and_then(|s| s.build.as_ref()) | |
| 196 | - | .map(|b| b.app.clone()) | |
| 197 | - | .unwrap_or_else(|| default_app.to_string()) | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | fn selected_target(&self) -> Option<String> { | |
| 201 | - | self.state | |
| 202 | - | .as_ref() | |
| 203 | - | .and_then(|s| s.build.as_ref()) | |
| 204 | - | .and_then(|b| b.targets.get(self.selected)) | |
| 205 | - | .map(|t| t.target.clone()) | |
| 206 | - | } | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // ---------- main ---------- | |
| 210 | - | ||
| 211 | - | /// Leave raw mode + the alternate screen and show the cursor. Best-effort: | |
| 212 | - | /// every step is attempted regardless of earlier failures, so a partially | |
| 213 | - | /// entered terminal is still restored as far as possible. | |
| 214 | - | fn restore_terminal() { | |
| 215 | - | let _ = disable_raw_mode(); | |
| 216 | - | let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | /// RAII guard that restores the terminal on drop — covering normal return, a | |
| 220 | - | /// `?` early-exit, AND unwinding from a panic (e.g. a poisoned `Mutex` lock). | |
| 221 | - | /// Without this, any panic between `enable_raw_mode` and the manual teardown | |
| 222 | - | /// left the operator's shell wedged in raw mode + alt screen until a blind | |
| 223 | - | /// `reset`. Pairs with the panic hook installed in `main`. | |
| 224 | - | struct TerminalGuard; | |
| 225 | - | ||
| 226 | - | impl Drop for TerminalGuard { | |
| 227 | - | fn drop(&mut self) { | |
| 228 | - | restore_terminal(); | |
| 229 | - | } | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | fn main() -> Result<()> { | |
| 233 | - | let daemon = std::env::var("BENTO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7800".into()); | |
| 234 | - | let default_app = std::env::var("BENTO_APP").unwrap_or_else(|_| "goingson".into()); | |
| 235 | - | let shared = Arc::new(Mutex::new(Shared::default())); | |
| 236 | - | ||
| 237 | - | let rt = tokio::runtime::Builder::new_multi_thread() | |
| 238 | - | .enable_all() | |
| 239 | - | .worker_threads(2) | |
| 240 | - | .build()?; | |
| 241 | - | ||
| 242 | - | let _g = rt.enter(); | |
| 243 | - | rt.spawn(state_poller(daemon.clone(), shared.clone())); | |
| 244 | - | rt.spawn(events_subscriber(daemon.clone(), shared.clone())); | |
| 245 | - | ||
| 246 | - | // Restore the terminal on panic BEFORE the default hook prints, so the | |
| 247 | - | // panic message + backtrace land on the normal screen rather than being | |
| 248 | - | // swallowed by the abandoned alternate screen. | |
| 249 | - | let default_hook = std::panic::take_hook(); | |
| 250 | - | std::panic::set_hook(Box::new(move |info| { | |
| 251 | - | restore_terminal(); | |
| 252 | - | default_hook(info); | |
| 253 | - | })); | |
| 254 | - | ||
| 255 | - | enable_raw_mode()?; | |
| 256 | - | let mut stdout = io::stdout(); | |
| 257 | - | crossterm::execute!(stdout, EnterAlternateScreen)?; | |
| 258 | - | let backend = CrosstermBackend::new(stdout); | |
| 259 | - | let mut term = Terminal::new(backend)?; | |
| 260 | - | // From here on the guard owns teardown (normal, `?`, or panic). | |
| 261 | - | let _guard = TerminalGuard; | |
| 262 | - | ||
| 263 | - | ui_loop(&mut term, &daemon, &default_app, &shared, rt.handle()) | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | // ---------- background tasks ---------- | |
| 267 | - | ||
| 268 | - | async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) { | |
| 269 | - | let url = format!("{daemon}/state"); | |
| 270 | - | let client = reqwest::Client::new(); | |
| 271 | - | loop { | |
| 272 | - | match client | |
| 273 | - | .get(&url) | |
| 274 | - | .timeout(Duration::from_secs(2)) | |
| 275 | - | .send() | |
| 276 | - | .await | |
| 277 | - | { | |
| 278 | - | Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await { | |
| 279 | - | Ok(s) => { | |
| 280 | - | let mut g = shared.lock().unwrap(); | |
| 281 | - | let n = s | |
| 282 | - | .build | |
| 283 | - | .as_ref() | |
| 284 | - | .map(|b| b.targets.len().saturating_sub(1)) | |
| 285 | - | .unwrap_or(0); | |
| 286 | - | if g.selected > n { | |
| 287 | - | g.selected = n; | |
| 288 | - | } | |
| 289 | - | g.state = Some(s); | |
| 290 | - | g.last_err = None; | |
| 291 | - | } | |
| 292 | - | Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")), | |
| 293 | - | }, | |
| 294 | - | Ok(resp) => shared.lock().unwrap().last_err = Some(format!("status {}", resp.status())), | |
| 295 | - | Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()), | |
| 296 | - | } | |
| 297 | - | tokio::time::sleep(Duration::from_secs(2)).await; | |
| 298 | - | } | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) { | |
| 302 | - | let ws_url = ws_url_from(&daemon); | |
| 303 | - | loop { | |
| 304 | - | match tokio_tungstenite::connect_async(&ws_url).await { | |
| 305 | - | Ok((mut socket, _resp)) => { | |
| 306 | - | shared.lock().unwrap().ws_ok = true; | |
| 307 | - | while let Some(msg) = socket.next().await { | |
| 308 | - | match msg { | |
| 309 | - | Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => { | |
| 310 | - | dispatch_ws_frame(&shared, &t) | |
| 311 | - | } | |
| 312 | - | Ok(tokio_tungstenite::tungstenite::Message::Close(_)) | Err(_) => break, | |
| 313 | - | _ => {} | |
| 314 | - | } | |
| 315 | - | } | |
| 316 | - | shared.lock().unwrap().ws_ok = false; | |
| 317 | - | } | |
| 318 | - | Err(_) => shared.lock().unwrap().ws_ok = false, | |
| 319 | - | } | |
| 320 | - | tokio::time::sleep(Duration::from_secs(3)).await; | |
| 321 | - | } | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | fn ws_url_from(daemon: &str) -> String { | |
| 325 | - | daemon | |
| 326 | - | .replacen("https://", "wss://", 1) | |
| 327 | - | .replacen("http://", "ws://", 1) | |
| 328 | - | + "/events" | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | /// Route a WS frame: log chunks update the per-step tail; everything else | |
| 332 | - | /// becomes one line in the events ring. (Chunks never hit the ring — a busy | |
| 333 | - | /// build emits hundreds per second and would evict every other event.) | |
| 334 | - | fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) { | |
| 335 | - | if let Some(line) = format_lagged(raw) { | |
| 336 | - | shared.lock().unwrap().push_event(line); | |
| 337 | - | return; | |
| 338 | - | } | |
| 339 | - | let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else { | |
| 340 | - | shared.lock().unwrap().push_event(raw.to_string()); | |
| 341 | - | return; | |
| 342 | - | }; | |
| 343 | - | match &env.event { | |
| 344 | - | Event::StepStart { | |
| 345 | - | run_id, | |
| 346 | - | target, | |
| 347 | - | step, | |
| 348 | - | .. | |
| 349 | - | } => { | |
| 350 | - | let mut g = shared.lock().unwrap(); | |
| 351 | - | g.open_tail(*run_id, target.to_string(), step.to_string()); | |
| 352 | - | let line = format_event_line(&env); | |
| 353 | - | g.push_event(line); | |
| 354 | - | } | |
| 355 | - | Event::StepLogChunk { run_id, text, .. } => { | |
| 356 | - | shared.lock().unwrap().push_tail_chunk(*run_id, text); | |
| 357 | - | } | |
| 358 | - | Event::StepDone { run_id, status, .. } => { | |
| 359 | - | let word = status.as_str().to_string(); | |
| 360 | - | let mut g = shared.lock().unwrap(); | |
| 361 | - | g.finalize_tail(*run_id, word); | |
| 362 | - | let line = format_event_line(&env); | |
| 363 | - | g.push_event(line); | |
| 364 | - | } | |
| 365 | - | _ => { | |
| 366 | - | let line = format_event_line(&env); | |
| 367 | - | shared.lock().unwrap().push_event(line); | |
| 368 | - | } | |
| 369 | - | } | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | fn format_event_line(env: &EventEnvelope) -> String { | |
| 373 | - | let time = env.at.format("%H:%M:%S").to_string(); | |
| 374 | - | format!("{time} {}", format_event_body(&env.event)) | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | fn format_event_body(e: &Event) -> String { | |
| 378 | - | match e { | |
| 379 | - | Event::BuildRequested { | |
| 380 | - | app, | |
| 381 | - | version, | |
| 382 | - | targets, | |
| 383 | - | } => { | |
| 384 | - | format!( | |
| 385 | - | "build_requested {app} {version} [{}]", | |
| 386 | - | targets | |
| 387 | - | .iter() | |
| 388 | - | .map(|t| t.to_string()) | |
| 389 | - | .collect::<Vec<_>>() | |
| 390 | - | .join(",") | |
| 391 | - | ) | |
| 392 | - | } | |
| 393 | - | Event::TargetAborted { target, .. } => format!("target_aborted {target}"), | |
| 394 | - | Event::TargetStart { target, .. } => format!("target_start {target}"), | |
| 395 | - | Event::StepStart { target, step, .. } => format!("step_start {target} {step}"), | |
| 396 | - | Event::StepLogChunk { run_id, text, .. } => { | |
| 397 | - | format!("log[{run_id}] {}", truncate(text.trim_end(), 80)) | |
| 398 | - | } | |
| 399 | - | Event::StepDone { | |
| 400 | - | target, | |
| 401 | - | step, | |
| 402 | - | status, | |
| 403 | - | .. | |
| 404 | - | } => format!("step_done {target} {step} {}", status.as_str()), | |
| 405 | - | Event::TargetOk { | |
| 406 | - | target, artifacts, .. | |
| 407 | - | } => format!("target_ok {target} ({} artifacts)", artifacts.len()), | |
| 408 | - | Event::TargetFailed { | |
| 409 | - | target, | |
| 410 | - | step, | |
| 411 | - | error, | |
| 412 | - | .. | |
| 413 | - | } => format!("target_failed {target} {step}: {}", truncate(error, 80)), | |
| 414 | - | Event::NotarizeRetry { | |
| 415 | - | target, | |
| 416 | - | attempt, | |
| 417 | - | reason, | |
| 418 | - | .. | |
| 419 | - | } => format!("notarize_retry {target} #{attempt} {reason}"), | |
| 420 | - | Event::ArtifactCollected { | |
| 421 | - | target, | |
| 422 | - | path, | |
| 423 | - | bytes, | |
| 424 | - | .. | |
| 425 | - | } => format!("artifact {target} {path} {bytes}B"), | |
| 426 | - | Event::PublishOk { | |
| 427 | - | target, channel, .. | |
| 428 | - | } => format!("publish_ok {target} {channel}"), | |
| 429 | - | Event::PublishFailed { | |
| 430 | - | target, | |
| 431 | - | channel, | |
| 432 | - | error, | |
| 433 | - | .. | |
| 434 | - | } => format!("publish_failed {target} {channel}: {}", truncate(error, 80)), | |
| 435 | - | } | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | fn format_lagged(raw: &str) -> Option<String> { | |
| 439 | - | let v: serde_json::Value = serde_json::from_str(raw).ok()?; | |
| 440 | - | if v.get("kind")?.as_str()? == "lagged" { | |
| 441 | - | Some(format!( | |
| 442 | - | "(lagged, skipped {})", | |
| 443 | - | v.get("skipped").and_then(|n| n.as_i64()).unwrap_or(0) | |
| 444 | - | )) | |
| 445 | - | } else { | |
| 446 | - | None | |
| 447 | - | } | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | // ---------- actions ---------- | |
| 451 | - | ||
| 452 | - | #[derive(Clone, Debug)] | |
| 453 | - | enum Action { | |
| 454 | - | Build { app: String }, | |
| 455 | - | Retry { app: String, target: String }, | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | impl std::fmt::Display for Action { | |
| 459 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 460 | - | match self { | |
| 461 | - | Action::Build { app } => write!(f, "build {app}"), | |
| 462 | - | Action::Retry { app, target } => write!(f, "retry {app}/{target}"), | |
| 463 | - | } | |
| 464 | - | } | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> { | |
| 468 | - | let client = reqwest::Client::new(); | |
| 469 | - | let (url, body) = match act { | |
| 470 | - | Action::Build { app } => (format!("{daemon}/build"), serde_json::json!({ "app": app })), | |
| 471 | - | Action::Retry { app, target } => ( | |
| 472 | - | format!("{daemon}/retry"), | |
| 473 | - | serde_json::json!({ "app": app, "target": target }), | |
| 474 | - | ), | |
| 475 | - | }; | |
| 476 | - | let mut req = client | |
| 477 | - | .post(&url) | |
| 478 | - | .timeout(Duration::from_secs(30)) | |
| 479 | - | .json(&body); | |
| 480 | - | // Build triggers require a bearer token when the daemon is configured with | |
| 481 | - | // one (CF2). Operator supplies it via BENTO_API_TOKEN. | |
| 482 | - | if let Ok(tok) = std::env::var("BENTO_API_TOKEN") { | |
| 483 | - | let tok = tok.trim(); | |
| 484 | - | if !tok.is_empty() { | |
| 485 | - | req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}")); | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | let resp = req.send().await.context("send")?; | |
| 489 | - | let status = resp.status(); | |
| 490 | - | // Distinguish a body-read failure from an empty body: the daemon's structured | |
| 491 | - | // error message is the operator's only signal on a rejected build, and silently | |
| 492 | - | // turning a read hiccup into "" renders as a contentless "HTTP 409: ". | |
| 493 | - | match (status.is_success(), resp.text().await) { | |
| 494 | - | (true, Ok(text)) => Ok(truncate(&text, 120)), | |
| 495 | - | (true, Err(e)) => Err(anyhow::anyhow!( | |
| 496 | - | "HTTP {status} but response body unreadable: {e}" | |
| 497 | - | )), | |
| 498 | - | (false, Ok(text)) => Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&text, 200))), | |
| 499 | - | (false, Err(e)) => Err(anyhow::anyhow!( | |
| 500 | - | "HTTP {status} (response body unreadable: {e})" |
Lines truncated
| @@ -117,7 +117,7 @@ dependencies = [ | |||
| 117 | 117 | "sha1 0.10.6", | |
| 118 | 118 | "sync_wrapper", | |
| 119 | 119 | "tokio", | |
| 120 | - | "tokio-tungstenite 0.29.0", | |
| 120 | + | "tokio-tungstenite", | |
| 121 | 121 | "tower", | |
| 122 | 122 | "tower-layer", | |
| 123 | 123 | "tower-service", | |
| @@ -206,21 +206,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 206 | 206 | checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" | |
| 207 | 207 | ||
| 208 | 208 | [[package]] | |
| 209 | - | name = "cassowary" | |
| 210 | - | version = "0.3.0" | |
| 211 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 212 | - | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" | |
| 213 | - | ||
| 214 | - | [[package]] | |
| 215 | - | name = "castaway" | |
| 216 | - | version = "0.2.4" | |
| 217 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 218 | - | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | |
| 219 | - | dependencies = [ | |
| 220 | - | "rustversion", | |
| 221 | - | ] | |
| 222 | - | ||
| 223 | - | [[package]] | |
| 224 | 209 | name = "cc" | |
| 225 | 210 | version = "1.2.65" | |
| 226 | 211 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -295,20 +280,6 @@ dependencies = [ | |||
| 295 | 280 | ] | |
| 296 | 281 | ||
| 297 | 282 | [[package]] | |
| 298 | - | name = "compact_str" | |
| 299 | - | version = "0.8.2" | |
| 300 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 301 | - | checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" | |
| 302 | - | dependencies = [ | |
| 303 | - | "castaway", | |
| 304 | - | "cfg-if", | |
| 305 | - | "itoa", | |
| 306 | - | "rustversion", | |
| 307 | - | "ryu", | |
| 308 | - | "static_assertions", | |
| 309 | - | ] | |
| 310 | - | ||
| 311 | - | [[package]] | |
| 312 | 283 | name = "concurrent-queue" | |
| 313 | 284 | version = "2.5.0" | |
| 314 | 285 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -318,12 +289,6 @@ dependencies = [ | |||
| 318 | 289 | ] | |
| 319 | 290 | ||
| 320 | 291 | [[package]] | |
| 321 | - | name = "const-oid" | |
| 322 | - | version = "0.10.2" | |
| 323 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 324 | - | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 325 | - | ||
| 326 | - | [[package]] | |
| 327 | 292 | name = "core-foundation" | |
| 328 | 293 | version = "0.10.1" | |
| 329 | 294 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -388,31 +353,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 388 | 353 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | |
| 389 | 354 | ||
| 390 | 355 | [[package]] | |
| 391 | - | name = "crossterm" | |
| 392 | - | version = "0.28.1" | |
| 393 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 394 | - | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" | |
| 395 | - | dependencies = [ | |
| 396 | - | "bitflags", | |
| 397 | - | "crossterm_winapi", | |
| 398 | - | "mio", | |
| 399 | - | "parking_lot", | |
| 400 | - | "rustix 0.38.44", | |
| 401 | - | "signal-hook", | |
| 402 | - | "signal-hook-mio", | |
| 403 | - | "winapi", | |
| 404 | - | ] | |
| 405 | - | ||
| 406 | - | [[package]] | |
| 407 | - | name = "crossterm_winapi" | |
| 408 | - | version = "0.9.1" | |
| 409 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 410 | - | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" | |
| 411 | - | dependencies = [ | |
| 412 | - | "winapi", | |
| 413 | - | ] | |
| 414 | - | ||
| 415 | - | [[package]] | |
| 416 | 356 | name = "crypto-common" | |
| 417 | 357 | version = "0.1.7" | |
| 418 | 358 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -441,40 +381,6 @@ dependencies = [ | |||
| 441 | 381 | ] | |
| 442 | 382 | ||
| 443 | 383 | [[package]] | |
| 444 | - | name = "darling" | |
| 445 | - | version = "0.23.0" | |
| 446 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 447 | - | checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" | |
| 448 | - | dependencies = [ | |
| 449 | - | "darling_core", | |
| 450 | - | "darling_macro", | |
| 451 | - | ] | |
| 452 | - | ||
| 453 | - | [[package]] | |
| 454 | - | name = "darling_core" | |
| 455 | - | version = "0.23.0" | |
| 456 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 457 | - | checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" | |
| 458 | - | dependencies = [ | |
| 459 | - | "ident_case", | |
| 460 | - | "proc-macro2", | |
| 461 | - | "quote", | |
| 462 | - | "strsim", | |
| 463 | - | "syn", | |
| 464 | - | ] | |
| 465 | - | ||
| 466 | - | [[package]] | |
| 467 | - | name = "darling_macro" | |
| 468 | - | version = "0.23.0" | |
| 469 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 470 | - | checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" | |
| 471 | - | dependencies = [ | |
| 472 | - | "darling_core", | |
| 473 | - | "quote", | |
| 474 | - | "syn", | |
| 475 | - | ] | |
| 476 | - | ||
| 477 | - | [[package]] | |
| 478 | 384 | name = "data-encoding" | |
| 479 | 385 | version = "2.11.0" | |
| 480 | 386 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -497,7 +403,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 497 | 403 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | |
| 498 | 404 | dependencies = [ | |
| 499 | 405 | "block-buffer 0.12.1", | |
| 500 | - | "const-oid", | |
| 501 | 406 | "crypto-common 0.2.2", | |
| 502 | 407 | "ctutils", | |
| 503 | 408 | ] | |
| @@ -596,12 +501,6 @@ dependencies = [ | |||
| 596 | 501 | ||
| 597 | 502 | [[package]] | |
| 598 | 503 | name = "foldhash" | |
| 599 | - | version = "0.1.5" | |
| 600 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 601 | - | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 602 | - | ||
| 603 | - | [[package]] | |
| 604 | - | name = "foldhash" | |
| 605 | 504 | version = "0.2.0" | |
| 606 | 505 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 607 | 506 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| @@ -749,24 +648,13 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" | |||
| 749 | 648 | ||
| 750 | 649 | [[package]] | |
| 751 | 650 | name = "hashbrown" | |
| 752 | - | version = "0.15.5" | |
| 753 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 754 | - | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| 755 | - | dependencies = [ | |
| 756 | - | "allocator-api2", | |
| 757 | - | "equivalent", | |
| 758 | - | "foldhash 0.1.5", | |
| 759 | - | ] | |
| 760 | - | ||
| 761 | - | [[package]] | |
| 762 | - | name = "hashbrown" | |
| 763 | 651 | version = "0.16.1" | |
| 764 | 652 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 765 | 653 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 766 | 654 | dependencies = [ | |
| 767 | 655 | "allocator-api2", | |
| 768 | 656 | "equivalent", | |
| 769 | - | "foldhash 0.2.0", | |
| 657 | + | "foldhash", | |
| 770 | 658 | ] | |
| 771 | 659 | ||
| 772 | 660 | [[package]] | |
| @@ -1034,12 +922,6 @@ dependencies = [ | |||
| 1034 | 922 | ] | |
| 1035 | 923 | ||
| 1036 | 924 | [[package]] | |
| 1037 | - | name = "ident_case" | |
| 1038 | - | version = "1.0.1" | |
| 1039 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1040 | - | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | |
| 1041 | - | ||
| 1042 | - | [[package]] | |
| 1043 | 925 | name = "idna" | |
| 1044 | 926 | version = "1.1.0" | |
| 1045 | 927 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1071,43 +953,12 @@ dependencies = [ | |||
| 1071 | 953 | ] | |
| 1072 | 954 | ||
| 1073 | 955 | [[package]] | |
| 1074 | - | name = "indoc" | |
| 1075 | - | version = "2.0.7" | |
| 1076 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1077 | - | checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" | |
| 1078 | - | dependencies = [ | |
| 1079 | - | "rustversion", | |
| 1080 | - | ] | |
| 1081 | - | ||
| 1082 | - | [[package]] | |
| 1083 | - | name = "instability" | |
| 1084 | - | version = "0.3.12" | |
| 1085 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1086 | - | checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" | |
| 1087 | - | dependencies = [ | |
| 1088 | - | "darling", | |
| 1089 | - | "indoc", | |
| 1090 | - | "proc-macro2", | |
| 1091 | - | "quote", | |
| 1092 | - | "syn", | |
| 1093 | - | ] | |
| 1094 | - | ||
| 1095 | - | [[package]] | |
| 1096 | 956 | name = "ipnet" | |
| 1097 | 957 | version = "2.12.0" | |
| 1098 | 958 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1099 | 959 | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" | |
| 1100 | 960 | ||
| 1101 | 961 | [[package]] | |
| 1102 | - | name = "itertools" | |
| 1103 | - | version = "0.13.0" | |
| 1104 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1105 | - | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" | |
| 1106 | - | dependencies = [ | |
| 1107 | - | "either", | |
| 1108 | - | ] | |
| 1109 | - | ||
| 1110 | - | [[package]] | |
| 1111 | 962 | name = "itoa" | |
| 1112 | 963 | version = "1.0.18" | |
| 1113 | 964 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1208,12 +1059,6 @@ dependencies = [ | |||
| 1208 | 1059 | ||
| 1209 | 1060 | [[package]] | |
| 1210 | 1061 | name = "linux-raw-sys" | |
| 1211 | - | version = "0.4.15" | |
| 1212 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1213 | - | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | |
| 1214 | - | ||
| 1215 | - | [[package]] | |
| 1216 | - | name = "linux-raw-sys" | |
| 1217 | 1062 | version = "0.12.1" | |
| 1218 | 1063 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1219 | 1064 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| @@ -1240,15 +1085,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1240 | 1085 | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 1241 | 1086 | ||
| 1242 | 1087 | [[package]] | |
| 1243 | - | name = "lru" | |
| 1244 | - | version = "0.12.5" | |
| 1245 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1246 | - | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" | |
| 1247 | - | dependencies = [ | |
| 1248 | - | "hashbrown 0.15.5", | |
| 1249 | - | ] | |
| 1250 | - | ||
| 1251 | - | [[package]] | |
| 1252 | 1088 | name = "lru-slab" | |
| 1253 | 1089 | version = "0.1.2" | |
| 1254 | 1090 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1298,7 +1134,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1298 | 1134 | checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" | |
| 1299 | 1135 | dependencies = [ | |
| 1300 | 1136 | "libc", | |
| 1301 | - | "log", | |
| 1302 | 1137 | "wasi", | |
| 1303 | 1138 | "windows-sys 0.61.2", | |
| 1304 | 1139 | ] | |
| @@ -1399,12 +1234,6 @@ dependencies = [ | |||
| 1399 | 1234 | ] | |
| 1400 | 1235 | ||
| 1401 | 1236 | [[package]] | |
| 1402 | - | name = "paste" | |
| 1403 | - | version = "1.0.15" | |
| 1404 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1405 | - | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | |
| 1406 | - | ||
| 1407 | - | [[package]] | |
| 1408 | 1237 | name = "percent-encoding" | |
| 1409 | 1238 | version = "2.3.2" | |
| 1410 | 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1573,27 +1402,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1573 | 1402 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 1574 | 1403 | ||
| 1575 | 1404 | [[package]] | |
| 1576 | - | name = "ratatui" | |
| 1577 | - | version = "0.29.0" | |
| 1578 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1579 | - | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" | |
| 1580 | - | dependencies = [ | |
| 1581 | - | "bitflags", | |
| 1582 | - | "cassowary", | |
| 1583 | - | "compact_str", | |
| 1584 | - | "crossterm", | |
| 1585 | - | "indoc", | |
| 1586 | - | "instability", | |
| 1587 | - | "itertools", | |
| 1588 | - | "lru", | |
| 1589 | - | "paste", | |
| 1590 | - | "strum", | |
| 1591 | - | "unicode-segmentation", | |
| 1592 | - | "unicode-truncate", | |
| 1593 | - | "unicode-width 0.2.0", | |
| 1594 | - | ] | |
| 1595 | - | ||
| 1596 | - | [[package]] | |
| 1597 | 1405 | name = "redox_syscall" | |
| 1598 | 1406 | version = "0.5.18" | |
| 1599 | 1407 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1687,19 +1495,6 @@ dependencies = [ | |||
| 1687 | 1495 | ||
| 1688 | 1496 | [[package]] | |
| 1689 | 1497 | name = "rustix" | |
| 1690 | - | version = "0.38.44" | |
| 1691 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1692 | - | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | |
| 1693 | - | dependencies = [ | |
| 1694 | - | "bitflags", | |
| 1695 | - | "errno", | |
| 1696 | - | "libc", | |
| 1697 | - | "linux-raw-sys 0.4.15", | |
| 1698 | - | "windows-sys 0.59.0", | |
| 1699 | - | ] | |
| 1700 | - | ||
| 1701 | - | [[package]] | |
| 1702 | - | name = "rustix" | |
| 1703 | 1498 | version = "1.1.4" | |
| 1704 | 1499 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1705 | 1500 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| @@ -1707,7 +1502,7 @@ dependencies = [ | |||
| 1707 | 1502 | "bitflags", | |
| 1708 | 1503 | "errno", | |
| 1709 | 1504 | "libc", | |
| 1710 | - | "linux-raw-sys 0.12.1", | |
| 1505 | + | "linux-raw-sys", | |
| 1711 | 1506 | "windows-sys 0.61.2", | |
| 1712 | 1507 | ] | |
| 1713 | 1508 | ||
| @@ -1834,23 +1629,6 @@ dependencies = [ | |||
| 1834 | 1629 | ] | |
| 1835 | 1630 | ||
| 1836 | 1631 | [[package]] | |
| 1837 | - | name = "sando-tui" | |
| 1838 | - | version = "0.2.2" | |
| 1839 | - | dependencies = [ | |
| 1840 | - | "anyhow", | |
| 1841 | - | "chrono", | |
| 1842 | - | "crossterm", | |
| 1843 | - | "futures-util", | |
| 1844 | - | "ratatui", | |
| 1845 | - | "reqwest", | |
| 1846 | - | "sando-daemon", | |
| 1847 | - | "serde", | |
| 1848 | - | "serde_json", | |
| 1849 | - | "tokio", | |
| 1850 | - | "tokio-tungstenite 0.30.0", | |
| 1851 | - | ] | |
| 1852 | - | ||
| 1853 | - | [[package]] | |
| 1854 | 1632 | name = "schannel" | |
| 1855 | 1633 | version = "0.1.29" | |
| 1856 | 1634 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2033,27 +1811,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2033 | 1811 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 2034 | 1812 | ||
| 2035 | 1813 | [[package]] | |
| 2036 | - | name = "signal-hook" | |
| 2037 | - | version = "0.3.18" | |
| 2038 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2039 | - | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" | |
| 2040 | - | dependencies = [ | |
| 2041 | - | "libc", | |
| 2042 | - | "signal-hook-registry", | |
| 2043 | - | ] | |
| 2044 | - | ||
| 2045 | - | [[package]] | |
| 2046 | - | name = "signal-hook-mio" | |
| 2047 | - | version = "0.2.5" | |
| 2048 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2049 | - | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" | |
| 2050 | - | dependencies = [ | |
| 2051 | - | "libc", | |
| 2052 | - | "mio", | |
| 2053 | - | "signal-hook", | |
| 2054 | - | ] | |
| 2055 | - | ||
| 2056 | - | [[package]] | |
| 2057 | 1814 | name = "signal-hook-registry" | |
| 2058 | 1815 | version = "1.4.8" | |
| 2059 | 1816 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2295,12 +2052,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2295 | 2052 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | |
| 2296 | 2053 | ||
| 2297 | 2054 | [[package]] | |
| 2298 | - | name = "static_assertions" | |
| 2299 | - | version = "1.1.0" | |
| 2300 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2301 | - | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | |
| 2302 | - | ||
| 2303 | - | [[package]] | |
| 2304 | 2055 | name = "stringprep" | |
| 2305 | 2056 | version = "0.1.5" | |
| 2306 | 2057 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2312,34 +2063,6 @@ dependencies = [ | |||
| 2312 | 2063 | ] | |
| 2313 | 2064 | ||
| 2314 | 2065 | [[package]] | |
| 2315 | - | name = "strsim" | |
| 2316 | - | version = "0.11.1" | |
| 2317 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2318 | - | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 2319 | - | ||
| 2320 | - | [[package]] | |
| 2321 | - | name = "strum" | |
| 2322 | - | version = "0.26.3" | |
| 2323 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2324 | - | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" | |
| 2325 | - | dependencies = [ | |
| 2326 | - | "strum_macros", | |
| 2327 | - | ] | |
| 2328 | - | ||
| 2329 | - | [[package]] | |
| 2330 | - | name = "strum_macros" | |
| 2331 | - | version = "0.26.4" | |
| 2332 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2333 | - | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" | |
| 2334 | - | dependencies = [ | |
| 2335 | - | "heck", | |
| 2336 | - | "proc-macro2", | |
| 2337 | - | "quote", | |
| 2338 | - | "rustversion", | |
| 2339 | - | "syn", | |
| 2340 | - | ] | |
| 2341 | - | ||
| 2342 | - | [[package]] | |
| 2343 | 2066 | name = "subtle" | |
| 2344 | 2067 | version = "2.6.1" | |
| 2345 | 2068 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2385,7 +2108,7 @@ dependencies = [ | |||
| 2385 | 2108 | "fastrand", | |
| 2386 | 2109 | "getrandom 0.4.3", | |
| 2387 | 2110 | "once_cell", | |
| 2388 | - | "rustix 1.1.4", | |
| 2111 | + | "rustix", | |
| 2389 | 2112 | "windows-sys 0.61.2", | |
| 2390 | 2113 | ] | |
| 2391 | 2114 | ||
| @@ -2500,23 +2223,7 @@ dependencies = [ | |||
| 2500 | 2223 | "futures-util", | |
| 2501 | 2224 | "log", | |
| 2502 | 2225 | "tokio", | |
| 2503 | - | "tungstenite 0.29.0", | |
| 2504 | - | ] | |
| 2505 | - | ||
| 2506 | - | [[package]] | |
| 2507 | - | name = "tokio-tungstenite" | |
| 2508 | - | version = "0.30.0" | |
| 2509 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2510 | - | checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" | |
| 2511 | - | dependencies = [ | |
| 2512 | - | "futures-util", | |
| 2513 | - | "log", | |
| 2514 | - | "rustls", | |
| 2515 | - | "rustls-pki-types", | |
| 2516 | - | "tokio", | |
| 2517 | - | "tokio-rustls", | |
| 2518 | - | "tungstenite 0.30.0", | |
| 2519 | - | "webpki-roots 0.26.11", | |
| 2226 | + | "tungstenite", | |
| 2520 | 2227 | ] | |
| 2521 | 2228 | ||
| 2522 | 2229 | [[package]] | |
| @@ -2702,24 +2409,6 @@ dependencies = [ | |||
| 2702 | 2409 | ] | |
| 2703 | 2410 | ||
| 2704 | 2411 | [[package]] | |
| 2705 | - | name = "tungstenite" | |
| 2706 | - | version = "0.30.0" | |
| 2707 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2708 | - | checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" | |
| 2709 | - | dependencies = [ | |
| 2710 | - | "bytes", | |
| 2711 | - | "data-encoding", | |
| 2712 | - | "http", | |
| 2713 | - | "httparse", | |
| 2714 | - | "log", | |
| 2715 | - | "rand 0.10.2", | |
| 2716 | - | "rustls", | |
| 2717 | - | "rustls-pki-types", | |
| 2718 | - | "sha1 0.11.0", | |
| 2719 | - | "thiserror", | |
| 2720 | - | ] | |
| 2721 | - | ||
| 2722 | - | [[package]] | |
| 2723 | 2412 | name = "typenum" | |
| 2724 | 2413 | version = "1.20.1" | |
| 2725 | 2414 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2753,35 +2442,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2753 | 2442 | checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" | |
| 2754 | 2443 | ||
| 2755 | 2444 | [[package]] | |
| 2756 | - | name = "unicode-segmentation" | |
| 2757 | - | version = "1.13.3" | |
| 2758 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2759 | - | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | |
| 2760 | - | ||
| 2761 | - | [[package]] | |
| 2762 | - | name = "unicode-truncate" | |
| 2763 | - | version = "1.1.0" | |
| 2764 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2765 | - | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" | |
| 2766 | - | dependencies = [ | |
| 2767 | - | "itertools", | |
| 2768 | - | "unicode-segmentation", | |
| 2769 | - | "unicode-width 0.1.14", |
Lines truncated
| @@ -1,8 +1,7 @@ | |||
| 1 | - | # Workspace root so `cargo build`/`cargo test` at sando/ covers BOTH crates. | |
| 2 | - | # The daemon and TUI share types (tui depends on sando-daemon by path); without | |
| 3 | - | # this gate a daemon enum-variant addition compiles clean while silently breaking | |
| 4 | - | # the TUI's exhaustive matches (ultra-fuzz Run 2, C1). Building both here makes | |
| 5 | - | # that a build error. | |
| 1 | + | # Workspace root so `cargo build`/`cargo test` at sando/ covers the daemon. | |
| 2 | + | # Kept as a workspace (rather than a flat crate) so future sando-side crates can | |
| 3 | + | # join without a restructure. The sando-tui member was retired 2026-07-22 when | |
| 4 | + | # the shared ops-viewer replaced it; see wiki [[release-viewer-overview]]. | |
| 6 | 5 | [workspace] | |
| 7 | 6 | resolver = "3" | |
| 8 | - | members = ["daemon", "tui"] | |
| 7 | + | members = ["daemon"] |
| @@ -1,22 +0,0 @@ | |||
| 1 | - | [package] | |
| 2 | - | name = "sando-tui" | |
| 3 | - | version = "0.2.2" | |
| 4 | - | edition = "2024" | |
| 5 | - | license = "MIT" | |
| 6 | - | ||
| 7 | - | [[bin]] | |
| 8 | - | name = "sando" | |
| 9 | - | path = "src/main.rs" | |
| 10 | - | ||
| 11 | - | [dependencies] | |
| 12 | - | sando-daemon = { path = "../daemon" } | |
| 13 | - | ratatui = "0.29" | |
| 14 | - | crossterm = "0.28" | |
| 15 | - | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } | |
| 16 | - | tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } | |
| 17 | - | futures-util = { version = "0.3", default-features = false } | |
| 18 | - | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 19 | - | serde = { version = "1.0.228", features = ["derive"] } | |
| 20 | - | serde_json = "1" | |
| 21 | - | anyhow = "1.0.102" | |
| 22 | - | chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } |
| @@ -1,20 +0,0 @@ | |||
| 1 | - | //! sando-tui actions module (split out of main.rs). | |
| 2 | - | ||
| 3 | - | #[derive(Clone, Debug)] | |
| 4 | - | pub(crate) enum Action { | |
| 5 | - | BackupFetch, | |
| 6 | - | Promote { tier: String }, | |
| 7 | - | Rollback { tier: String }, | |
| 8 | - | Confirm { tier: String }, | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | impl std::fmt::Display for Action { | |
| 12 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 13 | - | match self { | |
| 14 | - | Action::BackupFetch => write!(f, "backup/fetch"), | |
| 15 | - | Action::Promote { tier } => write!(f, "promote/{tier}"), | |
| 16 | - | Action::Rollback { tier } => write!(f, "rollback/{tier}"), | |
| 17 | - | Action::Confirm { tier } => write!(f, "confirm/{tier}"), | |
| 18 | - | } | |
| 19 | - | } | |
| 20 | - | } |
| @@ -1,241 +0,0 @@ | |||
| 1 | - | //! sando-tui format module (split out of main.rs). | |
| 2 | - | ||
| 3 | - | use crate::*; | |
| 4 | - | ||
| 5 | - | pub(crate) fn format_event_line(env: &EventEnvelope) -> String { | |
| 6 | - | let time = env.at.format("%H:%M:%S").to_string(); | |
| 7 | - | let kind = event_kind_str(&env.event); | |
| 8 | - | let body = format_event_body(&env.event); | |
| 9 | - | format!("{time} {kind} {body}") | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | /// Render one event envelope into a human-readable single line. | |
| 13 | - | /// | |
| 14 | - | /// Step 5: deserializes the daemon's `EventEnvelope` directly via the | |
| 15 | - | /// shared `sando_daemon::events` types. No more `serde_json::Value` | |
| 16 | - | /// reflection; the compiler enforces every field name. The `lagged` | |
| 17 | - | /// frame is a daemon-emitted ad-hoc envelope that doesn't match the | |
| 18 | - | /// `Event` enum (it's `{"kind":"lagged","skipped":N}`), so we handle it | |
| 19 | - | /// with a one-shot probe before the typed parse. | |
| 20 | - | #[cfg(test)] | |
| 21 | - | pub(crate) fn format_event(json: &str) -> Option<String> { | |
| 22 | - | if let Some(line) = format_lagged(json) { | |
| 23 | - | return Some(line); | |
| 24 | - | } | |
| 25 | - | let env: EventEnvelope = serde_json::from_str(json).ok()?; | |
| 26 | - | Some(format_event_line(&env)) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | pub(crate) fn format_lagged(json: &str) -> Option<String> { | |
| 30 | - | let v: serde_json::Value = serde_json::from_str(json).ok()?; | |
| 31 | - | if v.get("kind").and_then(|x| x.as_str()) != Some("lagged") { | |
| 32 | - | return None; | |
| 33 | - | } | |
| 34 | - | let at = v.get("at").and_then(|x| x.as_str()).unwrap_or(""); | |
| 35 | - | let time = at.get(11..19).unwrap_or(""); | |
| 36 | - | let skipped = v | |
| 37 | - | .get("skipped") | |
| 38 | - | .and_then(|x| x.as_u64()) | |
| 39 | - | .map(|n| n.to_string()) | |
| 40 | - | .unwrap_or_else(|| "?".into()); | |
| 41 | - | Some(format!("{time} lagged (skipped {skipped} events)")) | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | pub(crate) fn event_kind_str(e: &Event) -> &'static str { | |
| 45 | - | match e { | |
| 46 | - | Event::RebuildRequested { .. } => "rebuild_requested", | |
| 47 | - | Event::BuildAborted { .. } => "build_aborted", | |
| 48 | - | Event::BuildStart { .. } => "build_start", | |
| 49 | - | Event::BuildOk { .. } => "build_ok", | |
| 50 | - | Event::BuildFailed { .. } => "build_failed", | |
| 51 | - | Event::GateStart { .. } => "gate_start", | |
| 52 | - | Event::GateLogChunk { .. } => "gate_log", | |
| 53 | - | Event::GateDone { .. } => "gate_done", | |
| 54 | - | Event::DeployStart { .. } => "deploy_start", | |
| 55 | - | Event::DeployOk { .. } => "deploy_ok", | |
| 56 | - | Event::DeployFailed { .. } => "deploy_failed", | |
| 57 | - | Event::PromoteComplete { .. } => "promote_complete", | |
| 58 | - | Event::Rollback { .. } => "rollback", | |
| 59 | - | Event::BackupFetched { .. } => "backup_fetched", | |
| 60 | - | Event::ManualConfirm { .. } => "manual_confirm", | |
| 61 | - | } | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | pub(crate) fn format_event_body(e: &Event) -> String { | |
| 65 | - | match e { | |
| 66 | - | Event::RebuildRequested { sha } => format!("sha={}", sha.short()), | |
| 67 | - | Event::BuildAborted { sha_aborted } => format!("aborted prev sha={}", sha_aborted.short()), | |
| 68 | - | Event::BuildStart { sha, version } => format!("v={version} sha={}", sha.short()), | |
| 69 | - | Event::BuildOk { | |
| 70 | - | version, elapsed_s, .. | |
| 71 | - | } => format!("v={version} ({elapsed_s}s)"), | |
| 72 | - | Event::BuildFailed { | |
| 73 | - | version, elapsed_s, .. | |
| 74 | - | } => format!("v={version} FAILED ({elapsed_s}s)"), | |
| 75 | - | Event::GateStart { | |
| 76 | - | run_id, | |
| 77 | - | tier, | |
| 78 | - | version, | |
| 79 | - | gate, | |
| 80 | - | } => format!("[{run_id}] {tier}/{version} {gate} start"), | |
| 81 | - | Event::GateLogChunk { run_id, seq, text } => { | |
| 82 | - | // Single-line summary used by `format_event` (test path only); | |
| 83 | - | // production rendering routes chunks through `dispatch_ws_frame` | |
| 84 | - | // into the per-run tail buffer, never the events ring. | |
| 85 | - | let one = text.lines().next().unwrap_or("").trim_end(); | |
| 86 | - | let truncated: String = one.chars().take(160).collect(); | |
| 87 | - | format!("[{run_id} #{seq}] {truncated}") | |
| 88 | - | } | |
| 89 | - | Event::GateDone { | |
| 90 | - | run_id, | |
| 91 | - | tier, | |
| 92 | - | version, | |
| 93 | - | gate, | |
| 94 | - | outcome, | |
| 95 | - | .. | |
| 96 | - | } => { | |
| 97 | - | let summary = match &outcome.status { | |
| 98 | - | GateStatus::Passed { note } => format!("ok ({})", pass_note_short(note)), | |
| 99 | - | GateStatus::Failed { failure } => format!("FAIL ({})", failure_short(failure)), | |
| 100 | - | GateStatus::Blocked { blocker } => format!("blocked ({})", blocker_short(blocker)), | |
| 101 | - | }; | |
| 102 | - | format!("[{run_id}] {tier}/{version} {gate} {summary}") | |
| 103 | - | } | |
| 104 | - | Event::DeployStart { | |
| 105 | - | tier, | |
| 106 | - | node, | |
| 107 | - | version, | |
| 108 | - | } => format!("{tier} -> {node} v={version}"), | |
| 109 | - | Event::DeployOk { | |
| 110 | - | tier, | |
| 111 | - | node, | |
| 112 | - | version, | |
| 113 | - | } => format!("{tier} -> {node} v={version} ok"), | |
| 114 | - | Event::DeployFailed { | |
| 115 | - | tier, | |
| 116 | - | node, | |
| 117 | - | version, | |
| 118 | - | failure, | |
| 119 | - | } => format!( | |
| 120 | - | "{tier} -> {node} v={version} FAILED: {}", | |
| 121 | - | deploy_failure_short(failure) | |
| 122 | - | ), | |
| 123 | - | Event::PromoteComplete { tier, version } => format!("{tier} -> v={version} complete"), | |
| 124 | - | Event::Rollback { tier, from, to } => format!("{tier} {from} -> {to}"), | |
| 125 | - | Event::BackupFetched { source, byte_size } => { | |
| 126 | - | format!("backup {byte_size} bytes from {source}") | |
| 127 | - | } | |
| 128 | - | Event::ManualConfirm { tier, version } => format!("{tier} v={version} confirmed"), | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | pub(crate) fn pass_note_short(n: &PassNote) -> String { | |
| 133 | - | match n { | |
| 134 | - | PassNote::HealthyProbe { after_ms } => format!("/health {after_ms}ms"), | |
| 135 | - | PassNote::BurnInElapsed { hours } => format!("{hours}h elapsed"), | |
| 136 | - | PassNote::Migrated { backup_path } => { | |
| 137 | - | // Show just the basename; full path lives in /state if needed. | |
| 138 | - | let name = backup_path.rsplit('/').next().unwrap_or(backup_path); | |
| 139 | - | format!("migrated {name}") | |
| 140 | - | } | |
| 141 | - | PassNote::TestsPassed { duration_s } => format!("{duration_s}s"), | |
| 142 | - | PassNote::OperatorConfirmed { .. } => "operator".into(), | |
| 143 | - | PassNote::NodesHealthy { nodes } => format!("{nodes} node(s) healthy"), | |
| 144 | - | PassNote::Legacy { text } => text.clone(), | |
| 145 | - | } | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | pub(crate) fn failure_short(f: &GateFailure) -> String { | |
| 149 | - | match f { | |
| 150 | - | GateFailure::CargoTest { | |
| 151 | - | failed_count, | |
| 152 | - | first_panic: Some(p), | |
| 153 | - | .. | |
| 154 | - | } => format!("{failed_count} test(s); panic: {p}"), | |
| 155 | - | GateFailure::CargoTest { | |
| 156 | - | failed_count, | |
| 157 | - | first_failed: Some(name), | |
| 158 | - | .. | |
| 159 | - | } => format!("{failed_count} test(s); first {name}"), | |
| 160 | - | GateFailure::CargoTest { failed_count, .. } => format!("{failed_count} test(s) failed"), | |
| 161 | - | GateFailure::CompileError { | |
| 162 | - | error_count, | |
| 163 | - | first_error: Some(e), | |
| 164 | - | } => format!("compile failed ({error_count}); {e}"), | |
| 165 | - | GateFailure::CompileError { error_count, .. } => format!("compile failed ({error_count})"), | |
| 166 | - | GateFailure::MigrationDrift { migration } => format!("drift {migration}"), | |
| 167 | - | GateFailure::MigrationModified { migration } => format!("modified {migration}"), | |
| 168 | - | GateFailure::MigrationSqlError { | |
| 169 | - | migration, | |
| 170 | - | sqlstate: Some(s), | |
| 171 | - | } => format!("sql {migration} {s}"), | |
| 172 | - | GateFailure::MigrationSqlError { migration, .. } => format!("sql {migration}"), | |
| 173 | - | GateFailure::RestoreFailed { reason } => format!("restore: {reason}"), | |
| 174 | - | GateFailure::BootPanic { exit_code: Some(c) } => format!("panic exit {c}"), | |
| 175 | - | GateFailure::BootPanic { .. } => "panic".into(), | |
| 176 | - | GateFailure::BootExitedEarly { exit_code: Some(c) } => format!("exited {c}"), | |
| 177 | - | GateFailure::BootExitedEarly { .. } => "exited early".into(), | |
| 178 | - | GateFailure::BootHealthProbeFailed { last_error } => { | |
| 179 | - | format!( | |
| 180 | - | "no /health: {}", | |
| 181 | - | last_error.chars().take(40).collect::<String>() | |
| 182 | - | ) | |
| 183 | - | } | |
| 184 | - | GateFailure::NodeUnhealthy { node, detail } => { | |
| 185 | - | format!( | |
| 186 | - | "{node} unhealthy: {}", | |
| 187 | - | detail.chars().take(40).collect::<String>() | |
| 188 | - | ) | |
| 189 | - | } | |
| 190 | - | GateFailure::CodeSmokeSetup { reason } => { | |
| 191 | - | format!("db setup: {}", reason.chars().take(40).collect::<String>()) | |
| 192 | - | } | |
| 193 | - | GateFailure::CodeSmokeSeed { exit_code: Some(c) } => format!("migrate+seed exit {c}"), | |
| 194 | - | GateFailure::CodeSmokeSeed { .. } => "migrate+seed failed".into(), | |
| 195 | - | GateFailure::CodeSmokeNoListeningLog => "no 'listening' log".into(), | |
| 196 | - | GateFailure::SpawnFailed { message } => format!("spawn: {message}"), | |
| 197 | - | GateFailure::Timeout { gate, after_s } => format!("{gate} timeout {after_s}s"), | |
| 198 | - | GateFailure::Unclassified { | |
| 199 | - | legacy_detail: Some(d), | |
| 200 | - | } => { | |
| 201 | - | // Truncate aggressively — the events log is single-line. | |
| 202 | - | d.lines().next().unwrap_or("").chars().take(80).collect() | |
| 203 | - | } | |
| 204 | - | GateFailure::Unclassified { .. } => "unclassified".into(), | |
| 205 | - | } | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | pub(crate) fn deploy_failure_short(f: &DeployFailureKind) -> String { | |
| 209 | - | match f { | |
| 210 | - | DeployFailureKind::NodeUnreachable { .. } => "node unreachable".into(), | |
| 211 | - | DeployFailureKind::RsyncFailed { detail } => { | |
| 212 | - | // Show the rsync exit hint if present (e.g. "(28)" for ENOSPC). | |
| 213 | - | let first = detail.lines().next().unwrap_or("").trim_end(); | |
| 214 | - | format!("rsync: {}", first.chars().take(80).collect::<String>()) | |
| 215 | - | } | |
| 216 | - | DeployFailureKind::SymlinkSwapFailed { .. } => "symlink swap".into(), | |
| 217 | - | DeployFailureKind::ServiceRestartFailed { .. } => "service restart".into(), | |
| 218 | - | DeployFailureKind::Unclassified { detail } => detail | |
| 219 | - | .lines() | |
| 220 | - | .next() | |
| 221 | - | .unwrap_or("") | |
| 222 | - | .chars() | |
| 223 | - | .take(80) | |
| 224 | - | .collect(), | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | pub(crate) fn blocker_short(b: &GateBlocker) -> String { | |
| 229 | - | match b { | |
| 230 | - | GateBlocker::BurnInClockNotStarted => "burn-in clock not started".into(), | |
| 231 | - | GateBlocker::BurnInRemaining { | |
| 232 | - | hours_remaining, | |
| 233 | - | hours_total, | |
| 234 | - | } => format!("{hours_remaining}h of {hours_total}h remaining"), | |
| 235 | - | GateBlocker::AwaitingOperatorConfirmation => "needs operator".into(), | |
| 236 | - | GateBlocker::NoBackupAvailable => "no backup".into(), | |
| 237 | - | GateBlocker::ScratchDbUrlUnset => "scratch_db_url unset".into(), | |
| 238 | - | GateBlocker::ArtifactMissing { version } => format!("no artifact for {version}"), | |
| 239 | - | GateBlocker::NoNodesToProbe => "no nodes to probe".into(), | |
| 240 | - | } | |
| 241 | - | } |
| @@ -1,554 +0,0 @@ | |||
| 1 | - | //! sando-tui — operator front-end for sandod. | |
| 2 | - | //! | |
| 3 | - | //! Layout (top to bottom): | |
| 4 | - | //! | |
| 5 | - | //! ┌ daemon ─────────────────────────────────────────┐ | |
| 6 | - | //! │ sando -> http://...:7766 (ws: connected) │ | |
| 7 | - | //! ├ tiers ───────────────────── (arrows to select) │ | |
| 8 | - | //! │ tier prov current previous burn-in gates │ | |
| 9 | - | //! │ mm yes 0.8.12 - - ... │ | |
| 10 | - | //! │ a >yes< 0.8.12 - 02:34Z ... │ ← highlighted | |
| 11 | - | //! ├ events (WS /events) ────────────────────────────│ | |
| 12 | - | //! │ 02:55:39 backup_fetched 36MB │ | |
| 13 | - | //! │ 02:55:40 manual_confirm tier=a v=0.8.12 │ | |
| 14 | - | //! ├ tail [17] a/0.9.6 cargo_test — in-flight ───────│ | |
| 15 | - | //! │ running 1 test │ | |
| 16 | - | //! │ test routes::tests::ok ... ok │ | |
| 17 | - | //! ├ status / keys ──────────────────────────────────│ | |
| 18 | - | //! │ [p] promote [R] rollback [b] backup [c] confirm [[/]] tail [r] refresh [q] quit │ | |
| 19 | - | //! └─────────────────────────────────────────────────┘ | |
| 20 | - | //! | |
| 21 | - | //! State is refreshed every 2s by a background task. Events arrive in real | |
| 22 | - | //! time via WS. Actions are issued as background HTTP POSTs; their result | |
| 23 | - | //! shows up in the events log a moment later. | |
| 24 | - | ||
| 25 | - | use anyhow::{Context, Result}; | |
| 26 | - | use crossterm::event::{self, Event as XEvent, KeyCode, KeyModifiers}; | |
| 27 | - | use crossterm::terminal::{ | |
| 28 | - | EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, | |
| 29 | - | }; | |
| 30 | - | use futures_util::StreamExt; | |
| 31 | - | use ratatui::prelude::*; | |
| 32 | - | use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Row, Table}; | |
| 33 | - | use sando_daemon::domain::{GateKind, GateRunId, TierId, Version}; | |
| 34 | - | use sando_daemon::events::{Event, EventEnvelope}; | |
| 35 | - | use sando_daemon::outcome::{DeployFailureKind, GateBlocker, GateFailure, GateStatus, PassNote}; | |
| 36 | - | use serde::Deserialize; | |
| 37 | - | use std::collections::{BTreeMap, VecDeque}; | |
| 38 | - | use std::io; | |
| 39 | - | use std::sync::{Arc, Mutex}; | |
| 40 | - | use std::time::Duration; | |
| 41 | - | use tokio::sync::mpsc; | |
| 42 | - | ||
| 43 | - | mod actions; | |
| 44 | - | mod format; | |
| 45 | - | mod model; | |
| 46 | - | mod net; | |
| 47 | - | mod ui; | |
| 48 | - | ||
| 49 | - | pub(crate) use actions::*; | |
| 50 | - | pub(crate) use format::*; | |
| 51 | - | pub(crate) use model::*; | |
| 52 | - | pub(crate) use net::*; | |
| 53 | - | pub(crate) use ui::*; | |
| 54 | - | ||
| 55 | - | // ---------- daemon types ---------- | |
| 56 | - | // | |
| 57 | - | // `StateView` mirrors the JSON shape of `GET /state` from | |
| 58 | - | // `sando_daemon::routes`. The TUI deserializes a subset (only what it | |
| 59 | - | // renders) — adding new fields on the daemon side is a non-breaking | |
| 60 | - | // change as long as the field names stay stable. | |
| 61 | - | ||
| 62 | - | // ---------- app state shared between tasks ---------- | |
| 63 | - | ||
| 64 | - | // ---------- main ---------- | |
| 65 | - | ||
| 66 | - | /// Leave raw mode + the alternate screen and show the cursor. Best-effort: | |
| 67 | - | /// every step is attempted regardless of earlier failures, so a partially | |
| 68 | - | /// entered terminal is still restored as far as possible. | |
| 69 | - | fn restore_terminal() { | |
| 70 | - | let _ = disable_raw_mode(); | |
| 71 | - | let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | /// RAII guard that restores the terminal on drop — covering normal return, a | |
| 75 | - | /// `?` early-exit, AND unwinding from a panic (e.g. a poisoned `Mutex` lock). | |
| 76 | - | /// Without this, any panic between `enable_raw_mode` and the manual teardown | |
| 77 | - | /// left the operator's shell wedged in raw mode + alt screen until a blind | |
| 78 | - | /// `reset`. Pairs with the panic hook installed in `main`. | |
| 79 | - | struct TerminalGuard; | |
| 80 | - | ||
| 81 | - | impl Drop for TerminalGuard { | |
| 82 | - | fn drop(&mut self) { | |
| 83 | - | restore_terminal(); | |
| 84 | - | } | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | fn main() -> Result<()> { | |
| 88 | - | let daemon = std::env::var("SANDO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7766".into()); | |
| 89 | - | // The daemon now bearer-gates reads too (not just mutators), so the polling | |
| 90 | - | // and event-stream tasks must present the token. Same value the operator | |
| 91 | - | // already exports for POSTs; absent on a loopback/dev daemon, where reads | |
| 92 | - | // stay open. | |
| 93 | - | let token = std::env::var("SANDO_API_TOKEN") | |
| 94 | - | .ok() | |
| 95 | - | .map(|s| s.trim().to_string()) | |
| 96 | - | .filter(|s| !s.is_empty()); | |
| 97 | - | let shared = Arc::new(Mutex::new(Shared::default())); | |
| 98 | - | ||
| 99 | - | let rt = tokio::runtime::Builder::new_multi_thread() | |
| 100 | - | .enable_all() | |
| 101 | - | .worker_threads(2) | |
| 102 | - | .build()?; | |
| 103 | - | ||
| 104 | - | // Background pollers + WS subscriber. | |
| 105 | - | let _g = rt.enter(); | |
| 106 | - | rt.spawn(state_poller(daemon.clone(), token.clone(), shared.clone())); | |
| 107 | - | rt.spawn(events_subscriber( | |
| 108 | - | daemon.clone(), | |
| 109 | - | token.clone(), | |
| 110 | - | shared.clone(), | |
| 111 | - | )); | |
| 112 | - | ||
| 113 | - | // Restore the terminal on panic BEFORE the default hook prints, so the | |
| 114 | - | // panic message + backtrace land on the normal screen rather than being | |
| 115 | - | // swallowed by the abandoned alternate screen. | |
| 116 | - | let default_hook = std::panic::take_hook(); | |
| 117 | - | std::panic::set_hook(Box::new(move |info| { | |
| 118 | - | restore_terminal(); | |
| 119 | - | default_hook(info); | |
| 120 | - | })); | |
| 121 | - | ||
| 122 | - | enable_raw_mode()?; | |
| 123 | - | let mut stdout = io::stdout(); | |
| 124 | - | crossterm::execute!(stdout, EnterAlternateScreen)?; | |
| 125 | - | let backend = CrosstermBackend::new(stdout); | |
| 126 | - | let mut term = Terminal::new(backend)?; | |
| 127 | - | // From here on the guard owns teardown (normal, `?`, or panic). | |
| 128 | - | let _guard = TerminalGuard; | |
| 129 | - | ||
| 130 | - | ui_loop(&mut term, &daemon, &shared, rt.handle()) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | // ---------- background tasks ---------- | |
| 134 | - | ||
| 135 | - | // ---------- UI loop ---------- | |
| 136 | - | ||
| 137 | - | // ---------- actions ---------- | |
| 138 | - | ||
| 139 | - | // ---------- render ---------- | |
| 140 | - | ||
| 141 | - | // ---------- tests ---------- | |
| 142 | - | ||
| 143 | - | #[cfg(test)] | |
| 144 | - | mod tests { | |
| 145 | - | use super::*; | |
| 146 | - | ||
| 147 | - | /// tungstenite 0.30 carries text frames as `Utf8Bytes` rather than the | |
| 148 | - | /// `String` of 0.24. The subscriber loop hands that payload straight to | |
| 149 | - | /// `dispatch_ws_frame`, which takes `&str`, so this pins that the frame | |
| 150 | - | /// still reaches the dispatcher intact. | |
| 151 | - | #[test] | |
| 152 | - | fn text_frame_payload_reaches_the_dispatcher() { | |
| 153 | - | let shared = Arc::new(Mutex::new(model::Shared::default())); | |
| 154 | - | let raw = r#"{"event":{"type":"not_a_real_event"}}"#; | |
| 155 | - | let msg = tokio_tungstenite::tungstenite::Message::Text(raw.into()); | |
| 156 | - | ||
| 157 | - | // Mirror the match arm in net::events_subscriber. | |
| 158 | - | match msg { | |
| 159 | - | tokio_tungstenite::tungstenite::Message::Text(t) => net::dispatch_ws_frame(&shared, &t), | |
| 160 | - | other => panic!("expected a text frame, got {other:?}"), | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // An envelope this TUI cannot parse is surfaced verbatim, so the | |
| 164 | - | // exact bytes we sent should come back out of the events ring. | |
| 165 | - | let guard = shared.lock().unwrap(); | |
| 166 | - | assert_eq!(guard.events.back().map(String::as_str), Some(raw)); | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | #[test] | |
| 170 | - | fn ws_url_from_http() { | |
| 171 | - | assert_eq!( | |
| 172 | - | ws_url_from("http://127.0.0.1:7766"), | |
| 173 | - | "ws://127.0.0.1:7766/events" | |
| 174 | - | ); | |
| 175 | - | assert_eq!(ws_url_from("http://pop-os:7766"), "ws://pop-os:7766/events"); | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | #[test] | |
| 179 | - | fn ws_url_from_https() { | |
| 180 | - | assert_eq!( | |
| 181 | - | ws_url_from("https://sando.example.com"), | |
| 182 | - | "wss://sando.example.com/events" | |
| 183 | - | ); | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | #[test] | |
| 187 | - | fn ws_url_from_only_replaces_scheme_once() { | |
| 188 | - | // A path or query containing the literal string `http://` should not be | |
| 189 | - | // double-rewritten. Edge case but cheap to guard. | |
| 190 | - | assert_eq!( | |
| 191 | - | ws_url_from("http://host/api?next=http://other"), | |
| 192 | - | "ws://host/api?next=http://other/events" | |
| 193 | - | ); | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | #[test] | |
| 197 | - | fn ws_url_from_unknown_scheme_passes_through() { | |
| 198 | - | // Bad input is left alone — `connect_async` will reject it later. | |
| 199 | - | let s = ws_url_from("garbage://host"); | |
| 200 | - | assert_eq!(s, "garbage://host/events"); | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | #[test] | |
| 204 | - | fn format_event_build_ok() { | |
| 205 | - | let json = r#"{"at":"2026-06-01T02:55:39.123Z","kind":"build_ok","sha":"3dc8dca7a120c4","version":"0.8.12","elapsed_s":132}"#; | |
| 206 | - | let out = format_event(json).unwrap(); | |
| 207 | - | assert!(out.starts_with("02:55:39 build_ok")); | |
| 208 | - | assert!(out.contains("v=0.8.12")); | |
| 209 | - | assert!(out.contains("(132s)")); | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | #[test] | |
| 213 | - | fn format_event_gate_done_passed_uses_pass_note() { | |
| 214 | - | // Step 5+: GateDone carries a typed `outcome` and `run_id`. | |
| 215 | - | let pass = format_event( | |
| 216 | - | r#"{"at":"2026-06-01T02:55:39Z","kind":"gate_done","run_id":17,"tier":"mm","version":"0.8.12","gate":"cargo_test","outcome":{"status":{"kind":"passed","note":{"kind":"tests_passed","duration_s":42}}}}"#, | |
| 217 | - | ) | |
| 218 | - | .unwrap(); | |
| 219 | - | assert!(pass.contains("[17]"), "got: {pass}"); | |
| 220 | - | assert!(pass.contains("mm/0.8.12 cargo_test ok"), "got: {pass}"); | |
| 221 | - | assert!(pass.contains("42s"), "got: {pass}"); | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | #[test] | |
| 225 | - | fn format_event_gate_done_failed_uses_failure_variant() { | |
| 226 | - | let fail = format_event( | |
| 227 | - | r#"{"at":"2026-06-01T02:55:39Z","kind":"gate_done","run_id":18,"tier":"mm","version":"0.8.12","gate":"cargo_test","outcome":{"status":{"kind":"failed","failure":{"kind":"cargo_test","failed_count":3,"first_failed":"foo::bar"}}}}"#, | |
| 228 | - | ) | |
| 229 | - | .unwrap(); | |
| 230 | - | assert!(fail.contains("FAIL"), "got: {fail}"); | |
| 231 | - | assert!(fail.contains("3 test(s)"), "got: {fail}"); | |
| 232 | - | assert!(fail.contains("foo::bar"), "got: {fail}"); | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | #[test] | |
| 236 | - | fn format_event_gate_done_blocked_is_not_fail() { | |
| 237 | - | // The whole point of the Blocked variant: an unstarted burn-in | |
| 238 | - | // clock is not a defect, so the TUI renders it distinctly from a | |
| 239 | - | // genuine failure. No "FAIL" string. | |
| 240 | - | let blocked = format_event( | |
| 241 | - | r#"{"at":"2026-06-01T02:55:39Z","kind":"gate_done","run_id":19,"tier":"a","version":"0.9.6","gate":"burn_in","outcome":{"status":{"kind":"blocked","blocker":{"kind":"burn_in_clock_not_started"}}}}"#, | |
| 242 | - | ) | |
| 243 | - | .unwrap(); | |
| 244 | - | assert!(blocked.contains("burn_in blocked"), "got: {blocked}"); | |
| 245 | - | assert!(!blocked.contains("FAIL"), "got: {blocked}"); | |
| 246 | - | assert!( | |
| 247 | - | blocked.contains("burn-in clock not started"), | |
| 248 | - | "got: {blocked}" | |
| 249 | - | ); | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | #[test] | |
| 253 | - | fn format_event_gate_log_chunk_renders_truncated_line() { | |
| 254 | - | let line = format_event( | |
| 255 | - | r#"{"at":"2026-06-01T02:55:39Z","kind":"gate_log_chunk","run_id":42,"seq":7,"text":" Compiling foo v0.1.0\n"}"#, | |
| 256 | - | ) | |
| 257 | - | .unwrap(); | |
| 258 | - | assert!(line.contains("[42 #7]"), "got: {line}"); | |
| 259 | - | assert!(line.contains("Compiling foo"), "got: {line}"); | |
| 260 | - | // Trailing newline stripped, no FAIL/ok decorations. | |
| 261 | - | assert!(!line.ends_with('\n'), "got: {line}"); | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | #[test] | |
| 265 | - | fn format_event_gate_log_chunk_truncates_long_input() { | |
| 266 | - | // 300-char line should be cut to 160 — single-line UX. | |
| 267 | - | let text = "x".repeat(300); | |
| 268 | - | let json = format!( | |
| 269 | - | r#"{{"at":"2026-06-01T02:55:39Z","kind":"gate_log_chunk","run_id":1,"seq":0,"text":"{text}"}}"# | |
| 270 | - | ); | |
| 271 | - | let line = format_event(&json).unwrap(); | |
| 272 | - | // Total line includes timestamp + kind + prefix + the truncated body. | |
| 273 | - | // The truncated body alone should be ≤ 160 chars of 'x'. | |
| 274 | - | let x_count = line.chars().filter(|c| *c == 'x').count(); | |
| 275 | - | assert!(x_count <= 160, "got {x_count} x's; full line: {line}"); | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | #[test] | |
| 279 | - | fn format_event_backup_fetched() { | |
| 280 | - | let out = format_event( | |
| 281 | - | r#"{"at":"2026-06-01T04:00:01Z","kind":"backup_fetched","source":"ssh://backup-puller@alpha-west-1:2200/latest.sql.gz","byte_size":36079398}"#, | |
| 282 | - | ) | |
| 283 | - | .unwrap(); | |
| 284 | - | assert!(out.starts_with("04:00:01 backup_fetched")); | |
| 285 | - | assert!(out.contains("36079398")); | |
| 286 | - | assert!(out.contains("ssh://")); | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | #[test] | |
| 290 | - | fn format_event_deploy_failed_uses_typed_failure() { | |
| 291 | - | // Step 7: DeployFailed carries a typed DeployFailureKind. | |
| 292 | - | let out = format_event( | |
| 293 | - | r#"{"at":"2026-06-01T02:55:39Z","kind":"deploy_failed","tier":"a","node":"testnot-1","version":"0.8.12","failure":{"kind":"rsync_failed","detail":"rsync: write failed: No space left on device (28)"}}"#, | |
| 294 | - | ) | |
| 295 | - | .unwrap(); | |
| 296 | - | assert!(out.contains("a -> testnot-1"), "got: {out}"); | |
| 297 | - | assert!(out.contains("FAILED"), "got: {out}"); | |
| 298 | - | assert!(out.contains("rsync"), "got: {out}"); | |
| 299 | - | assert!(out.contains("No space left"), "got: {out}"); | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | #[test] | |
| 303 | - | fn format_event_deploy_failed_node_unreachable_short_form() { | |
| 304 | - | // NodeUnreachable renders as a stable short label; the operator | |
| 305 | - | // doesn't need the full ssh chain in the events log. | |
| 306 | - | let out = format_event( | |
| 307 | - | r#"{"at":"2026-06-01T02:55:39Z","kind":"deploy_failed","tier":"a","node":"testnot-1","version":"0.8.12","failure":{"kind":"node_unreachable","detail":"Connection refused"}}"#, | |
| 308 | - | ) | |
| 309 | - | .unwrap(); | |
| 310 | - | assert!(out.contains("node unreachable"), "got: {out}"); | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | #[test] | |
| 314 | - | fn format_event_unknown_kind_returns_none() { | |
| 315 | - | // Step 5: typed parse rejects unknown kinds. The caller falls | |
| 316 | - | // back to the raw text (see events_subscriber's | |
| 317 | - | // `unwrap_or_else(|| t.to_string())`), so unknown events still | |
| 318 | - | // appear in the log; they just don't get the prettified line. | |
| 319 | - | let json = r#"{"at":"2026-06-01T02:55:39Z","kind":"new_thing","whatever":42}"#; | |
| 320 | - | assert_eq!(format_event(json), None); | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | #[test] | |
| 324 | - | fn format_event_malformed_returns_none() { | |
| 325 | - | // Not a JSON object → None; caller falls back to the raw text. | |
| 326 | - | assert_eq!(format_event("not json"), None); | |
| 327 | - | assert_eq!(format_event("[1,2,3]"), None); | |
| 328 | - | // Object without `kind` → None too. | |
| 329 | - | assert_eq!(format_event(r#"{"at":"2026-06-01T02:55:39Z"}"#), None); | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | #[test] | |
| 333 | - | fn action_display_strings() { | |
| 334 | - | // These show up verbatim in the events log; the daemon's gate parser | |
| 335 | - | // doesn't read them, but the operator does. Keep stable. | |
| 336 | - | assert_eq!(Action::BackupFetch.to_string(), "backup/fetch"); | |
| 337 | - | assert_eq!( | |
| 338 | - | Action::Promote { tier: "a".into() }.to_string(), | |
| 339 | - | "promote/a" | |
| 340 | - | ); | |
| 341 | - | assert_eq!( | |
| 342 | - | Action::Rollback { tier: "b".into() }.to_string(), | |
| 343 | - | "rollback/b" | |
| 344 | - | ); | |
| 345 | - | assert_eq!( | |
| 346 | - | Action::Confirm { tier: "mm".into() }.to_string(), | |
| 347 | - | "confirm/mm" | |
| 348 | - | ); | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | #[test] | |
| 352 | - | fn truncate_short_returns_same() { | |
| 353 | - | assert_eq!(truncate("hi", 10), "hi"); | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | #[test] | |
| 357 | - | fn truncate_long_appends_ellipsis() { | |
| 358 | - | let s = "a".repeat(200); | |
| 359 | - | let out = truncate(&s, 10); | |
| 360 | - | assert_eq!(out.chars().count(), 11); // 10 chars + ellipsis | |
| 361 | - | assert!(out.ends_with('…')); | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | #[test] | |
| 365 | - | fn centered_rect_centers_and_clamps() { | |
| 366 | - | let area = Rect { | |
| 367 | - | x: 0, | |
| 368 | - | y: 0, | |
| 369 | - | width: 100, | |
| 370 | - | height: 40, | |
| 371 | - | }; | |
| 372 | - | let r = centered_rect(54, 7, area); | |
| 373 | - | assert_eq!((r.width, r.height), (54, 7)); | |
| 374 | - | assert_eq!(r.x, 23); // (100-54)/2 | |
| 375 | - | assert_eq!(r.y, 16); // (40-7)/2 floored | |
| 376 | - | // Oversized request clamps to the available area, never overflows. | |
| 377 | - | let tiny = Rect { | |
| 378 | - | x: 5, | |
| 379 | - | y: 5, | |
| 380 | - | width: 20, | |
| 381 | - | height: 3, | |
| 382 | - | }; | |
| 383 | - | let c = centered_rect(54, 7, tiny); | |
| 384 | - | assert_eq!((c.width, c.height), (20, 3)); | |
| 385 | - | assert_eq!((c.x, c.y), (5, 5)); | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | #[test] | |
| 389 | - | fn shared_push_event_caps_at_capacity() { | |
| 390 | - | let mut sh = Shared::default(); | |
| 391 | - | for i in 0..(EVENTS_CAP + 50) { | |
| 392 | - | sh.push_event(format!("e{i}")); | |
| 393 | - | } | |
| 394 | - | assert_eq!(sh.events.len(), EVENTS_CAP); | |
| 395 | - | // Oldest 50 were dropped: front should be "e50". | |
| 396 | - | assert_eq!(sh.events.front().unwrap(), "e50"); | |
| 397 | - | assert_eq!(sh.events.back().unwrap(), &format!("e{}", EVENTS_CAP + 49)); | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | // ----- gate tail buffer (Phase C) ----- | |
| 401 | - | ||
| 402 | - | fn tail_fixture() -> GateTail { | |
| 403 | - | GateTail::new( | |
| 404 | - | TierId::new("a"), | |
| 405 | - | "0.9.6".parse().unwrap(), | |
| 406 | - | GateKind::CargoTest, | |
| 407 | - | ) | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | #[test] | |
| 411 | - | fn gate_tail_splits_chunks_on_newlines() { | |
| 412 | - | let mut t = tail_fixture(); | |
| 413 | - | t.push_chunk("one\ntwo\n"); | |
| 414 | - | assert_eq!(t.lines.len(), 2); | |
| 415 | - | assert_eq!(t.lines[0], "one"); | |
| 416 | - | assert_eq!(t.lines[1], "two"); | |
| 417 | - | assert!(t.partial.is_empty()); | |
| 418 | - | } | |
| 419 | - | ||
| 420 | - | #[test] | |
| 421 | - | fn gate_tail_buffers_partial_line_across_chunks() { | |
| 422 | - | // Chunks arrive at tokio read boundaries — a single logical line | |
| 423 | - | // can span multiple chunks. The tail must wait for `\n` before | |
| 424 | - | // committing. | |
| 425 | - | let mut t = tail_fixture(); | |
| 426 | - | t.push_chunk("Compi"); | |
| 427 | - | t.push_chunk("ling foo"); | |
| 428 | - | assert!(t.lines.is_empty()); | |
| 429 | - | assert_eq!(t.partial, "Compiling foo"); | |
| 430 | - | t.push_chunk(" v0.1.0\nDone\n"); | |
| 431 | - | assert_eq!(t.lines.len(), 2); | |
| 432 | - | assert_eq!(t.lines[0], "Compiling foo v0.1.0"); | |
| 433 | - | assert_eq!(t.lines[1], "Done"); | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | #[test] | |
| 437 | - | fn gate_tail_strips_carriage_returns() { | |
| 438 | - | // Cargo emits `\r\n` on Windows-builds; treat the `\r` as part of | |
| 439 | - | // the line terminator so the pane doesn't render visible `^M`s. | |
| 440 | - | let mut t = tail_fixture(); | |
| 441 | - | t.push_chunk("hello\r\nworld\r\n"); | |
| 442 | - | assert_eq!(t.lines[0], "hello"); | |
| 443 | - | assert_eq!(t.lines[1], "world"); | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | #[test] | |
| 447 | - | fn gate_tail_caps_lines_at_tail_lines_cap() { | |
| 448 | - | let mut t = tail_fixture(); | |
| 449 | - | for i in 0..(TAIL_LINES_CAP + 50) { | |
| 450 | - | t.push_chunk(&format!("line {i}\n")); | |
| 451 | - | } | |
| 452 | - | assert_eq!(t.lines.len(), TAIL_LINES_CAP); | |
| 453 | - | assert_eq!(t.lines.front().unwrap(), &format!("line {}", 50)); | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | #[test] | |
| 457 | - | fn gate_tail_finalize_flushes_trailing_partial() { | |
| 458 | - | // If the gate's last write didn't end in `\n` (panic mid-line, | |
| 459 | - | // SIGKILL), the partial-buffered tail must still surface on | |
| 460 | - | // finalize. | |
| 461 | - | let mut t = tail_fixture(); | |
| 462 | - | t.push_chunk("partial without newline"); | |
| 463 | - | assert!(t.lines.is_empty()); | |
| 464 | - | t.finalize("failed".into()); | |
| 465 | - | assert_eq!(t.lines.len(), 1); | |
| 466 | - | assert_eq!(t.lines[0], "partial without newline"); | |
| 467 | - | assert_eq!(t.status, TailStatus::Finished("failed".into())); | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | #[test] | |
| 471 | - | fn shared_open_tail_evicts_oldest_at_capacity() { | |
| 472 | - | let mut sh = Shared::default(); | |
| 473 | - | for i in 0..(GATE_TAILS_CAP + 3) as i64 { | |
| 474 | - | sh.open_tail( | |
| 475 | - | GateRunId(i + 1), | |
| 476 | - | TierId::new("a"), | |
| 477 | - | "0.9.6".parse().unwrap(), | |
| 478 | - | GateKind::CargoTest, | |
| 479 | - | ); | |
| 480 | - | } | |
| 481 | - | assert_eq!(sh.gate_tails.len(), GATE_TAILS_CAP); | |
| 482 | - | // The first 3 (run_id 1, 2, 3) were evicted; the smallest key | |
| 483 | - | // remaining should be 4. | |
| 484 | - | assert_eq!(*sh.gate_tails.keys().next().unwrap(), GateRunId(4)); | |
| 485 | - | // focus_run follows the most recent insertion. | |
| 486 | - | assert_eq!(sh.focus_run, Some(GateRunId((GATE_TAILS_CAP + 3) as i64))); | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | #[test] | |
| 490 | - | fn shared_push_tail_chunk_is_noop_for_unknown_run() { | |
| 491 | - | // GateLogChunk can arrive before GateStart if a frame lags; just | |
| 492 | - | // drop it rather than synthesizing a header-less run. | |
| 493 | - | let mut sh = Shared::default(); | |
| 494 | - | sh.push_tail_chunk(GateRunId(99), "orphaned\n"); | |
| 495 | - | assert!(sh.gate_tails.is_empty()); | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | #[test] | |
| 499 | - | fn cycle_focus_wraps_in_both_directions() { | |
| 500 | - | let mut sh = Shared::default(); |
Lines truncated
| @@ -1,179 +0,0 @@ | |||
| 1 | - | //! sando-tui model module (split out of main.rs). | |
| 2 | - | ||
| 3 | - | use crate::*; | |
| 4 | - | ||
| 5 | - | pub(crate) const EVENTS_CAP: usize = 200; | |
| 6 | - | pub(crate) const GATE_TAILS_CAP: usize = 10; | |
| 7 | - | pub(crate) const TAIL_LINES_CAP: usize = 200; | |
| 8 | - | ||
| 9 | - | #[derive(Clone, Debug, Deserialize)] | |
| 10 | - | pub(crate) struct StateView { | |
| 11 | - | pub(crate) tiers: Vec<TierView>, | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | #[derive(Clone, Debug, Deserialize)] | |
| 15 | - | pub(crate) struct TierView { | |
| 16 | - | pub(crate) name: String, | |
| 17 | - | pub(crate) provisioned: bool, | |
| 18 | - | #[allow(dead_code)] | |
| 19 | - | pub(crate) canary: String, | |
| 20 | - | pub(crate) current_version: Option<String>, | |
| 21 | - | pub(crate) previous_version: Option<String>, | |
| 22 | - | pub(crate) burn_in_started_at: Option<String>, | |
| 23 | - | /// Non-null when the daemon left the tier in a partial / mixed-version state | |
| 24 | - | /// (a failed promote/rollback that couldn't fully restore consistency). | |
| 25 | - | #[serde(default)] | |
| 26 | - | pub(crate) partial_reason: Option<String>, | |
| 27 | - | pub(crate) nodes: Vec<String>, | |
| 28 | - | pub(crate) gates: Vec<GateView>, | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | #[derive(Clone, Debug, Deserialize)] | |
| 32 | - | pub(crate) struct GateView { | |
| 33 | - | pub(crate) kind: String, | |
| 34 | - | #[allow(dead_code)] | |
| 35 | - | pub(crate) finished_at: Option<String>, | |
| 36 | - | /// `'passed' | 'failed' | 'blocked'` or NULL. NULL = in-flight. | |
| 37 | - | #[serde(default)] | |
| 38 | - | pub(crate) status: Option<String>, | |
| 39 | - | /// Full typed `GateOutcome`, JSON object. We don't deserialize the | |
| 40 | - | /// inner shape on the polling path — the events handler does that | |
| 41 | - | /// when the gate finishes, since that's when classification actually | |
| 42 | - | /// matters for the operator's render. Carried here as opaque JSON. | |
| 43 | - | #[serde(default)] | |
| 44 | - | #[allow(dead_code)] | |
| 45 | - | pub(crate) outcome: Option<serde_json::Value>, | |
| 46 | - | #[serde(default)] | |
| 47 | - | #[allow(dead_code)] | |
| 48 | - | pub(crate) log_ref: Option<String>, | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | #[derive(Default)] | |
| 52 | - | pub(crate) struct Shared { | |
| 53 | - | pub(crate) state: Option<StateView>, | |
| 54 | - | pub(crate) last_err: Option<String>, | |
| 55 | - | pub(crate) events: VecDeque<String>, | |
| 56 | - | pub(crate) ws_ok: bool, | |
| 57 | - | pub(crate) selected: usize, | |
| 58 | - | pub(crate) notice: Option<String>, | |
| 59 | - | /// Per-run live-tail buffers keyed by `GateRunId`. Populated by | |
| 60 | - | /// `GateStart` / `GateLogChunk` / `GateDone`. Bounded to the most | |
| 61 | - | /// recent `GATE_TAILS_CAP` runs (LRU by start time). | |
| 62 | - | pub(crate) gate_tails: BTreeMap<GateRunId, GateTail>, | |
| 63 | - | /// Run shown in the tail pane. Defaults to the most recently active | |
| 64 | - | /// run; updated as new `GateStart` events arrive. | |
| 65 | - | pub(crate) focus_run: Option<GateRunId>, | |
| 66 | - | /// A mutating action (promote/rollback/confirm) awaiting y/N confirmation. | |
| 67 | - | /// While `Some`, the key handler intercepts everything: `y`/Enter dispatches | |
| 68 | - | /// it, any other key cancels. Prevents a single fat-fingered keystroke from | |
| 69 | - | /// shipping or rolling back prod. | |
| 70 | - | pub(crate) pending: Option<Action>, | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | /// Per-run live-tail state. Receives `GateLogChunk` bytes and presents | |
| 74 | - | /// them as a line-aware ring buffer. Chunks are NOT line-aligned at the | |
| 75 | - | /// transport — we buffer a trailing partial line across chunks so the UI | |
| 76 | - | /// only renders complete lines (and one trailing in-flight line, on | |
| 77 | - | /// `close`/finalize). | |
| 78 | - | pub(crate) struct GateTail { | |
| 79 | - | pub(crate) tier: TierId, | |
| 80 | - | pub(crate) version: Version, | |
| 81 | - | pub(crate) gate: GateKind, | |
| 82 | - | /// Complete lines, oldest first. Newest end of the deque is what the | |
| 83 | - | /// pane shows at the bottom. | |
| 84 | - | pub(crate) lines: VecDeque<String>, | |
| 85 | - | /// Trailing bytes after the last `\n` in any chunk so far. Flushed | |
| 86 | - | /// into `lines` on `finalize` (so the operator sees the last line | |
| 87 | - | /// even if the gate exited without a final newline). | |
| 88 | - | pub(crate) partial: String, | |
| 89 | - | pub(crate) status: TailStatus, | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 93 | - | pub(crate) enum TailStatus { | |
| 94 | - | InFlight, | |
| 95 | - | Finished(String), | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | impl GateTail { | |
| 99 | - | pub(crate) fn new(tier: TierId, version: Version, gate: GateKind) -> Self { | |
| 100 | - | Self { | |
| 101 | - | tier, | |
| 102 | - | version, | |
| 103 | - | gate, | |
| 104 | - | lines: VecDeque::new(), | |
| 105 | - | partial: String::new(), | |
| 106 | - | status: TailStatus::InFlight, | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | /// Append a chunk of stdout/stderr. Splits on `\n`, buffers the | |
| 111 | - | /// trailing partial line, caps the ring at `TAIL_LINES_CAP`. | |
| 112 | - | pub(crate) fn push_chunk(&mut self, text: &str) { | |
| 113 | - | let combined = std::mem::take(&mut self.partial) + text; | |
| 114 | - | let mut rest = combined.as_str(); | |
| 115 | - | while let Some(idx) = rest.find('\n') { | |
| 116 | - | let (line, after) = rest.split_at(idx); | |
| 117 | - | self.push_line(line.trim_end_matches('\r').to_string()); | |
| 118 | - | rest = &after[1..]; | |
| 119 | - | } | |
| 120 | - | self.partial = rest.to_string(); | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | pub(crate) fn push_line(&mut self, line: String) { | |
| 124 | - | if self.lines.len() >= TAIL_LINES_CAP { | |
| 125 | - | self.lines.pop_front(); | |
| 126 | - | } | |
| 127 | - | self.lines.push_back(line); | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | /// Called when `GateDone` arrives. Flushes any trailing partial | |
| 131 | - | /// line and records the gate's final status word. | |
| 132 | - | pub(crate) fn finalize(&mut self, status_word: String) { | |
| 133 | - | let trailing = std::mem::take(&mut self.partial); | |
| 134 | - | if !trailing.is_empty() { | |
| 135 | - | self.push_line(trailing); | |
| 136 | - | } | |
| 137 | - | self.status = TailStatus::Finished(status_word); | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | impl Shared { | |
| 142 | - | pub(crate) fn push_event(&mut self, line: String) { | |
| 143 | - | if self.events.len() >= EVENTS_CAP { | |
| 144 | - | self.events.pop_front(); | |
| 145 | - | } | |
| 146 | - | self.events.push_back(line); | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | pub(crate) fn open_tail( | |
| 150 | - | &mut self, | |
| 151 | - | run_id: GateRunId, | |
| 152 | - | tier: TierId, | |
| 153 | - | version: Version, | |
| 154 | - | gate: GateKind, | |
| 155 | - | ) { | |
| 156 | - | if self.gate_tails.len() >= GATE_TAILS_CAP { | |
| 157 | - | // BTreeMap is sorted by GateRunId (monotonic from the daemon's | |
| 158 | - | // INSERT … RETURNING id), so the smallest key is the oldest run. | |
| 159 | - | if let Some((&oldest, _)) = self.gate_tails.iter().next() { | |
| 160 | - | self.gate_tails.remove(&oldest); | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | self.gate_tails | |
| 164 | - | .insert(run_id, GateTail::new(tier, version, gate)); | |
| 165 | - | self.focus_run = Some(run_id); | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | pub(crate) fn push_tail_chunk(&mut self, run_id: GateRunId, text: &str) { | |
| 169 | - | if let Some(t) = self.gate_tails.get_mut(&run_id) { | |
| 170 | - | t.push_chunk(text); | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | pub(crate) fn finalize_tail(&mut self, run_id: GateRunId, status_word: String) { | |
| 175 | - | if let Some(t) = self.gate_tails.get_mut(&run_id) { | |
| 176 | - | t.finalize(status_word); | |
| 177 | - | } | |
| 178 | - | } | |
| 179 | - | } |
| @@ -1,188 +0,0 @@ | |||
| 1 | - | //! sando-tui net module (split out of main.rs). | |
| 2 | - | ||
| 3 | - | use crate::*; | |
| 4 | - | ||
| 5 | - | pub(crate) async fn state_poller( | |
| 6 | - | daemon: String, | |
| 7 | - | token: Option<String>, | |
| 8 | - | shared: Arc<Mutex<Shared>>, | |
| 9 | - | ) { | |
| 10 | - | let url = format!("{}/state", daemon); | |
| 11 | - | let client = reqwest::Client::new(); | |
| 12 | - | loop { | |
| 13 | - | let mut req = client.get(&url).timeout(Duration::from_secs(2)); | |
| 14 | - | if let Some(tok) = &token { | |
| 15 | - | req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}")); | |
| 16 | - | } | |
| 17 | - | match req.send().await { | |
| 18 | - | Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await { | |
| 19 | - | Ok(s) => { | |
| 20 | - | let mut g = shared.lock().unwrap(); | |
| 21 | - | let n = s.tiers.len().saturating_sub(1); | |
| 22 | - | if g.selected > n { | |
| 23 | - | g.selected = n; | |
| 24 | - | } | |
| 25 | - | g.state = Some(s); | |
| 26 | - | g.last_err = None; | |
| 27 | - | } | |
| 28 | - | Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")), | |
| 29 | - | }, | |
| 30 | - | Ok(resp) => { | |
| 31 | - | shared.lock().unwrap().last_err = Some(format!("status {}", resp.status())); | |
| 32 | - | } | |
| 33 | - | Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()), | |
| 34 | - | } | |
| 35 | - | tokio::time::sleep(Duration::from_secs(2)).await; | |
| 36 | - | } | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | pub(crate) async fn events_subscriber( | |
| 40 | - | daemon: String, | |
| 41 | - | token: Option<String>, | |
| 42 | - | shared: Arc<Mutex<Shared>>, | |
| 43 | - | ) { | |
| 44 | - | use tokio_tungstenite::tungstenite::client::IntoClientRequest; | |
| 45 | - | let ws_url = ws_url_from(&daemon); | |
| 46 | - | ||
| 47 | - | loop { | |
| 48 | - | // Build the upgrade request fresh each attempt so the bearer header (when | |
| 49 | - | // configured) rides the /events handshake — the daemon gates the WS route | |
| 50 | - | // like every other read. A malformed request is treated like a dial | |
| 51 | - | // failure: mark the stream down and retry. | |
| 52 | - | let request = match ws_url.as_str().into_client_request() { | |
| 53 | - | Ok(mut r) => { | |
| 54 | - | if let Some(tok) = &token | |
| 55 | - | && let Ok(val) = format!("Bearer {tok}").parse() | |
| 56 | - | { | |
| 57 | - | r.headers_mut().insert( | |
| 58 | - | tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, | |
| 59 | - | val, | |
| 60 | - | ); | |
| 61 | - | } | |
| 62 | - | r | |
| 63 | - | } | |
| 64 | - | Err(_) => { | |
| 65 | - | shared.lock().unwrap().ws_ok = false; | |
| 66 | - | tokio::time::sleep(Duration::from_secs(3)).await; | |
| 67 | - | continue; | |
| 68 | - | } | |
| 69 | - | }; | |
| 70 | - | match tokio_tungstenite::connect_async(request).await { | |
| 71 | - | Ok((mut socket, _resp)) => { | |
| 72 | - | shared.lock().unwrap().ws_ok = true; | |
| 73 | - | while let Some(msg) = socket.next().await { | |
| 74 | - | match msg { | |
| 75 | - | Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => { | |
| 76 | - | dispatch_ws_frame(&shared, &t); | |
| 77 | - | } | |
| 78 | - | Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => break, | |
| 79 | - | Err(_) => break, | |
| 80 | - | _ => {} | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | shared.lock().unwrap().ws_ok = false; | |
| 84 | - | } | |
| 85 | - | Err(_) => { | |
| 86 | - | shared.lock().unwrap().ws_ok = false; | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | tokio::time::sleep(Duration::from_secs(3)).await; | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | /// Convert the daemon's HTTP URL into the matching WebSocket URL for /events. | |
| 94 | - | /// Plain string replace; rejects nothing — bad input fails later when | |
| 95 | - | /// `connect_async` actually dials. | |
| 96 | - | pub(crate) fn ws_url_from(daemon: &str) -> String { | |
| 97 | - | daemon | |
| 98 | - | .replacen("https://", "wss://", 1) | |
| 99 | - | .replacen("http://", "ws://", 1) | |
| 100 | - | + "/events" | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | /// Route an incoming WS frame: gate live-tail events update the per-run | |
| 104 | - | /// buffer, every other event becomes one line in the events ring. | |
| 105 | - | /// | |
| 106 | - | /// `GateLogChunk` does NOT push into the events ring — a busy gate emits | |
| 107 | - | /// hundreds of chunks per second, which would evict every other event in | |
| 108 | - | /// under a minute. The chunk text only lives in the per-run tail buffer. | |
| 109 | - | pub(crate) fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) { | |
| 110 | - | if let Some(line) = format_lagged(raw) { | |
| 111 | - | shared.lock().unwrap().push_event(line); | |
| 112 | - | return; | |
| 113 | - | } | |
| 114 | - | let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else { | |
| 115 | - | // Unparseable frame — surface verbatim so the operator can see it. | |
| 116 | - | shared.lock().unwrap().push_event(raw.to_string()); | |
| 117 | - | return; | |
| 118 | - | }; | |
| 119 | - | match &env.event { | |
| 120 | - | Event::GateStart { | |
| 121 | - | run_id, | |
| 122 | - | tier, | |
| 123 | - | version, | |
| 124 | - | gate, | |
| 125 | - | } => { | |
| 126 | - | let mut g = shared.lock().unwrap(); | |
| 127 | - | g.open_tail(*run_id, tier.clone(), version.clone(), *gate); | |
| 128 | - | let line = format_event_line(&env); | |
| 129 | - | g.push_event(line); | |
| 130 | - | } | |
| 131 | - | Event::GateLogChunk { run_id, text, .. } => { | |
| 132 | - | shared.lock().unwrap().push_tail_chunk(*run_id, text); | |
| 133 | - | } | |
| 134 | - | Event::GateDone { | |
| 135 | - | run_id, outcome, .. | |
| 136 | - | } => { | |
| 137 | - | let status_word = outcome.status_str().to_string(); | |
| 138 | - | let mut g = shared.lock().unwrap(); | |
| 139 | - | g.finalize_tail(*run_id, status_word); | |
| 140 | - | let line = format_event_line(&env); | |
| 141 | - | g.push_event(line); | |
| 142 | - | } | |
| 143 | - | _ => { | |
| 144 | - | let line = format_event_line(&env); | |
| 145 | - | shared.lock().unwrap().push_event(line); | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | pub(crate) async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> { | |
| 151 | - | let client = reqwest::Client::new(); | |
| 152 | - | let url = match act { | |
| 153 | - | Action::BackupFetch => format!("{daemon}/backup/fetch"), | |
| 154 | - | Action::Promote { tier } => format!("{daemon}/promote/{tier}"), | |
| 155 | - | Action::Rollback { tier } => format!("{daemon}/rollback/{tier}"), | |
| 156 | - | Action::Confirm { tier } => format!("{daemon}/confirm/{tier}"), | |
| 157 | - | }; | |
| 158 | - | let mut req = client.post(&url).timeout(Duration::from_secs(120)); | |
| 159 | - | // Deploy mutators require a bearer token when the daemon is configured with | |
| 160 | - | // one (CF2). The operator supplies it via SANDO_API_TOKEN, the same value in | |
| 161 | - | // the daemon's EnvironmentFile. | |
| 162 | - | if let Ok(tok) = std::env::var("SANDO_API_TOKEN") { | |
| 163 | - | let tok = tok.trim(); | |
| 164 | - | if !tok.is_empty() { | |
| 165 | - | req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}")); | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | // /promote accepts an empty body now (defaults version to predecessor's | |
| 169 | - | // current). The other endpoints take no body. | |
| 170 | - | if matches!(act, Action::Promote { .. }) { | |
| 171 | - | req = req.header("Content-Type", "application/json").body("{}"); | |
| 172 | - | } | |
| 173 | - | let resp = req.send().await.context("send")?; | |
| 174 | - | let status = resp.status(); | |
| 175 | - | // Distinguish a body-read failure from an empty body: on a rejected promote the | |
| 176 | - | // daemon's structured error message is the operator's only signal, and silently | |
| 177 | - | // turning a read hiccup into "" renders as a contentless "HTTP 409: ". | |
| 178 | - | match (status.is_success(), resp.text().await) { | |
| 179 | - | (true, Ok(body)) => Ok(truncate(&body, 80)), | |
| 180 | - | (true, Err(e)) => Err(anyhow::anyhow!( | |
| 181 | - | "HTTP {status} but response body unreadable: {e}" | |
| 182 | - | )), | |
| 183 | - | (false, Ok(body)) => Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&body, 200))), | |
| 184 | - | (false, Err(e)) => Err(anyhow::anyhow!( | |
| 185 | - | "HTTP {status} (response body unreadable: {e})" | |
| 186 | - | )), | |
| 187 | - | } | |
| 188 | - | } |
| @@ -1,430 +0,0 @@ | |||
| 1 | - | //! sando-tui ui module (split out of main.rs). | |
| 2 | - | ||
| 3 | - | use crate::*; | |
| 4 | - | ||
| 5 | - | /// Pick the one-word mark + color for a gate row in the tiers table. | |
| 6 | - | /// `status` is NULL while the gate is in-flight. | |
| 7 | - | pub(crate) fn gate_mark_and_style(g: &GateView) -> (&'static str, Style) { | |
| 8 | - | match g.status.as_deref() { | |
| 9 | - | Some("passed") => ("ok", Style::default().fg(Color::Green)), | |
| 10 | - | Some("failed") => ( | |
| 11 | - | "FAIL", | |
| 12 | - | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 13 | - | ), | |
| 14 | - | Some("blocked") => ("blocked", Style::default().fg(Color::Yellow)), | |
| 15 | - | // A genuinely unknown status string from the daemon is a contract drift, | |
| 16 | - | // not an in-flight gate — surface it distinctly rather than masking it as "...". | |
| 17 | - | Some(_) => ("?", Style::default().fg(Color::Magenta)), | |
| 18 | - | None => ("...", Style::default().fg(Color::DarkGray)), | |
| 19 | - | } | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | pub(crate) fn ui_loop<B: Backend>( | |
| 23 | - | term: &mut Terminal<B>, | |
| 24 | - | daemon: &str, | |
| 25 | - | shared: &Arc<Mutex<Shared>>, | |
| 26 | - | rt: &tokio::runtime::Handle, | |
| 27 | - | ) -> Result<()> { | |
| 28 | - | let (action_tx, mut action_rx) = mpsc::channel::<Action>(32); | |
| 29 | - | ||
| 30 | - | // Action dispatcher task: posts to daemon, drops notice on Shared. | |
| 31 | - | { | |
| 32 | - | let shared = shared.clone(); | |
| 33 | - | let daemon = daemon.to_string(); | |
| 34 | - | rt.spawn(async move { | |
| 35 | - | while let Some(act) = action_rx.recv().await { | |
| 36 | - | let res = dispatch_action(&daemon, &act).await; | |
| 37 | - | let line = match res { | |
| 38 | - | Ok(msg) => format!("[ok] {act}: {msg}"), | |
| 39 | - | Err(e) => format!("[err] {act}: {e}"), | |
| 40 | - | }; | |
| 41 | - | let mut g = shared.lock().unwrap(); | |
| 42 | - | g.notice = Some(line.clone()); | |
| 43 | - | g.push_event(format!(" action {line}")); | |
| 44 | - | } | |
| 45 | - | }); | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | loop { | |
| 49 | - | term.draw(|f| draw(f, daemon, shared))?; | |
| 50 | - | ||
| 51 | - | if event::poll(Duration::from_millis(120))? | |
| 52 | - | && let XEvent::Key(k) = event::read()? | |
| 53 | - | { | |
| 54 | - | { | |
| 55 | - | // Ctrl+C always quits, even mid-confirmation. | |
| 56 | - | if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) { | |
| 57 | - | return Ok(()); | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // A confirmation is pending: this keystroke answers it and nothing | |
| 61 | - | // else. y/Enter dispatches the held action; any other key cancels. | |
| 62 | - | // This is the guard that turns p/R/c from a single fat-fingerable | |
| 63 | - | // keystroke into a deliberate two-step (prod manual_confirm included). | |
| 64 | - | let pending = shared.lock().unwrap().pending.take(); | |
| 65 | - | if let Some(act) = pending { | |
| 66 | - | match k.code { | |
| 67 | - | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { | |
| 68 | - | // Don't swallow a full-channel drop: the operator must not | |
| 69 | - | // believe a promote was sent when it wasn't. | |
| 70 | - | if let Err(e) = action_tx.try_send(act) { | |
| 71 | - | let act = e.into_inner(); | |
| 72 | - | shared.lock().unwrap().notice = | |
| 73 | - | Some(format!("{act} not sent (daemon busy) - retry")); | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | _ => { | |
| 77 | - | shared.lock().unwrap().notice = Some(format!("{act} cancelled")); | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | continue; | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | let selected_tier = { | |
| 84 | - | let g = shared.lock().unwrap(); | |
| 85 | - | g.state | |
| 86 | - | .as_ref() | |
| 87 | - | .and_then(|s| s.tiers.get(g.selected).cloned()) | |
| 88 | - | }; | |
| 89 | - | ||
| 90 | - | match k.code { | |
| 91 | - | KeyCode::Char('q') | KeyCode::Esc => return Ok(()), | |
| 92 | - | KeyCode::Up | KeyCode::Char('k') => { | |
| 93 | - | let mut g = shared.lock().unwrap(); | |
| 94 | - | g.selected = g.selected.saturating_sub(1); | |
| 95 | - | } | |
| 96 | - | KeyCode::Down | KeyCode::Char('j') => { | |
| 97 | - | let mut g = shared.lock().unwrap(); | |
| 98 | - | let max = g | |
| 99 | - | .state | |
| 100 | - | .as_ref() | |
| 101 | - | .map(|s| s.tiers.len().saturating_sub(1)) | |
| 102 | - | .unwrap_or(0); | |
| 103 | - | if g.selected < max { | |
| 104 | - | g.selected += 1; | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | KeyCode::Char('r') => { | |
| 108 | - | // Nudge: state poller wakes every 2s anyway; this just notes intent. | |
| 109 | - | shared.lock().unwrap().notice = Some("refresh on next tick".into()); | |
| 110 | - | } | |
| 111 | - | KeyCode::Char('b') => { | |
| 112 | - | // Every mutating action stages a confirmation; the actual | |
| 113 | - | // dispatch (action_tx.send) lives ONLY in the pending/confirm | |
| 114 | - | // branch above, so no mutator fires on a single keystroke — | |
| 115 | - | // backup/fetch included (it was the one that still did). | |
| 116 | - | shared.lock().unwrap().pending = Some(Action::BackupFetch); | |
| 117 | - | } | |
| 118 | - | KeyCode::Char('p') => { | |
| 119 | - | if let Some(t) = selected_tier { | |
| 120 | - | shared.lock().unwrap().pending = Some(Action::Promote { tier: t.name }); | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | KeyCode::Char('R') => { | |
| 124 | - | if let Some(t) = selected_tier { | |
| 125 | - | shared.lock().unwrap().pending = | |
| 126 | - | Some(Action::Rollback { tier: t.name }); | |
| 127 | - | } | |
| 128 | - | } | |
| 129 | - | KeyCode::Char('c') => { | |
| 130 | - | if let Some(t) = selected_tier { | |
| 131 | - | shared.lock().unwrap().pending = Some(Action::Confirm { tier: t.name }); | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | KeyCode::Char('[') => { | |
| 135 | - | let mut g = shared.lock().unwrap(); | |
| 136 | - | g.focus_run = cycle_focus(&g.gate_tails, g.focus_run, -1); | |
| 137 | - | } | |
| 138 | - | KeyCode::Char(']') => { | |
| 139 | - | let mut g = shared.lock().unwrap(); | |
| 140 | - | g.focus_run = cycle_focus(&g.gate_tails, g.focus_run, 1); | |
| 141 | - | } | |
| 142 | - | _ => {} | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | pub(crate) fn truncate(s: &str, n: usize) -> String { | |
| 150 | - | if s.chars().count() <= n { | |
| 151 | - | s.into() | |
| 152 | - | } else { | |
| 153 | - | s.chars().take(n).collect::<String>() + "…" | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | pub(crate) fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) { | |
| 158 | - | let g = shared.lock().unwrap(); | |
| 159 | - | let chunks = Layout::default() | |
| 160 | - | .direction(Direction::Vertical) | |
| 161 | - | .constraints([ | |
| 162 | - | Constraint::Length(3), // header | |
| 163 | - | Constraint::Length(10), // tiers | |
| 164 | - | Constraint::Min(4), // events | |
| 165 | - | Constraint::Length(8), // gate tail | |
| 166 | - | Constraint::Length(2), // status | |
| 167 | - | ]) | |
| 168 | - | .split(f.area()); | |
| 169 | - | ||
| 170 | - | let ws_label = if g.ws_ok { "ws ok" } else { "ws ..." }; | |
| 171 | - | let header = Paragraph::new(format!("sando -> {daemon} ({ws_label})")) | |
| 172 | - | .block(Block::default().title("daemon").borders(Borders::ALL)); | |
| 173 | - | f.render_widget(header, chunks[0]); | |
| 174 | - | ||
| 175 | - | // Tiers table | |
| 176 | - | if let Some(s) = g.state.as_ref() { | |
| 177 | - | let hr = Row::new(vec![ | |
| 178 | - | " ", "tier", "prov", "current", "previous", "burn-in", "nodes", "gates", | |
| 179 | - | ]) | |
| 180 | - | .style(Style::default().add_modifier(Modifier::BOLD)); | |
| 181 | - | ||
| 182 | - | let rows: Vec<Row> = s | |
| 183 | - | .tiers | |
| 184 | - | .iter() | |
| 185 | - | .enumerate() | |
| 186 | - | .map(|(i, t)| { | |
| 187 | - | let marker = if i == g.selected { ">" } else { " " }; | |
| 188 | - | let gates = if t.gates.is_empty() { | |
| 189 | - | Line::from("-") | |
| 190 | - | } else { | |
| 191 | - | // Step 5: render Passed (green) / Failed (red) / | |
| 192 | - | // Blocked (yellow) / in-flight (default) from the | |
| 193 | - | // typed `status` column. Falls back to `passed` for | |
| 194 | - | // rows written before migration 003 (which the | |
| 195 | - | // backfill should have set, but defensive anyway). | |
| 196 | - | let mut spans: Vec<Span> = Vec::new(); | |
| 197 | - | for (idx, g) in t.gates.iter().enumerate() { | |
| 198 | - | if idx > 0 { | |
| 199 | - | spans.push(Span::raw(" ")); | |
| 200 | - | } | |
| 201 | - | let (mark, style) = gate_mark_and_style(g); | |
| 202 | - | spans.push(Span::raw(format!("{}:", g.kind))); | |
| 203 | - | spans.push(Span::styled(mark, style)); | |
| 204 | - | } | |
| 205 | - | Line::from(spans) | |
| 206 | - | }; | |
| 207 | - | // A partial tier renders its name red and prefixed with "!" so the | |
| 208 | - | // mixed-version condition is visible at a glance; the full reason | |
| 209 | - | // goes in the status line below. | |
| 210 | - | let name_cell: ratatui::widgets::Cell = if t.partial_reason.is_some() { | |
| 211 | - | Span::styled( | |
| 212 | - | format!("!{}", t.name), | |
| 213 | - | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 214 | - | ) | |
| 215 | - | .into() | |
| 216 | - | } else { | |
| 217 | - | t.name.clone().into() | |
| 218 | - | }; | |
| 219 | - | let cells: Vec<ratatui::widgets::Cell> = vec![ | |
| 220 | - | marker.to_string().into(), | |
| 221 | - | name_cell, | |
| 222 | - | if t.provisioned { | |
| 223 | - | "yes".into() | |
| 224 | - | } else { | |
| 225 | - | "no".into() | |
| 226 | - | }, | |
| 227 | - | t.current_version | |
| 228 | - | .clone() | |
| 229 | - | .unwrap_or_else(|| "-".into()) | |
| 230 | - | .into(), | |
| 231 | - | t.previous_version | |
| 232 | - | .clone() | |
| 233 | - | .unwrap_or_else(|| "-".into()) | |
| 234 | - | .into(), | |
| 235 | - | t.burn_in_started_at | |
| 236 | - | .as_deref() | |
| 237 | - | .and_then(|s| s.get(11..19)) | |
| 238 | - | .unwrap_or("-") | |
| 239 | - | .to_string() | |
| 240 | - | .into(), | |
| 241 | - | if t.nodes.is_empty() { | |
| 242 | - | "-".into() | |
| 243 | - | } else { | |
| 244 | - | t.nodes.join(",").into() | |
| 245 | - | }, | |
| 246 | - | gates.into(), | |
| 247 | - | ]; | |
| 248 | - | let row = Row::new(cells); | |
| 249 | - | if i == g.selected { | |
| 250 | - | row.style(Style::default().add_modifier(Modifier::REVERSED)) | |
| 251 | - | } else { | |
| 252 | - | row | |
| 253 | - | } | |
| 254 | - | }) | |
| 255 | - | .collect(); | |
| 256 | - | ||
| 257 | - | let widths = [ | |
| 258 | - | Constraint::Length(2), | |
| 259 | - | Constraint::Length(8), | |
| 260 | - | Constraint::Length(5), | |
| 261 | - | Constraint::Length(12), | |
| 262 | - | Constraint::Length(12), | |
| 263 | - | Constraint::Length(10), | |
| 264 | - | Constraint::Length(20), | |
| 265 | - | Constraint::Min(20), | |
| 266 | - | ]; | |
| 267 | - | let table = Table::new(rows, widths).header(hr).block( | |
| 268 | - | Block::default() | |
| 269 | - | .title(format!("tiers ({}) ↑/↓ select", s.tiers.len())) | |
| 270 | - | .borders(Borders::ALL), | |
| 271 | - | ); | |
| 272 | - | f.render_widget(table, chunks[1]); | |
| 273 | - | } else { | |
| 274 | - | let msg = g.last_err.clone().unwrap_or_else(|| "loading...".into()); | |
| 275 | - | let placeholder = | |
| 276 | - | Paragraph::new(msg).block(Block::default().title("tiers").borders(Borders::ALL)); | |
| 277 | - | f.render_widget(placeholder, chunks[1]); | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | // Events | |
| 281 | - | let area = chunks[2]; | |
| 282 | - | let visible = area.height.saturating_sub(2) as usize; // minus borders | |
| 283 | - | let items: Vec<ListItem> = g | |
| 284 | - | .events | |
| 285 | - | .iter() | |
| 286 | - | .rev() | |
| 287 | - | .take(visible) | |
| 288 | - | .rev() | |
| 289 | - | .map(|line| ListItem::new(line.clone())) | |
| 290 | - | .collect(); | |
| 291 | - | let events_block = List::new(items).block( | |
| 292 | - | Block::default() | |
| 293 | - | .title(format!("events ({})", g.events.len())) | |
| 294 | - | .borders(Borders::ALL), | |
| 295 | - | ); | |
| 296 | - | f.render_widget(events_block, area); | |
| 297 | - | ||
| 298 | - | // Gate tail (per-run live log buffer). | |
| 299 | - | let tail_area = chunks[3]; | |
| 300 | - | let tail = g | |
| 301 | - | .focus_run | |
| 302 | - | .and_then(|id| g.gate_tails.get(&id).map(|t| (id, t))); | |
| 303 | - | let tail_widget = match tail { | |
| 304 | - | Some((id, t)) => { | |
| 305 | - | let title_state = match &t.status { | |
| 306 | - | TailStatus::InFlight => "in-flight".to_string(), | |
| 307 | - | TailStatus::Finished(s) => s.clone(), | |
| 308 | - | }; | |
| 309 | - | let title = format!( | |
| 310 | - | "tail [{}] {}/{} {} — {title_state} ([) prev / (]) next {} of {}", | |
| 311 | - | id, | |
| 312 | - | t.tier, | |
| 313 | - | t.version, | |
| 314 | - | t.gate, | |
| 315 | - | tail_position(&g.gate_tails, id), | |
| 316 | - | g.gate_tails.len(), | |
| 317 | - | ); | |
| 318 | - | let visible = tail_area.height.saturating_sub(2) as usize; | |
| 319 | - | let items: Vec<ListItem> = t | |
| 320 | - | .lines | |
| 321 | - | .iter() | |
| 322 | - | .rev() | |
| 323 | - | .take(visible) | |
| 324 | - | .rev() | |
| 325 | - | .map(|l| ListItem::new(truncate(l, 200))) | |
| 326 | - | .collect(); | |
| 327 | - | List::new(items).block(Block::default().title(title).borders(Borders::ALL)) | |
| 328 | - | } | |
| 329 | - | None => { | |
| 330 | - | let body = "no recent gate runs"; | |
| 331 | - | List::new(vec![ListItem::new(body)]) | |
| 332 | - | .block(Block::default().title("tail").borders(Borders::ALL)) | |
| 333 | - | } | |
| 334 | - | }; | |
| 335 | - | f.render_widget(tail_widget, tail_area); | |
| 336 | - | ||
| 337 | - | // Status / keys | |
| 338 | - | let keys = "[p] promote [R] rollback [b] backup [c] confirm [↑↓] select [[/]] tail [r] refresh [q] quit"; | |
| 339 | - | // A partial/mixed-version tier is the operator's most urgent signal — it | |
| 340 | - | // outranks transient notices and errors and renders red until resolved. | |
| 341 | - | let partial = g.state.as_ref().and_then(|s| { | |
| 342 | - | s.tiers.iter().find_map(|t| { | |
| 343 | - | t.partial_reason | |
| 344 | - | .as_ref() | |
| 345 | - | .map(|r| format!("PARTIAL {}: {r}", t.name)) | |
| 346 | - | }) | |
| 347 | - | }); | |
| 348 | - | if let Some(p) = partial { | |
| 349 | - | f.render_widget( | |
| 350 | - | Paragraph::new(format!("{p} {keys}")) | |
| 351 | - | .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), | |
| 352 | - | chunks[4], | |
| 353 | - | ); | |
| 354 | - | } else { | |
| 355 | - | let status = if let Some(n) = &g.notice { | |
| 356 | - | format!("{n} {keys}") | |
| 357 | - | } else if let Some(e) = &g.last_err { | |
| 358 | - | format!("error: {e} {keys}") | |
| 359 | - | } else { | |
| 360 | - | keys.into() | |
| 361 | - | }; | |
| 362 | - | f.render_widget(Paragraph::new(status), chunks[4]); | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | // Confirmation modal: drawn last so it overlays everything. A pending | |
| 366 | - | // promote/rollback/confirm cannot be discharged without an explicit y. | |
| 367 | - | if let Some(act) = &g.pending { | |
| 368 | - | let area = centered_rect(54, 7, f.area()); | |
| 369 | - | f.render_widget(Clear, area); | |
| 370 | - | let prompt = Paragraph::new(vec![ | |
| 371 | - | Line::from(""), | |
| 372 | - | Line::from(Span::styled( | |
| 373 | - | format!(" {act}"), | |
| 374 | - | Style::default().add_modifier(Modifier::BOLD), | |
| 375 | - | )), | |
| 376 | - | Line::from(""), | |
| 377 | - | Line::from(" [y] confirm [any other key] cancel"), | |
| 378 | - | ]) | |
| 379 | - | .block( | |
| 380 | - | Block::default() | |
| 381 | - | .title("confirm") | |
| 382 | - | .borders(Borders::ALL) | |
| 383 | - | .border_style(Style::default().fg(Color::Yellow)), | |
| 384 | - | ); | |
| 385 | - | f.render_widget(prompt, area); | |
| 386 | - | } | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | /// A `w`×`h` rectangle centered within `area` (clamped to fit). | |
| 390 | - | pub(crate) fn centered_rect(w: u16, h: u16, area: Rect) -> Rect { | |
| 391 | - | let w = w.min(area.width); | |
| 392 | - | let h = h.min(area.height); | |
| 393 | - | let x = area.x + (area.width.saturating_sub(w)) / 2; | |
| 394 | - | let y = area.y + (area.height.saturating_sub(h)) / 2; | |
| 395 | - | Rect { | |
| 396 | - | x, | |
| 397 | - | y, | |
| 398 | - | width: w, | |
| 399 | - | height: h, | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | /// 1-based index of `id` within the sorted tail map; 0 if not present. | |
| 404 | - | pub(crate) fn tail_position(tails: &BTreeMap<GateRunId, GateTail>, id: GateRunId) -> usize { | |
| 405 | - | tails | |
| 406 | - | .keys() | |
| 407 | - | .position(|k| *k == id) | |
| 408 | - | .map(|i| i + 1) | |
| 409 | - | .unwrap_or(0) | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | /// Move `focus_run` by `step` positions through the sorted run map. | |
| 413 | - | /// Wraps at both ends. Returns the new focus, or `None` if the map is | |
| 414 | - | /// empty. | |
| 415 | - | pub(crate) fn cycle_focus( | |
| 416 | - | tails: &BTreeMap<GateRunId, GateTail>, | |
| 417 | - | current: Option<GateRunId>, | |
| 418 | - | step: i32, | |
| 419 | - | ) -> Option<GateRunId> { | |
| 420 | - | if tails.is_empty() { | |
| 421 | - | return None; | |
| 422 | - | } | |
| 423 | - | let keys: Vec<GateRunId> = tails.keys().copied().collect(); | |
| 424 | - | let idx = current | |
| 425 | - | .and_then(|c| keys.iter().position(|k| *k == c)) | |
| 426 | - | .unwrap_or(keys.len().saturating_sub(1)); | |
| 427 | - | let len = keys.len() as i32; | |
| 428 | - | let new_idx = ((idx as i32 + step) % len + len) % len; | |
| 429 | - | Some(keys[new_idx as usize]) | |
| 430 | - | } |
| @@ -82,7 +82,7 @@ Licenses noted in parentheses where they aren't the standard MIT/Apache-2.0 dual | |||
| 82 | 82 | ||
| 83 | 83 | **Errors**: `anyhow`, `thiserror`. | |
| 84 | 84 | ||
| 85 | - | **CLI and TUI** (mnw-cli, sando-tui, wam): `clap`, `ratatui`, `crossterm`, `russh`, `russh-sftp`, `dirs`. | |
| 85 | + | **CLI and TUI** (mnw-cli, ops-viewer, wam): `clap`, `ratatui`, `crossterm`, `russh`, `russh-sftp`, `dirs`. | |
| 86 | 86 | ||
| 87 | 87 | **MCP**: `rmcp`. | |
| 88 | 88 |