Skip to main content

max / makenotwork

Retire sando-tui and bento-tui for the shared ops-viewer Both operator TUIs are superseded by MNW/viewer (ops-viewer), which renders every daemon's /status.json through the shared ops-status contract. Running three viewers is the mistake the unified surface exists to end, so the old two are deleted rather than left to coexist. sando drops its tui workspace member; bento/tui was standalone. credits.md now names ops-viewer.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 23:55 UTC
Signed with PGP, not checked
Commit: d0f1ddac27690615d11587964c5c06d5de158148
Parent: 51541c4
13 files changed, +11 insertions, -2939 deletions
M sando/Cargo.lock +5 -330
@@ -117,7 +117,7 @@
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",
@@ -205,21 +205,6 @@
205 205 source = "registry+https://github.com/rust-lang/crates.io-index"
206 206 checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
207 207
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 208 [[package]]
224 209 name = "cc"
225 210 version = "1.2.65"
@@ -294,20 +279,6 @@
294 279 "memchr",
295 280 ]
296 281
297 - [[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 282 [[package]]
312 283 name = "concurrent-queue"
313 284 version = "2.5.0"
@@ -317,12 +288,6 @@
317 288 "crossbeam-utils",
318 289 ]
319 290
320 - [[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 291 [[package]]
327 292 name = "core-foundation"
328 293 version = "0.10.1"
@@ -387,31 +352,6 @@
387 352 source = "registry+https://github.com/rust-lang/crates.io-index"
388 353 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
389 354
390 - [[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 355 [[package]]
416 356 name = "crypto-common"
417 357 version = "0.1.7"
@@ -440,40 +380,6 @@
440 380 "cmov",
441 381 ]
442 382
443 - [[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 383 [[package]]
478 384 name = "data-encoding"
479 385 version = "2.11.0"
@@ -497,7 +403,6 @@
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 ]
@@ -594,12 +499,6 @@
594 499 "spin",
595 500 ]
596 501
597 - [[package]]
598 - name = "foldhash"
599 - version = "0.1.5"
600 - source = "registry+https://github.com/rust-lang/crates.io-index"
601 - checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
602 -
603 502 [[package]]
604 503 name = "foldhash"
605 504 version = "0.2.0"
@@ -747,17 +646,6 @@
747 646 source = "registry+https://github.com/rust-lang/crates.io-index"
748 647 checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
749 648
750 - [[package]]
751 - 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 649 [[package]]
762 650 name = "hashbrown"
763 651 version = "0.16.1"
@@ -766,7 +654,7 @@
766 654 dependencies = [
767 655 "allocator-api2",
768 656 "equivalent",
769 - "foldhash 0.2.0",
657 + "foldhash",
770 658 ]
771 659
772 660 [[package]]
@@ -1033,12 +921,6 @@
1033 921 "zerovec",
1034 922 ]
1035 923
1036 - [[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 924 [[package]]
1043 925 name = "idna"
1044 926 version = "1.1.0"
@@ -1070,43 +952,12 @@
1070 952 "hashbrown 0.17.1",
1071 953 ]
1072 954
1073 - [[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 955 [[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 - [[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 961 [[package]]
1111 962 name = "itoa"
1112 963 version = "1.0.18"
@@ -1206,12 +1057,6 @@
1206 1057 "vcpkg",
1207 1058 ]
1208 1059
1209 - [[package]]
1210 - 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 1060 [[package]]
1216 1061 name = "linux-raw-sys"
1217 1062 version = "0.12.1"
@@ -1239,15 +1084,6 @@
1239 1084 source = "registry+https://github.com/rust-lang/crates.io-index"
1240 1085 checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1241 1086
1242 - [[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 1087 [[package]]
1252 1088 name = "lru-slab"
1253 1089 version = "0.1.2"
@@ -1298,7 +1134,6 @@
1298 1134 checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
1299 1135 dependencies = [
1300 1136 "libc",
1301 - "log",
1302 1137 "wasi",
1303 1138 "windows-sys 0.61.2",
1304 1139 ]
@@ -1398,12 +1233,6 @@
1398 1233 "windows-link",
1399 1234 ]
1400 1235
1401 - [[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 1236 [[package]]
1408 1237 name = "percent-encoding"
1409 1238 version = "2.3.2"
@@ -1572,27 +1401,6 @@
1572 1401 source = "registry+https://github.com/rust-lang/crates.io-index"
1573 1402 checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1574 1403
1575 - [[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 1404 [[package]]
1597 1405 name = "redox_syscall"
1598 1406 version = "0.5.18"
@@ -1685,19 +1493,6 @@
1685 1493 "semver",
1686 1494 ]
1687 1495
1688 - [[package]]
1689 - 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 1496 [[package]]
1702 1497 name = "rustix"
1703 1498 version = "1.1.4"
@@ -1707,7 +1502,7 @@
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
@@ -1833,23 +1628,6 @@
1833 1628 "tracing-subscriber",
1834 1629 ]
1835 1630
1836 - [[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 1631 [[package]]
1854 1632 name = "schannel"
1855 1633 version = "0.1.29"
@@ -2032,27 +1810,6 @@
2032 1810 source = "registry+https://github.com/rust-lang/crates.io-index"
2033 1811 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2034 1812
2035 - [[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 1813 [[package]]
2057 1814 name = "signal-hook-registry"
2058 1815 version = "1.4.8"
@@ -2294,12 +2051,6 @@
2294 2051 source = "registry+https://github.com/rust-lang/crates.io-index"
2295 2052 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2296 2053
2297 - [[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 2054 [[package]]
2304 2055 name = "stringprep"
2305 2056 version = "0.1.5"
@@ -2311,34 +2062,6 @@
2311 2062 "unicode-properties",
2312 2063 ]
2313 2064
2314 - [[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 2065 [[package]]
2343 2066 name = "subtle"
2344 2067 version = "2.6.1"
@@ -2385,7 +2108,7 @@
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 @@
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]]
@@ -2701,24 +2408,6 @@
2701 2408 "thiserror",
2702 2409 ]
2703 2410
2704 - [[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 2411 [[package]]
2723 2412 name = "typenum"
2724 2413 version = "1.20.1"
@@ -2752,35 +2441,6 @@
2752 2441 source = "registry+https://github.com/rust-lang/crates.io-index"
2753 2442 checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
2754 2443
2755 - [[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",
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"]
@@ -82,7 +82,7 @@
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
@@ -1,3634 +1,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 +1,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 +1,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
@@ -1,22 +1,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 +1,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 +1,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 +1,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