use anyhow::Result; use crossterm::event::{self, Event, KeyCode}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; use serde::Deserialize; use std::io; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; #[derive(Clone, Debug, Deserialize)] struct StateView { tiers: Vec, } #[derive(Clone, Debug, Deserialize)] struct TierView { name: String, provisioned: bool, canary: String, current_version: Option, previous_version: Option, burn_in_started_at: Option, nodes: Vec, gates: Vec, } #[derive(Clone, Debug, Deserialize)] struct GateView { kind: String, passed: Option, finished_at: Option, } struct App { daemon: String, state: Arc>>, last_err: Arc>>, } fn main() -> Result<()> { let daemon = std::env::var("SANDO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7766".into()); let app = App { daemon, state: Arc::new(Mutex::new(None)), last_err: Arc::new(Mutex::new(None)), }; let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; enable_raw_mode()?; let mut stdout = io::stdout(); crossterm::execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut term = Terminal::new(backend)?; let res = run(&mut term, &app, &rt); disable_raw_mode()?; crossterm::execute!(term.backend_mut(), LeaveAlternateScreen)?; term.show_cursor()?; res } fn run(term: &mut Terminal, app: &App, rt: &tokio::runtime::Runtime) -> Result<()> { let mut next_poll = Instant::now(); loop { if Instant::now() >= next_poll { poll_state(app, rt); next_poll = Instant::now() + Duration::from_secs(2); } term.draw(|f| draw(f, app))?; if event::poll(Duration::from_millis(150))? { if let Event::Key(k) = event::read()? { match k.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Char('r') => next_poll = Instant::now(), _ => {} } } } } } fn poll_state(app: &App, rt: &tokio::runtime::Runtime) { let url = format!("{}/state", app.daemon); let result: std::result::Result = rt.block_on(async { let resp = reqwest::Client::new() .get(&url) .timeout(Duration::from_secs(2)) .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(format!("status {}", resp.status())); } resp.json::().await.map_err(|e| e.to_string()) }); match result { Ok(s) => { *app.state.lock().unwrap() = Some(s); *app.last_err.lock().unwrap() = None; } Err(e) => *app.last_err.lock().unwrap() = Some(e), } } fn draw(f: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(2)]) .split(f.area()); let header = Paragraph::new(format!("sando -> {}", app.daemon)) .block(Block::default().title("daemon").borders(Borders::ALL)); f.render_widget(header, chunks[0]); let state = app.state.lock().unwrap().clone(); let err = app.last_err.lock().unwrap().clone(); if let Some(s) = state { let header_row = Row::new(vec![ "tier", "prov", "current", "previous", "burn-in", "nodes", "gates", ]) .style(Style::default().add_modifier(Modifier::BOLD)); let rows: Vec = s.tiers.iter().map(|t| { let gates = if t.gates.is_empty() { "-".into() } else { t.gates.iter().map(|g| { let mark = match g.passed { Some(true) => "ok", Some(false) => "fail", None => "?", }; format!("{}:{}", g.kind, mark) }).collect::>().join(" ") }; Row::new(vec![ t.name.clone(), if t.provisioned { "yes".into() } else { "no".into() }, t.current_version.clone().unwrap_or_else(|| "-".into()), t.previous_version.clone().unwrap_or_else(|| "-".into()), t.burn_in_started_at.clone().unwrap_or_else(|| "-".into()), if t.nodes.is_empty() { "-".into() } else { t.nodes.join(",") }, gates, ]) }).collect(); let widths = [ Constraint::Length(8), Constraint::Length(5), Constraint::Length(12), Constraint::Length(12), Constraint::Length(22), Constraint::Length(24), Constraint::Min(20), ]; let table = Table::new(rows, widths) .header(header_row) .block(Block::default().title(format!("tiers ({})", s.tiers.len())).borders(Borders::ALL)); f.render_widget(table, chunks[1]); } else { let msg = err.clone().unwrap_or_else(|| "loading...".into()); let placeholder = Paragraph::new(msg) .block(Block::default().title("tiers").borders(Borders::ALL)); f.render_widget(placeholder, chunks[1]); } let status = if let Some(e) = err { format!("error: {e} [r] retry [q] quit") } else { "[r] refresh [q] quit (poll 2s, canary col shows tier policy)".into() }; f.render_widget(Paragraph::new(status), chunks[2]); let _ = app.state.lock().unwrap().as_ref().map(|s| { s.tiers.iter().map(|t| t.canary.clone()).collect::>() }); }