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
Commit: d0f1ddac27690615d11587964c5c06d5de158148
Parent: 51541c4
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
M sando/Cargo.lock +5 -330
@@ -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