max / makenotwork
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 | + |