Skip to main content

max / makenotwork

Split sando TUI main.rs into model/net/format/ui/actions modules tui/src/main.rs was a 1412-line binary mixing the daemon-view data model, network polling/WS, event formatting, ratatui rendering, and the action enum. Break it into submodules: model.rs (StateView/TierView/GateView/ Shared/GateTail + impls + caps), net.rs (state_poller, events_subscriber, ws helpers, dispatch_action), format.rs (event -> line formatters), ui.rs (ui_loop, draw, layout helpers), actions.rs (Action + Display). main.rs keeps main/TerminalGuard/restore_terminal, the module wiring, and the tests. Cross-module items are pub(crate); main.rs re-exports each module's glob so existing unqualified references and the tests resolve unchanged. Function set is identical (64 fns). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 15:32 UTC
Commit: 3676eb38e4f866567be7ae29e780b7540d8c55db
Parent: 425e817
6 files changed, +947 insertions, -471 deletions
@@ -0,0 +1,21 @@
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 + }
21 +
@@ -0,0 +1,176 @@
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") { return None; }
32 + let at = v.get("at").and_then(|x| x.as_str()).unwrap_or("");
33 + let time = at.get(11..19).unwrap_or("");
34 + let skipped = v.get("skipped")
35 + .and_then(|x| x.as_u64())
36 + .map(|n| n.to_string())
37 + .unwrap_or_else(|| "?".into());
38 + Some(format!("{time} lagged (skipped {skipped} events)"))
39 + }
40 +
41 + pub(crate) fn event_kind_str(e: &Event) -> &'static str {
42 + match e {
43 + Event::RebuildRequested { .. } => "rebuild_requested",
44 + Event::BuildAborted { .. } => "build_aborted",
45 + Event::BuildStart { .. } => "build_start",
46 + Event::BuildOk { .. } => "build_ok",
47 + Event::BuildFailed { .. } => "build_failed",
48 + Event::GateStart { .. } => "gate_start",
49 + Event::GateLogChunk { .. } => "gate_log",
50 + Event::GateDone { .. } => "gate_done",
51 + Event::DeployStart { .. } => "deploy_start",
52 + Event::DeployOk { .. } => "deploy_ok",
53 + Event::DeployFailed { .. } => "deploy_failed",
54 + Event::PromoteComplete { .. } => "promote_complete",
55 + Event::Rollback { .. } => "rollback",
56 + Event::BackupFetched { .. } => "backup_fetched",
57 + Event::ManualConfirm { .. } => "manual_confirm",
58 + }
59 + }
60 +
61 + pub(crate) fn format_event_body(e: &Event) -> String {
62 + match e {
63 + Event::RebuildRequested { sha } => format!("sha={}", sha.short()),
64 + Event::BuildAborted { sha_aborted } => format!("aborted prev sha={}", sha_aborted.short()),
65 + Event::BuildStart { sha, version } => format!("v={version} sha={}", sha.short()),
66 + Event::BuildOk { version, elapsed_s, .. } => format!("v={version} ({elapsed_s}s)"),
67 + Event::BuildFailed { version, elapsed_s, .. } => format!("v={version} FAILED ({elapsed_s}s)"),
68 + Event::GateStart { run_id, tier, version, gate } =>
69 + format!("[{run_id}] {tier}/{version} {gate} start"),
70 + Event::GateLogChunk { run_id, seq, text } => {
71 + // Single-line summary used by `format_event` (test path only);
72 + // production rendering routes chunks through `dispatch_ws_frame`
73 + // into the per-run tail buffer, never the events ring.
74 + let one = text.lines().next().unwrap_or("").trim_end();
75 + let truncated: String = one.chars().take(160).collect();
76 + format!("[{run_id} #{seq}] {truncated}")
77 + }
78 + Event::GateDone { run_id, tier, version, gate, outcome, .. } => {
79 + let summary = match &outcome.status {
80 + GateStatus::Passed { note } => format!("ok ({})", pass_note_short(note)),
81 + GateStatus::Failed { failure } => format!("FAIL ({})", failure_short(failure)),
82 + GateStatus::Blocked { blocker } => format!("blocked ({})", blocker_short(blocker)),
83 + };
84 + format!("[{run_id}] {tier}/{version} {gate} {summary}")
85 + }
86 + Event::DeployStart { tier, node, version } => format!("{tier} -> {node} v={version}"),
87 + Event::DeployOk { tier, node, version } => format!("{tier} -> {node} v={version} ok"),
88 + Event::DeployFailed { tier, node, version, failure } =>
89 + format!("{tier} -> {node} v={version} FAILED: {}", deploy_failure_short(failure)),
90 + Event::PromoteComplete { tier, version } => format!("{tier} -> v={version} complete"),
91 + Event::Rollback { tier, from, to } => format!("{tier} {from} -> {to}"),
92 + Event::BackupFetched { source, byte_size } => format!("backup {byte_size} bytes from {source}"),
93 + Event::ManualConfirm { tier, version } => format!("{tier} v={version} confirmed"),
94 + }
95 + }
96 +
97 + pub(crate) fn pass_note_short(n: &PassNote) -> String {
98 + match n {
99 + PassNote::HealthyProbe { after_ms } => format!("/health {after_ms}ms"),
100 + PassNote::BurnInElapsed { hours } => format!("{hours}h elapsed"),
101 + PassNote::Migrated { backup_path } => {
102 + // Show just the basename; full path lives in /state if needed.
103 + let name = backup_path.rsplit('/').next().unwrap_or(backup_path);
104 + format!("migrated {name}")
105 + }
106 + PassNote::TestsPassed { duration_s } => format!("{duration_s}s"),
107 + PassNote::OperatorConfirmed { .. } => "operator".into(),
108 + PassNote::NodesHealthy { nodes } => format!("{nodes} node(s) healthy"),
109 + PassNote::Legacy { text } => text.clone(),
110 + }
111 + }
112 +
113 + pub(crate) fn failure_short(f: &GateFailure) -> String {
114 + match f {
115 + GateFailure::CargoTest { failed_count, first_panic: Some(p), .. } =>
116 + format!("{failed_count} test(s); panic: {p}"),
117 + GateFailure::CargoTest { failed_count, first_failed: Some(name), .. } =>
118 + format!("{failed_count} test(s); first {name}"),
119 + GateFailure::CargoTest { failed_count, .. } => format!("{failed_count} test(s) failed"),
120 + GateFailure::CompileError { error_count, first_error: Some(e) } =>
121 + format!("compile failed ({error_count}); {e}"),
122 + GateFailure::CompileError { error_count, .. } => format!("compile failed ({error_count})"),
123 + GateFailure::MigrationDrift { migration } => format!("drift {migration}"),
124 + GateFailure::MigrationModified { migration } => format!("modified {migration}"),
125 + GateFailure::MigrationSqlError { migration, sqlstate: Some(s) } =>
126 + format!("sql {migration} {s}"),
127 + GateFailure::MigrationSqlError { migration, .. } => format!("sql {migration}"),
128 + GateFailure::RestoreFailed { reason } => format!("restore: {reason}"),
129 + GateFailure::BootPanic { exit_code: Some(c) } => format!("panic exit {c}"),
130 + GateFailure::BootPanic { .. } => "panic".into(),
131 + GateFailure::BootExitedEarly { exit_code: Some(c) } => format!("exited {c}"),
132 + GateFailure::BootExitedEarly { .. } => "exited early".into(),
133 + GateFailure::BootHealthProbeFailed { last_error } => {
134 + format!("no /health: {}", last_error.chars().take(40).collect::<String>())
135 + }
136 + GateFailure::NodeUnhealthy { node, detail } => {
137 + format!("{node} unhealthy: {}", detail.chars().take(40).collect::<String>())
138 + }
139 + GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
140 + GateFailure::Timeout { gate, after_s } => format!("{gate} timeout {after_s}s"),
141 + GateFailure::Unclassified { legacy_detail: Some(d) } => {
142 + // Truncate aggressively — the events log is single-line.
143 + d.lines().next().unwrap_or("").chars().take(80).collect()
144 + }
145 + GateFailure::Unclassified { .. } => "unclassified".into(),
146 + }
147 + }
148 +
149 + pub(crate) fn deploy_failure_short(f: &DeployFailureKind) -> String {
150 + match f {
151 + DeployFailureKind::NodeUnreachable { .. } => "node unreachable".into(),
152 + DeployFailureKind::RsyncFailed { detail } => {
153 + // Show the rsync exit hint if present (e.g. "(28)" for ENOSPC).
154 + let first = detail.lines().next().unwrap_or("").trim_end();
155 + format!("rsync: {}", first.chars().take(80).collect::<String>())
156 + }
157 + DeployFailureKind::SymlinkSwapFailed { .. } => "symlink swap".into(),
158 + DeployFailureKind::ServiceRestartFailed { .. } => "service restart".into(),
159 + DeployFailureKind::Unclassified { detail } =>
160 + detail.lines().next().unwrap_or("").chars().take(80).collect(),
161 + }
162 + }
163 +
164 + pub(crate) fn blocker_short(b: &GateBlocker) -> String {
165 + match b {
166 + GateBlocker::BurnInClockNotStarted => "burn-in clock not started".into(),
167 + GateBlocker::BurnInRemaining { hours_remaining, hours_total } =>
168 + format!("{hours_remaining}h of {hours_total}h remaining"),
169 + GateBlocker::AwaitingOperatorConfirmation => "needs operator".into(),
170 + GateBlocker::NoBackupAvailable => "no backup".into(),
171 + GateBlocker::ScratchDbUrlUnset => "scratch_db_url unset".into(),
172 + GateBlocker::ArtifactMissing { version } => format!("no artifact for {version}"),
173 + GateBlocker::NoNodesToProbe => "no nodes to probe".into(),
174 + }
175 + }
176 +
@@ -40,6 +40,18 @@ use std::sync::{Arc, Mutex};
40 40 use std::time::Duration;
41 41 use tokio::sync::mpsc;
42 42
43 + mod model;
44 + mod actions;
45 + mod net;
46 + mod format;
47 + mod ui;
48 +
49 + pub(crate) use model::*;
50 + pub(crate) use actions::*;
51 + pub(crate) use net::*;
52 + pub(crate) use format::*;
53 + pub(crate) use ui::*;
54 +
43 55 // ---------- daemon types ----------
44 56 //
45 57 // `StateView` mirrors the JSON shape of `GET /state` from
@@ -47,177 +59,8 @@ use tokio::sync::mpsc;
47 59 // renders) — adding new fields on the daemon side is a non-breaking
48 60 // change as long as the field names stay stable.
49 61
50 - #[derive(Clone, Debug, Deserialize)]
51 - struct StateView {
52 - tiers: Vec<TierView>,
53 - }
54 -
55 - #[derive(Clone, Debug, Deserialize)]
56 - struct TierView {
57 - name: String,
58 - provisioned: bool,
59 - #[allow(dead_code)]
60 - canary: String,
61 - current_version: Option<String>,
62 - previous_version: Option<String>,
63 - burn_in_started_at: Option<String>,
64 - /// Non-null when the daemon left the tier in a partial / mixed-version state
65 - /// (a failed promote/rollback that couldn't fully restore consistency).
66 - #[serde(default)]
67 - partial_reason: Option<String>,
68 - nodes: Vec<String>,
69 - gates: Vec<GateView>,
70 - }
71 -
72 - #[derive(Clone, Debug, Deserialize)]
73 - struct GateView {
74 - kind: String,
75 - #[allow(dead_code)]
76 - finished_at: Option<String>,
77 - /// `'passed' | 'failed' | 'blocked'` or NULL. NULL = in-flight.
78 - #[serde(default)]
79 - status: Option<String>,
80 - /// Full typed `GateOutcome`, JSON object. We don't deserialize the
81 - /// inner shape on the polling path — the events handler does that
82 - /// when the gate finishes, since that's when classification actually
83 - /// matters for the operator's render. Carried here as opaque JSON.
84 - #[serde(default)]
85 - #[allow(dead_code)]
86 - outcome: Option<serde_json::Value>,
87 - #[serde(default)]
88 - #[allow(dead_code)]
89 - log_ref: Option<String>,
90 - }
91 -
92 62 // ---------- app state shared between tasks ----------
93 63
94 - #[derive(Default)]
95 - struct Shared {
96 - state: Option<StateView>,
97 - last_err: Option<String>,
98 - events: VecDeque<String>,
99 - ws_ok: bool,
100 - selected: usize,
101 - notice: Option<String>,
102 - /// Per-run live-tail buffers keyed by `GateRunId`. Populated by
103 - /// `GateStart` / `GateLogChunk` / `GateDone`. Bounded to the most
104 - /// recent `GATE_TAILS_CAP` runs (LRU by start time).
105 - gate_tails: BTreeMap<GateRunId, GateTail>,
106 - /// Run shown in the tail pane. Defaults to the most recently active
107 - /// run; updated as new `GateStart` events arrive.
108 - focus_run: Option<GateRunId>,
109 - /// A mutating action (promote/rollback/confirm) awaiting y/N confirmation.
110 - /// While `Some`, the key handler intercepts everything: `y`/Enter dispatches
111 - /// it, any other key cancels. Prevents a single fat-fingered keystroke from
112 - /// shipping or rolling back prod.
113 - pending: Option<Action>,
114 - }
115 -
116 - const EVENTS_CAP: usize = 200;
117 - const GATE_TAILS_CAP: usize = 10;
118 - const TAIL_LINES_CAP: usize = 200;
119 -
120 - /// Per-run live-tail state. Receives `GateLogChunk` bytes and presents
121 - /// them as a line-aware ring buffer. Chunks are NOT line-aligned at the
122 - /// transport — we buffer a trailing partial line across chunks so the UI
123 - /// only renders complete lines (and one trailing in-flight line, on
124 - /// `close`/finalize).
125 - struct GateTail {
126 - tier: TierId,
127 - version: Version,
128 - gate: GateKind,
129 - /// Complete lines, oldest first. Newest end of the deque is what the
130 - /// pane shows at the bottom.
131 - lines: VecDeque<String>,
132 - /// Trailing bytes after the last `\n` in any chunk so far. Flushed
133 - /// into `lines` on `finalize` (so the operator sees the last line
134 - /// even if the gate exited without a final newline).
135 - partial: String,
136 - status: TailStatus,
137 - }
138 -
139 - #[derive(Clone, Debug, PartialEq, Eq)]
140 - enum TailStatus {
141 - InFlight,
142 - Finished(String),
143 - }
144 -
145 - impl GateTail {
146 - fn new(tier: TierId, version: Version, gate: GateKind) -> Self {
147 - Self {
148 - tier,
149 - version,
150 - gate,
151 - lines: VecDeque::new(),
152 - partial: String::new(),
153 - status: TailStatus::InFlight,
154 - }
155 - }
156 -
157 - /// Append a chunk of stdout/stderr. Splits on `\n`, buffers the
158 - /// trailing partial line, caps the ring at `TAIL_LINES_CAP`.
159 - fn push_chunk(&mut self, text: &str) {
160 - let combined = std::mem::take(&mut self.partial) + text;
161 - let mut rest = combined.as_str();
162 - while let Some(idx) = rest.find('\n') {
163 - let (line, after) = rest.split_at(idx);
164 - self.push_line(line.trim_end_matches('\r').to_string());
165 - rest = &after[1..];
166 - }
167 - self.partial = rest.to_string();
168 - }
169 -
170 - fn push_line(&mut self, line: String) {
171 - if self.lines.len() >= TAIL_LINES_CAP {
172 - self.lines.pop_front();
173 - }
174 - self.lines.push_back(line);
175 - }
176 -
177 - /// Called when `GateDone` arrives. Flushes any trailing partial
178 - /// line and records the gate's final status word.
179 - fn finalize(&mut self, status_word: String) {
180 - let trailing = std::mem::take(&mut self.partial);
181 - if !trailing.is_empty() {
182 - self.push_line(trailing);
183 - }
184 - self.status = TailStatus::Finished(status_word);
185 - }
186 - }
187 -
188 - impl Shared {
189 - fn push_event(&mut self, line: String) {
190 - if self.events.len() >= EVENTS_CAP {
191 - self.events.pop_front();
192 - }
193 - self.events.push_back(line);
194 - }
195 -
196 - fn open_tail(&mut self, run_id: GateRunId, tier: TierId, version: Version, gate: GateKind) {
197 - if self.gate_tails.len() >= GATE_TAILS_CAP {
198 - // BTreeMap is sorted by GateRunId (monotonic from the daemon's
199 - // INSERT … RETURNING id), so the smallest key is the oldest run.
200 - if let Some((&oldest, _)) = self.gate_tails.iter().next() {
201 - self.gate_tails.remove(&oldest);
202 - }
203 - }
204 - self.gate_tails.insert(run_id, GateTail::new(tier, version, gate));
205 - self.focus_run = Some(run_id);
206 - }
207 -
208 - fn push_tail_chunk(&mut self, run_id: GateRunId, text: &str) {
209 - if let Some(t) = self.gate_tails.get_mut(&run_id) {
210 - t.push_chunk(text);
211 - }
212 - }
213 -
214 - fn finalize_tail(&mut self, run_id: GateRunId, status_word: String) {
215 - if let Some(t) = self.gate_tails.get_mut(&run_id) {
216 - t.finalize(status_word);
217 - }
218 - }
219 - }
220 -
221 64 // ---------- main ----------
222 65
223 66 /// Leave raw mode + the alternate screen and show the cursor. Best-effort:
@@ -285,759 +128,12 @@ fn main() -> Result<()> {
285 128
286 129 // ---------- background tasks ----------
287 130
288 - async fn state_poller(daemon: String, token: Option<String>, shared: Arc<Mutex<Shared>>) {
289 - let url = format!("{}/state", daemon);
290 - let client = reqwest::Client::new();
291 - loop {
292 - let mut req = client.get(&url).timeout(Duration::from_secs(2));
293 - if let Some(tok) = &token {
294 - req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}"));
295 - }
296 - match req.send().await {
297 - Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await {
298 - Ok(s) => {
299 - let mut g = shared.lock().unwrap();
300 - let n = s.tiers.len().saturating_sub(1);
301 - if g.selected > n {
302 - g.selected = n;
303 - }
304 - g.state = Some(s);
305 - g.last_err = None;
306 - }
307 - Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")),
308 - },
309 - Ok(resp) => {
310 - shared.lock().unwrap().last_err = Some(format!("status {}", resp.status()));
311 - }
312 - Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()),
313 - }
314 - tokio::time::sleep(Duration::from_secs(2)).await;
315 - }
316 - }
317 -
318 - async fn events_subscriber(daemon: String, token: Option<String>, shared: Arc<Mutex<Shared>>) {
319 - use tokio_tungstenite::tungstenite::client::IntoClientRequest;
320 - let ws_url = ws_url_from(&daemon);
321 -
322 - loop {
323 - // Build the upgrade request fresh each attempt so the bearer header (when
324 - // configured) rides the /events handshake — the daemon gates the WS route
325 - // like every other read. A malformed request is treated like a dial
326 - // failure: mark the stream down and retry.
327 - let request = match ws_url.as_str().into_client_request() {
328 - Ok(mut r) => {
329 - if let Some(tok) = &token
330 - && let Ok(val) = format!("Bearer {tok}").parse()
331 - {
332 - r.headers_mut().insert(
333 - tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
334 - val,
335 - );
336 - }
337 - r
338 - }
339 - Err(_) => {
340 - shared.lock().unwrap().ws_ok = false;
341 - tokio::time::sleep(Duration::from_secs(3)).await;
342 - continue;
343 - }
344 - };
345 - match tokio_tungstenite::connect_async(request).await {
346 - Ok((mut socket, _resp)) => {
347 - shared.lock().unwrap().ws_ok = true;
348 - while let Some(msg) = socket.next().await {
349 - match msg {
350 - Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => {
351 - dispatch_ws_frame(&shared, &t);
352 - }
353 - Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => break,
354 - Err(_) => break,
355 - _ => {}
356 - }
357 - }
358 - shared.lock().unwrap().ws_ok = false;
359 - }
360 - Err(_) => {
361 - shared.lock().unwrap().ws_ok = false;
362 - }
363 - }
364 - tokio::time::sleep(Duration::from_secs(3)).await;
365 - }
366 - }
367 -
368 - /// Convert the daemon's HTTP URL into the matching WebSocket URL for /events.
369 - /// Plain string replace; rejects nothing — bad input fails later when
370 - /// `connect_async` actually dials.
371 - fn ws_url_from(daemon: &str) -> String {
372 - daemon
373 - .replacen("https://", "wss://", 1)
374 - .replacen("http://", "ws://", 1)
375 - + "/events"
376 - }
377 -
378 - /// Route an incoming WS frame: gate live-tail events update the per-run
379 - /// buffer, every other event becomes one line in the events ring.
380 - ///
381 - /// `GateLogChunk` does NOT push into the events ring — a busy gate emits
382 - /// hundreds of chunks per second, which would evict every other event in
383 - /// under a minute. The chunk text only lives in the per-run tail buffer.
384 - fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) {
385 - if let Some(line) = format_lagged(raw) {
386 - shared.lock().unwrap().push_event(line);
387 - return;
388 - }
389 - let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else {
390 - // Unparseable frame — surface verbatim so the operator can see it.
391 - shared.lock().unwrap().push_event(raw.to_string());
392 - return;
393 - };
394 - match &env.event {
395 - Event::GateStart { run_id, tier, version, gate } => {
396 - let mut g = shared.lock().unwrap();
397 - g.open_tail(*run_id, tier.clone(), version.clone(), *gate);
398 - let line = format_event_line(&env);
399 - g.push_event(line);
400 - }
401 - Event::GateLogChunk { run_id, text, .. } => {
402 - shared.lock().unwrap().push_tail_chunk(*run_id, text);
403 - }
404 - Event::GateDone { run_id, outcome, .. } => {
405 - let status_word = outcome.status_str().to_string();
406 - let mut g = shared.lock().unwrap();
407 - g.finalize_tail(*run_id, status_word);
408 - let line = format_event_line(&env);
409 - g.push_event(line);
410 - }
411 - _ => {
412 - let line = format_event_line(&env);
413 - shared.lock().unwrap().push_event(line);
414 - }
415 - }
416 - }
417 -
418 - fn format_event_line(env: &EventEnvelope) -> String {
419 - let time = env.at.format("%H:%M:%S").to_string();
420 - let kind = event_kind_str(&env.event);
421 - let body = format_event_body(&env.event);
422 - format!("{time} {kind} {body}")
423 - }
424 -
425 - /// Render one event envelope into a human-readable single line.
426 - ///
427 - /// Step 5: deserializes the daemon's `EventEnvelope` directly via the
428 - /// shared `sando_daemon::events` types. No more `serde_json::Value`
429 - /// reflection; the compiler enforces every field name. The `lagged`
430 - /// frame is a daemon-emitted ad-hoc envelope that doesn't match the
431 - /// `Event` enum (it's `{"kind":"lagged","skipped":N}`), so we handle it
432 - /// with a one-shot probe before the typed parse.
433 - #[cfg(test)]
434 - fn format_event(json: &str) -> Option<String> {
435 - if let Some(line) = format_lagged(json) {
436 - return Some(line);
437 - }
438 - let env: EventEnvelope = serde_json::from_str(json).ok()?;
439 - Some(format_event_line(&env))
440 - }
441 -
442 - fn format_lagged(json: &str) -> Option<String> {
443 - let v: serde_json::Value = serde_json::from_str(json).ok()?;
444 - if v.get("kind").and_then(|x| x.as_str()) != Some("lagged") { return None; }
445 - let at = v.get("at").and_then(|x| x.as_str()).unwrap_or("");
446 - let time = at.get(11..19).unwrap_or("");
447 - let skipped = v.get("skipped")
448 - .and_then(|x| x.as_u64())
449 - .map(|n| n.to_string())
450 - .unwrap_or_else(|| "?".into());
451 - Some(format!("{time} lagged (skipped {skipped} events)"))
452 - }
453 -
454 - fn event_kind_str(e: &Event) -> &'static str {
455 - match e {
456 - Event::RebuildRequested { .. } => "rebuild_requested",
457 - Event::BuildAborted { .. } => "build_aborted",
458 - Event::BuildStart { .. } => "build_start",
459 - Event::BuildOk { .. } => "build_ok",
460 - Event::BuildFailed { .. } => "build_failed",
461 - Event::GateStart { .. } => "gate_start",
462 - Event::GateLogChunk { .. } => "gate_log",
463 - Event::GateDone { .. } => "gate_done",
464 - Event::DeployStart { .. } => "deploy_start",
465 - Event::DeployOk { .. } => "deploy_ok",
466 - Event::DeployFailed { .. } => "deploy_failed",
467 - Event::PromoteComplete { .. } => "promote_complete",
468 - Event::Rollback { .. } => "rollback",
469 - Event::BackupFetched { .. } => "backup_fetched",
470 - Event::ManualConfirm { .. } => "manual_confirm",
471 - }
472 - }
473 -
474 - fn format_event_body(e: &Event) -> String {
475 - match e {
476 - Event::RebuildRequested { sha } => format!("sha={}", sha.short()),
477 - Event::BuildAborted { sha_aborted } => format!("aborted prev sha={}", sha_aborted.short()),
478 - Event::BuildStart { sha, version } => format!("v={version} sha={}", sha.short()),
479 - Event::BuildOk { version, elapsed_s, .. } => format!("v={version} ({elapsed_s}s)"),
480 - Event::BuildFailed { version, elapsed_s, .. } => format!("v={version} FAILED ({elapsed_s}s)"),
481 - Event::GateStart { run_id, tier, version, gate } =>
482 - format!("[{run_id}] {tier}/{version} {gate} start"),
483 - Event::GateLogChunk { run_id, seq, text } => {
484 - // Single-line summary used by `format_event` (test path only);
485 - // production rendering routes chunks through `dispatch_ws_frame`
486 - // into the per-run tail buffer, never the events ring.
487 - let one = text.lines().next().unwrap_or("").trim_end();
488 - let truncated: String = one.chars().take(160).collect();
489 - format!("[{run_id} #{seq}] {truncated}")
490 - }
491 - Event::GateDone { run_id, tier, version, gate, outcome, .. } => {
492 - let summary = match &outcome.status {
493 - GateStatus::Passed { note } => format!("ok ({})", pass_note_short(note)),
494 - GateStatus::Failed { failure } => format!("FAIL ({})", failure_short(failure)),
495 - GateStatus::Blocked { blocker } => format!("blocked ({})", blocker_short(blocker)),
496 - };
497 - format!("[{run_id}] {tier}/{version} {gate} {summary}")
498 - }
499 - Event::DeployStart { tier, node, version } => format!("{tier} -> {node} v={version}"),
500 - Event::DeployOk { tier, node, version } => format!("{tier} -> {node} v={version} ok"),
501 - Event::DeployFailed { tier, node, version, failure } =>
502 - format!("{tier} -> {node} v={version} FAILED: {}", deploy_failure_short(failure)),
503 - Event::PromoteComplete { tier, version } => format!("{tier} -> v={version} complete"),
504 - Event::Rollback { tier, from, to } => format!("{tier} {from} -> {to}"),
505 - Event::BackupFetched { source, byte_size } => format!("backup {byte_size} bytes from {source}"),
506 - Event::ManualConfirm { tier, version } => format!("{tier} v={version} confirmed"),
507 - }
508 - }
509 -
510 - fn pass_note_short(n: &PassNote) -> String {
511 - match n {
512 - PassNote::HealthyProbe { after_ms } => format!("/health {after_ms}ms"),
513 - PassNote::BurnInElapsed { hours } => format!("{hours}h elapsed"),
514 - PassNote::Migrated { backup_path } => {
515 - // Show just the basename; full path lives in /state if needed.
516 - let name = backup_path.rsplit('/').next().unwrap_or(backup_path);
517 - format!("migrated {name}")
518 - }
519 - PassNote::TestsPassed { duration_s } => format!("{duration_s}s"),
520 - PassNote::OperatorConfirmed { .. } => "operator".into(),
521 - PassNote::NodesHealthy { nodes } => format!("{nodes} node(s) healthy"),
522 - PassNote::Legacy { text } => text.clone(),
523 - }
524 - }
525 -
526 - fn failure_short(f: &GateFailure) -> String {
527 - match f {
528 - GateFailure::CargoTest { failed_count, first_panic: Some(p), .. } =>
529 - format!("{failed_count} test(s); panic: {p}"),
530 - GateFailure::CargoTest { failed_count, first_failed: Some(name), .. } =>
531 - format!("{failed_count} test(s); first {name}"),
532 - GateFailure::CargoTest { failed_count, .. } => format!("{failed_count} test(s) failed"),
533 - GateFailure::CompileError { error_count, first_error: Some(e) } =>
534 - format!("compile failed ({error_count}); {e}"),
535 - GateFailure::CompileError { error_count, .. } => format!("compile failed ({error_count})"),
536 - GateFailure::MigrationDrift { migration } => format!("drift {migration}"),
537 - GateFailure::MigrationModified { migration } => format!("modified {migration}"),
538 - GateFailure::MigrationSqlError { migration, sqlstate: Some(s) } =>
539 - format!("sql {migration} {s}"),
540 - GateFailure::MigrationSqlError { migration, .. } => format!("sql {migration}"),
541 - GateFailure::RestoreFailed { reason } => format!("restore: {reason}"),
542 - GateFailure::BootPanic { exit_code: Some(c) } => format!("panic exit {c}"),
543 - GateFailure::BootPanic { .. } => "panic".into(),
544 - GateFailure::BootExitedEarly { exit_code: Some(c) } => format!("exited {c}"),
545 - GateFailure::BootExitedEarly { .. } => "exited early".into(),
546 - GateFailure::BootHealthProbeFailed { last_error } => {
547 - format!("no /health: {}", last_error.chars().take(40).collect::<String>())
548 - }
549 - GateFailure::NodeUnhealthy { node, detail } => {
550 - format!("{node} unhealthy: {}", detail.chars().take(40).collect::<String>())
551 - }
552 - GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
553 - GateFailure::Timeout { gate, after_s } => format!("{gate} timeout {after_s}s"),
554 - GateFailure::Unclassified { legacy_detail: Some(d) } => {
555 - // Truncate aggressively — the events log is single-line.
556 - d.lines().next().unwrap_or("").chars().take(80).collect()
557 - }
558 - GateFailure::Unclassified { .. } => "unclassified".into(),
559 - }
560 - }
561 -
562 - /// Pick the one-word mark + color for a gate row in the tiers table.
563 - /// `status` is NULL while the gate is in-flight.
564 - fn gate_mark_and_style(g: &GateView) -> (&'static str, Style) {
565 - match g.status.as_deref() {
566 - Some("passed") => ("ok", Style::default().fg(Color::Green)),
567 - Some("failed") => ("FAIL", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
568 - Some("blocked") => ("blocked", Style::default().fg(Color::Yellow)),
569 - // A genuinely unknown status string from the daemon is a contract drift,
570 - // not an in-flight gate — surface it distinctly rather than masking it as "...".
571 - Some(_) => ("?", Style::default().fg(Color::Magenta)),
572 - None => ("...", Style::default().fg(Color::DarkGray)),
573 - }
574 - }
575 -
576 - fn deploy_failure_short(f: &DeployFailureKind) -> String {
577 - match f {
578 - DeployFailureKind::NodeUnreachable { .. } => "node unreachable".into(),
579 - DeployFailureKind::RsyncFailed { detail } => {
580 - // Show the rsync exit hint if present (e.g. "(28)" for ENOSPC).
581 - let first = detail.lines().next().unwrap_or("").trim_end();
582 - format!("rsync: {}", first.chars().take(80).collect::<String>())
583 - }
584 - DeployFailureKind::SymlinkSwapFailed { .. } => "symlink swap".into(),
585 - DeployFailureKind::ServiceRestartFailed { .. } => "service restart".into(),
586 - DeployFailureKind::Unclassified { detail } =>
587 - detail.lines().next().unwrap_or("").chars().take(80).collect(),
588 - }
589 - }
Lines truncated
@@ -0,0 +1,173 @@
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(&mut self, run_id: GateRunId, tier: TierId, version: Version, gate: GateKind) {
150 + if self.gate_tails.len() >= GATE_TAILS_CAP {
151 + // BTreeMap is sorted by GateRunId (monotonic from the daemon's
152 + // INSERT … RETURNING id), so the smallest key is the oldest run.
153 + if let Some((&oldest, _)) = self.gate_tails.iter().next() {
154 + self.gate_tails.remove(&oldest);
155 + }
156 + }
157 + self.gate_tails.insert(run_id, GateTail::new(tier, version, gate));
158 + self.focus_run = Some(run_id);
159 + }
160 +
161 + pub(crate) fn push_tail_chunk(&mut self, run_id: GateRunId, text: &str) {
162 + if let Some(t) = self.gate_tails.get_mut(&run_id) {
163 + t.push_chunk(text);
164 + }
165 + }
166 +
167 + pub(crate) fn finalize_tail(&mut self, run_id: GateRunId, status_word: String) {
168 + if let Some(t) = self.gate_tails.get_mut(&run_id) {
169 + t.finalize(status_word);
170 + }
171 + }
172 + }
173 +
@@ -0,0 +1,170 @@
1 + //! sando-tui net module (split out of main.rs).
2 +
3 + use crate::*;
4 +
5 + pub(crate) async fn state_poller(daemon: String, token: Option<String>, shared: Arc<Mutex<Shared>>) {
6 + let url = format!("{}/state", daemon);
7 + let client = reqwest::Client::new();
8 + loop {
9 + let mut req = client.get(&url).timeout(Duration::from_secs(2));
10 + if let Some(tok) = &token {
11 + req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}"));
12 + }
13 + match req.send().await {
14 + Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await {
15 + Ok(s) => {
16 + let mut g = shared.lock().unwrap();
17 + let n = s.tiers.len().saturating_sub(1);
18 + if g.selected > n {
19 + g.selected = n;
20 + }
21 + g.state = Some(s);
22 + g.last_err = None;
23 + }
24 + Err(e) => shared.lock().unwrap().last_err = Some(format!("decode: {e}")),
25 + },
26 + Ok(resp) => {
27 + shared.lock().unwrap().last_err = Some(format!("status {}", resp.status()));
28 + }
29 + Err(e) => shared.lock().unwrap().last_err = Some(e.to_string()),
30 + }
31 + tokio::time::sleep(Duration::from_secs(2)).await;
32 + }
33 + }
34 +
35 + pub(crate) async fn events_subscriber(daemon: String, token: Option<String>, shared: Arc<Mutex<Shared>>) {
36 + use tokio_tungstenite::tungstenite::client::IntoClientRequest;
37 + let ws_url = ws_url_from(&daemon);
38 +
39 + loop {
40 + // Build the upgrade request fresh each attempt so the bearer header (when
41 + // configured) rides the /events handshake — the daemon gates the WS route
42 + // like every other read. A malformed request is treated like a dial
43 + // failure: mark the stream down and retry.
44 + let request = match ws_url.as_str().into_client_request() {
45 + Ok(mut r) => {
46 + if let Some(tok) = &token
47 + && let Ok(val) = format!("Bearer {tok}").parse()
48 + {
49 + r.headers_mut().insert(
50 + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
51 + val,
52 + );
53 + }
54 + r
55 + }
56 + Err(_) => {
57 + shared.lock().unwrap().ws_ok = false;
58 + tokio::time::sleep(Duration::from_secs(3)).await;
59 + continue;
60 + }
61 + };
62 + match tokio_tungstenite::connect_async(request).await {
63 + Ok((mut socket, _resp)) => {
64 + shared.lock().unwrap().ws_ok = true;
65 + while let Some(msg) = socket.next().await {
66 + match msg {
67 + Ok(tokio_tungstenite::tungstenite::Message::Text(t)) => {
68 + dispatch_ws_frame(&shared, &t);
69 + }
70 + Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => break,
71 + Err(_) => break,
72 + _ => {}
73 + }
74 + }
75 + shared.lock().unwrap().ws_ok = false;
76 + }
77 + Err(_) => {
78 + shared.lock().unwrap().ws_ok = false;
79 + }
80 + }
81 + tokio::time::sleep(Duration::from_secs(3)).await;
82 + }
83 + }
84 +
85 + /// Convert the daemon's HTTP URL into the matching WebSocket URL for /events.
86 + /// Plain string replace; rejects nothing — bad input fails later when
87 + /// `connect_async` actually dials.
88 + pub(crate) fn ws_url_from(daemon: &str) -> String {
89 + daemon
90 + .replacen("https://", "wss://", 1)
91 + .replacen("http://", "ws://", 1)
92 + + "/events"
93 + }
94 +
95 + /// Route an incoming WS frame: gate live-tail events update the per-run
96 + /// buffer, every other event becomes one line in the events ring.
97 + ///
98 + /// `GateLogChunk` does NOT push into the events ring — a busy gate emits
99 + /// hundreds of chunks per second, which would evict every other event in
100 + /// under a minute. The chunk text only lives in the per-run tail buffer.
101 + pub(crate) fn dispatch_ws_frame(shared: &Arc<Mutex<Shared>>, raw: &str) {
102 + if let Some(line) = format_lagged(raw) {
103 + shared.lock().unwrap().push_event(line);
104 + return;
105 + }
106 + let Ok(env) = serde_json::from_str::<EventEnvelope>(raw) else {
107 + // Unparseable frame — surface verbatim so the operator can see it.
108 + shared.lock().unwrap().push_event(raw.to_string());
109 + return;
110 + };
111 + match &env.event {
112 + Event::GateStart { run_id, tier, version, gate } => {
113 + let mut g = shared.lock().unwrap();
114 + g.open_tail(*run_id, tier.clone(), version.clone(), *gate);
115 + let line = format_event_line(&env);
116 + g.push_event(line);
117 + }
118 + Event::GateLogChunk { run_id, text, .. } => {
119 + shared.lock().unwrap().push_tail_chunk(*run_id, text);
120 + }
121 + Event::GateDone { run_id, outcome, .. } => {
122 + let status_word = outcome.status_str().to_string();
123 + let mut g = shared.lock().unwrap();
124 + g.finalize_tail(*run_id, status_word);
125 + let line = format_event_line(&env);
126 + g.push_event(line);
127 + }
128 + _ => {
129 + let line = format_event_line(&env);
130 + shared.lock().unwrap().push_event(line);
131 + }
132 + }
133 + }
134 +
135 + pub(crate) async fn dispatch_action(daemon: &str, act: &Action) -> Result<String> {
136 + let client = reqwest::Client::new();
137 + let url = match act {
138 + Action::BackupFetch => format!("{daemon}/backup/fetch"),
139 + Action::Promote { tier } => format!("{daemon}/promote/{tier}"),
140 + Action::Rollback { tier } => format!("{daemon}/rollback/{tier}"),
141 + Action::Confirm { tier } => format!("{daemon}/confirm/{tier}"),
142 + };
143 + let mut req = client.post(&url).timeout(Duration::from_secs(120));
144 + // Deploy mutators require a bearer token when the daemon is configured with
145 + // one (CF2). The operator supplies it via SANDO_API_TOKEN, the same value in
146 + // the daemon's EnvironmentFile.
147 + if let Ok(tok) = std::env::var("SANDO_API_TOKEN") {
148 + let tok = tok.trim();
149 + if !tok.is_empty() {
150 + req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}"));
151 + }
152 + }
153 + // /promote accepts an empty body now (defaults version to predecessor's
154 + // current). The other endpoints take no body.
155 + if matches!(act, Action::Promote { .. }) {
156 + req = req.header("Content-Type", "application/json").body("{}");
157 + }
158 + let resp = req.send().await.context("send")?;
159 + let status = resp.status();
160 + // Distinguish a body-read failure from an empty body: on a rejected promote the
161 + // daemon's structured error message is the operator's only signal, and silently
162 + // turning a read hiccup into "" renders as a contentless "HTTP 409: ".
163 + match (status.is_success(), resp.text().await) {
164 + (true, Ok(body)) => Ok(truncate(&body, 80)),
165 + (true, Err(e)) => Err(anyhow::anyhow!("HTTP {status} but response body unreadable: {e}")),
166 + (false, Ok(body)) => Err(anyhow::anyhow!("HTTP {status}: {}", truncate(&body, 200))),
167 + (false, Err(e)) => Err(anyhow::anyhow!("HTTP {status} (response body unreadable: {e})")),
168 + }
169 + }
170 +
@@ -0,0 +1,395 @@
1 + //! sando-tui ui module (split out of main.rs).
2 +
3 + use ratatui::prelude::*;
4 + use crate::*;
5 +
6 + /// Pick the one-word mark + color for a gate row in the tiers table.
7 + /// `status` is NULL while the gate is in-flight.
8 + pub(crate) fn gate_mark_and_style(g: &GateView) -> (&'static str, Style) {
9 + match g.status.as_deref() {
10 + Some("passed") => ("ok", Style::default().fg(Color::Green)),
11 + Some("failed") => ("FAIL", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
12 + Some("blocked") => ("blocked", Style::default().fg(Color::Yellow)),
13 + // A genuinely unknown status string from the daemon is a contract drift,
14 + // not an in-flight gate — surface it distinctly rather than masking it as "...".
15 + Some(_) => ("?", Style::default().fg(Color::Magenta)),
16 + None => ("...", Style::default().fg(Color::DarkGray)),
17 + }
18 + }
19 +
20 + pub(crate) fn ui_loop<B: Backend>(
21 + term: &mut Terminal<B>,
22 + daemon: &str,
23 + shared: &Arc<Mutex<Shared>>,
24 + rt: &tokio::runtime::Handle,
25 + ) -> Result<()> {
26 + let (action_tx, mut action_rx) = mpsc::channel::<Action>(32);
27 +
28 + // Action dispatcher task: posts to daemon, drops notice on Shared.
29 + {
30 + let shared = shared.clone();
31 + let daemon = daemon.to_string();
32 + rt.spawn(async move {
33 + while let Some(act) = action_rx.recv().await {
34 + let res = dispatch_action(&daemon, &act).await;
35 + let line = match res {
36 + Ok(msg) => format!("[ok] {act}: {msg}"),
37 + Err(e) => format!("[err] {act}: {e}"),
38 + };
39 + let mut g = shared.lock().unwrap();
40 + g.notice = Some(line.clone());
41 + g.push_event(format!(" action {line}"));
42 + }
43 + });
44 + }
45 +
46 + loop {
47 + term.draw(|f| draw(f, daemon, shared))?;
48 +
49 + if event::poll(Duration::from_millis(120))?
50 + && let XEvent::Key(k) = event::read()?
51 + {
52 + {
53 + // Ctrl+C always quits, even mid-confirmation.
54 + if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) {
55 + return Ok(());
56 + }
57 +
58 + // A confirmation is pending: this keystroke answers it and nothing
59 + // else. y/Enter dispatches the held action; any other key cancels.
60 + // This is the guard that turns p/R/c from a single fat-fingerable
61 + // keystroke into a deliberate two-step (prod manual_confirm included).
62 + let pending = shared.lock().unwrap().pending.take();
63 + if let Some(act) = pending {
64 + match k.code {
65 + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
66 + // Don't swallow a full-channel drop: the operator must not
67 + // believe a promote was sent when it wasn't.
68 + if let Err(e) = action_tx.try_send(act) {
69 + let act = e.into_inner();
70 + shared.lock().unwrap().notice =
71 + Some(format!("{act} not sent (daemon busy) - retry"));
72 + }
73 + }
74 + _ => {
75 + shared.lock().unwrap().notice = Some(format!("{act} cancelled"));
76 + }
77 + }
78 + continue;
79 + }
80 +
81 + let selected_tier = {
82 + let g = shared.lock().unwrap();
83 + g.state
84 + .as_ref()
85 + .and_then(|s| s.tiers.get(g.selected).cloned())
86 + };
87 +
88 + match k.code {
89 + KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
90 + KeyCode::Up | KeyCode::Char('k') => {
91 + let mut g = shared.lock().unwrap();
92 + g.selected = g.selected.saturating_sub(1);
93 + }
94 + KeyCode::Down | KeyCode::Char('j') => {
95 + let mut g = shared.lock().unwrap();
96 + let max = g
97 + .state
98 + .as_ref()
99 + .map(|s| s.tiers.len().saturating_sub(1))
100 + .unwrap_or(0);
101 + if g.selected < max {
102 + g.selected += 1;
103 + }
104 + }
105 + KeyCode::Char('r') => {
106 + // Nudge: state poller wakes every 2s anyway; this just notes intent.
107 + shared.lock().unwrap().notice = Some("refresh on next tick".into());
108 + }
109 + KeyCode::Char('b') => {
110 + // Every mutating action stages a confirmation; the actual
111 + // dispatch (action_tx.send) lives ONLY in the pending/confirm
112 + // branch above, so no mutator fires on a single keystroke —
113 + // backup/fetch included (it was the one that still did).
114 + shared.lock().unwrap().pending = Some(Action::BackupFetch);
115 + }
116 + KeyCode::Char('p') => {
117 + if let Some(t) = selected_tier {
118 + shared.lock().unwrap().pending = Some(Action::Promote { tier: t.name });
119 + }
120 + }
121 + KeyCode::Char('R') => {
122 + if let Some(t) = selected_tier {
123 + shared.lock().unwrap().pending = Some(Action::Rollback { tier: t.name });
124 + }
125 + }
126 + KeyCode::Char('c') => {
127 + if let Some(t) = selected_tier {
128 + shared.lock().unwrap().pending = Some(Action::Confirm { tier: t.name });
129 + }
130 + }
131 + KeyCode::Char('[') => {
132 + let mut g = shared.lock().unwrap();
133 + g.focus_run = cycle_focus(&g.gate_tails, g.focus_run, -1);
134 + }
135 + KeyCode::Char(']') => {
136 + let mut g = shared.lock().unwrap();
137 + g.focus_run = cycle_focus(&g.gate_tails, g.focus_run, 1);
138 + }
139 + _ => {}
140 + }
141 + }
142 + }
143 + }
144 + }
145 +
146 + pub(crate) fn truncate(s: &str, n: usize) -> String {
147 + if s.chars().count() <= n {
148 + s.into()
149 + } else {
150 + s.chars().take(n).collect::<String>() + "…"
151 + }
152 + }
153 +
154 + pub(crate) fn draw(f: &mut Frame, daemon: &str, shared: &Arc<Mutex<Shared>>) {
155 + let g = shared.lock().unwrap();
156 + let chunks = Layout::default()
157 + .direction(Direction::Vertical)
158 + .constraints([
159 + Constraint::Length(3), // header
160 + Constraint::Length(10), // tiers
161 + Constraint::Min(4), // events
162 + Constraint::Length(8), // gate tail
163 + Constraint::Length(2), // status
164 + ])
165 + .split(f.area());
166 +
167 + let ws_label = if g.ws_ok { "ws ok" } else { "ws ..." };
168 + let header = Paragraph::new(format!("sando -> {daemon} ({ws_label})"))
169 + .block(Block::default().title("daemon").borders(Borders::ALL));
170 + f.render_widget(header, chunks[0]);
171 +
172 + // Tiers table
173 + if let Some(s) = g.state.as_ref() {
174 + let hr = Row::new(vec![
175 + " ", "tier", "prov", "current", "previous", "burn-in", "nodes", "gates",
176 + ])
177 + .style(Style::default().add_modifier(Modifier::BOLD));
178 +
179 + let rows: Vec<Row> = s
180 + .tiers
181 + .iter()
182 + .enumerate()
183 + .map(|(i, t)| {
184 + let marker = if i == g.selected { ">" } else { " " };
185 + let gates = if t.gates.is_empty() {
186 + Line::from("-")
187 + } else {
188 + // Step 5: render Passed (green) / Failed (red) /
189 + // Blocked (yellow) / in-flight (default) from the
190 + // typed `status` column. Falls back to `passed` for
191 + // rows written before migration 003 (which the
192 + // backfill should have set, but defensive anyway).
193 + let mut spans: Vec<Span> = Vec::new();
194 + for (idx, g) in t.gates.iter().enumerate() {
195 + if idx > 0 { spans.push(Span::raw(" ")); }
196 + let (mark, style) = gate_mark_and_style(g);
197 + spans.push(Span::raw(format!("{}:", g.kind)));
198 + spans.push(Span::styled(mark, style));
199 + }
200 + Line::from(spans)
201 + };
202 + // A partial tier renders its name red and prefixed with "!" so the
203 + // mixed-version condition is visible at a glance; the full reason
204 + // goes in the status line below.
205 + let name_cell: ratatui::widgets::Cell = if t.partial_reason.is_some() {
206 + Span::styled(
207 + format!("!{}", t.name),
208 + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
209 + )
210 + .into()
211 + } else {
212 + t.name.clone().into()
213 + };
214 + let cells: Vec<ratatui::widgets::Cell> = vec![
215 + marker.to_string().into(),
216 + name_cell,
217 + if t.provisioned { "yes".into() } else { "no".into() },
218 + t.current_version.clone().unwrap_or_else(|| "-".into()).into(),
219 + t.previous_version.clone().unwrap_or_else(|| "-".into()).into(),
220 + t.burn_in_started_at
221 + .as_deref()
222 + .and_then(|s| s.get(11..19))
223 + .unwrap_or("-")
224 + .to_string()
225 + .into(),
226 + if t.nodes.is_empty() { "-".into() } else { t.nodes.join(",").into() },
227 + gates.into(),
228 + ];
229 + let row = Row::new(cells);
230 + if i == g.selected {
231 + row.style(Style::default().add_modifier(Modifier::REVERSED))
232 + } else {
233 + row
234 + }
235 + })
236 + .collect();
237 +
238 + let widths = [
239 + Constraint::Length(2),
240 + Constraint::Length(8),
241 + Constraint::Length(5),
242 + Constraint::Length(12),
243 + Constraint::Length(12),
244 + Constraint::Length(10),
245 + Constraint::Length(20),
246 + Constraint::Min(20),
247 + ];
248 + let table = Table::new(rows, widths).header(hr).block(
249 + Block::default()
250 + .title(format!("tiers ({}) ↑/↓ select", s.tiers.len()))
251 + .borders(Borders::ALL),
252 + );
253 + f.render_widget(table, chunks[1]);
254 + } else {
255 + let msg = g
256 + .last_err
257 + .clone()
258 + .unwrap_or_else(|| "loading...".into());
259 + let placeholder =
260 + Paragraph::new(msg).block(Block::default().title("tiers").borders(Borders::ALL));
261 + f.render_widget(placeholder, chunks[1]);
262 + }
263 +
264 + // Events
265 + let area = chunks[2];
266 + let visible = area.height.saturating_sub(2) as usize; // minus borders
267 + let items: Vec<ListItem> = g
268 + .events
269 + .iter()
270 + .rev()
271 + .take(visible)
272 + .rev()
273 + .map(|line| ListItem::new(line.clone()))
274 + .collect();
275 + let events_block = List::new(items).block(
276 + Block::default()
277 + .title(format!("events ({})", g.events.len()))
278 + .borders(Borders::ALL),
279 + );
280 + f.render_widget(events_block, area);
281 +
282 + // Gate tail (per-run live log buffer).
283 + let tail_area = chunks[3];
284 + let tail = g.focus_run.and_then(|id| g.gate_tails.get(&id).map(|t| (id, t)));
285 + let tail_widget = match tail {
286 + Some((id, t)) => {
287 + let title_state = match &t.status {
288 + TailStatus::InFlight => "in-flight".to_string(),
289 + TailStatus::Finished(s) => s.clone(),
290 + };
291 + let title = format!(
292 + "tail [{}] {}/{} {} — {title_state} ([) prev / (]) next {} of {}",
293 + id, t.tier, t.version, t.gate,
294 + tail_position(&g.gate_tails, id), g.gate_tails.len(),
295 + );
296 + let visible = tail_area.height.saturating_sub(2) as usize;
297 + let items: Vec<ListItem> = t.lines
298 + .iter()
299 + .rev()
300 + .take(visible)
301 + .rev()
302 + .map(|l| ListItem::new(truncate(l, 200)))
303 + .collect();
304 + List::new(items).block(Block::default().title(title).borders(Borders::ALL))
305 + }
306 + None => {
307 + let body = "no recent gate runs";
308 + List::new(vec![ListItem::new(body)])
309 + .block(Block::default().title("tail").borders(Borders::ALL))
310 + }
311 + };
312 + f.render_widget(tail_widget, tail_area);
313 +
314 + // Status / keys
315 + let keys = "[p] promote [R] rollback [b] backup [c] confirm [↑↓] select [[/]] tail [r] refresh [q] quit";
316 + // A partial/mixed-version tier is the operator's most urgent signal — it
317 + // outranks transient notices and errors and renders red until resolved.
318 + let partial = g.state.as_ref().and_then(|s| {
319 + s.tiers.iter().find_map(|t| {
320 + t.partial_reason.as_ref().map(|r| format!("PARTIAL {}: {r}", t.name))
321 + })
322 + });
323 + if let Some(p) = partial {
324 + f.render_widget(
325 + Paragraph::new(format!("{p} {keys}"))
326 + .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
327 + chunks[4],
328 + );
329 + } else {
330 + let status = if let Some(n) = &g.notice {
331 + format!("{n} {keys}")
332 + } else if let Some(e) = &g.last_err {
333 + format!("error: {e} {keys}")
334 + } else {
335 + keys.into()
336 + };
337 + f.render_widget(Paragraph::new(status), chunks[4]);
338 + }
339 +
340 + // Confirmation modal: drawn last so it overlays everything. A pending
341 + // promote/rollback/confirm cannot be discharged without an explicit y.
342 + if let Some(act) = &g.pending {
343 + let area = centered_rect(54, 7, f.area());
344 + f.render_widget(Clear, area);
345 + let prompt = Paragraph::new(vec![
346 + Line::from(""),
347 + Line::from(Span::styled(
348 + format!(" {act}"),
349 + Style::default().add_modifier(Modifier::BOLD),
350 + )),
351 + Line::from(""),
352 + Line::from(" [y] confirm [any other key] cancel"),
353 + ])
354 + .block(
355 + Block::default()
356 + .title("confirm")
357 + .borders(Borders::ALL)
358 + .border_style(Style::default().fg(Color::Yellow)),
359 + );
360 + f.render_widget(prompt, area);
361 + }
362 + }
363 +
364 + /// A `w`×`h` rectangle centered within `area` (clamped to fit).
365 + pub(crate) fn centered_rect(w: u16, h: u16, area: Rect) -> Rect {
366 + let w = w.min(area.width);
367 + let h = h.min(area.height);
368 + let x = area.x + (area.width.saturating_sub(w)) / 2;
369 + let y = area.y + (area.height.saturating_sub(h)) / 2;
370 + Rect { x, y, width: w, height: h }
371 + }
372 +
373 + /// 1-based index of `id` within the sorted tail map; 0 if not present.
374 + pub(crate) fn tail_position(tails: &BTreeMap<GateRunId, GateTail>, id: GateRunId) -> usize {
375 + tails.keys().position(|k| *k == id).map(|i| i + 1).unwrap_or(0)
376 + }
377 +
378 + /// Move `focus_run` by `step` positions through the sorted run map.
379 + /// Wraps at both ends. Returns the new focus, or `None` if the map is
380 + /// empty.
381 + pub(crate) fn cycle_focus(
382 + tails: &BTreeMap<GateRunId, GateTail>,
383 + current: Option<GateRunId>,
384 + step: i32,
385 + ) -> Option<GateRunId> {
386 + if tails.is_empty() { return None; }
387 + let keys: Vec<GateRunId> = tails.keys().copied().collect();
388 + let idx = current
389 + .and_then(|c| keys.iter().position(|k| *k == c))
390 + .unwrap_or(keys.len().saturating_sub(1));
391 + let len = keys.len() as i32;
392 + let new_idx = ((idx as i32 + step) % len + len) % len;
393 + Some(keys[new_idx as usize])
394 + }
395 +