Skip to main content

max / makenotwork

5.9 KB · 189 lines History Blame Raw
1 use anyhow::Result;
2 use crossterm::event::{self, Event, KeyCode};
3 use crossterm::terminal::{
4 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
5 };
6 use ratatui::prelude::*;
7 use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
8 use serde::Deserialize;
9 use std::io;
10 use std::sync::{Arc, Mutex};
11 use std::time::{Duration, Instant};
12
13 #[derive(Clone, Debug, Deserialize)]
14 struct StateView {
15 tiers: Vec<TierView>,
16 }
17
18 #[derive(Clone, Debug, Deserialize)]
19 struct TierView {
20 name: String,
21 provisioned: bool,
22 canary: String,
23 current_version: Option<String>,
24 previous_version: Option<String>,
25 burn_in_started_at: Option<String>,
26 nodes: Vec<String>,
27 gates: Vec<GateView>,
28 }
29
30 #[derive(Clone, Debug, Deserialize)]
31 struct GateView {
32 kind: String,
33 passed: Option<bool>,
34 finished_at: Option<String>,
35 }
36
37 struct App {
38 daemon: String,
39 state: Arc<Mutex<Option<StateView>>>,
40 last_err: Arc<Mutex<Option<String>>>,
41 }
42
43 fn main() -> Result<()> {
44 let daemon = std::env::var("SANDO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7766".into());
45
46 let app = App {
47 daemon,
48 state: Arc::new(Mutex::new(None)),
49 last_err: Arc::new(Mutex::new(None)),
50 };
51
52 let rt = tokio::runtime::Builder::new_current_thread()
53 .enable_all()
54 .build()?;
55
56 enable_raw_mode()?;
57 let mut stdout = io::stdout();
58 crossterm::execute!(stdout, EnterAlternateScreen)?;
59 let backend = CrosstermBackend::new(stdout);
60 let mut term = Terminal::new(backend)?;
61
62 let res = run(&mut term, &app, &rt);
63
64 disable_raw_mode()?;
65 crossterm::execute!(term.backend_mut(), LeaveAlternateScreen)?;
66 term.show_cursor()?;
67 res
68 }
69
70 fn run<B: Backend>(term: &mut Terminal<B>, app: &App, rt: &tokio::runtime::Runtime) -> Result<()> {
71 let mut next_poll = Instant::now();
72 loop {
73 if Instant::now() >= next_poll {
74 poll_state(app, rt);
75 next_poll = Instant::now() + Duration::from_secs(2);
76 }
77
78 term.draw(|f| draw(f, app))?;
79
80 if event::poll(Duration::from_millis(150))? {
81 if let Event::Key(k) = event::read()? {
82 match k.code {
83 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
84 KeyCode::Char('r') => next_poll = Instant::now(),
85 _ => {}
86 }
87 }
88 }
89 }
90 }
91
92 fn poll_state(app: &App, rt: &tokio::runtime::Runtime) {
93 let url = format!("{}/state", app.daemon);
94 let result: std::result::Result<StateView, String> = rt.block_on(async {
95 let resp = reqwest::Client::new()
96 .get(&url)
97 .timeout(Duration::from_secs(2))
98 .send()
99 .await
100 .map_err(|e| e.to_string())?;
101 if !resp.status().is_success() {
102 return Err(format!("status {}", resp.status()));
103 }
104 resp.json::<StateView>().await.map_err(|e| e.to_string())
105 });
106 match result {
107 Ok(s) => {
108 *app.state.lock().unwrap() = Some(s);
109 *app.last_err.lock().unwrap() = None;
110 }
111 Err(e) => *app.last_err.lock().unwrap() = Some(e),
112 }
113 }
114
115 fn draw(f: &mut Frame, app: &App) {
116 let chunks = Layout::default()
117 .direction(Direction::Vertical)
118 .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(2)])
119 .split(f.area());
120
121 let header = Paragraph::new(format!("sando -> {}", app.daemon))
122 .block(Block::default().title("daemon").borders(Borders::ALL));
123 f.render_widget(header, chunks[0]);
124
125 let state = app.state.lock().unwrap().clone();
126 let err = app.last_err.lock().unwrap().clone();
127
128 if let Some(s) = state {
129 let header_row = Row::new(vec![
130 "tier", "prov", "current", "previous", "burn-in", "nodes", "gates",
131 ])
132 .style(Style::default().add_modifier(Modifier::BOLD));
133
134 let rows: Vec<Row> = s.tiers.iter().map(|t| {
135 let gates = if t.gates.is_empty() {
136 "-".into()
137 } else {
138 t.gates.iter().map(|g| {
139 let mark = match g.passed {
140 Some(true) => "ok",
141 Some(false) => "fail",
142 None => "?",
143 };
144 format!("{}:{}", g.kind, mark)
145 }).collect::<Vec<_>>().join(" ")
146 };
147 Row::new(vec![
148 t.name.clone(),
149 if t.provisioned { "yes".into() } else { "no".into() },
150 t.current_version.clone().unwrap_or_else(|| "-".into()),
151 t.previous_version.clone().unwrap_or_else(|| "-".into()),
152 t.burn_in_started_at.clone().unwrap_or_else(|| "-".into()),
153 if t.nodes.is_empty() { "-".into() } else { t.nodes.join(",") },
154 gates,
155 ])
156 }).collect();
157
158 let widths = [
159 Constraint::Length(8),
160 Constraint::Length(5),
161 Constraint::Length(12),
162 Constraint::Length(12),
163 Constraint::Length(22),
164 Constraint::Length(24),
165 Constraint::Min(20),
166 ];
167 let table = Table::new(rows, widths)
168 .header(header_row)
169 .block(Block::default().title(format!("tiers ({})", s.tiers.len())).borders(Borders::ALL));
170 f.render_widget(table, chunks[1]);
171 } else {
172 let msg = err.clone().unwrap_or_else(|| "loading...".into());
173 let placeholder = Paragraph::new(msg)
174 .block(Block::default().title("tiers").borders(Borders::ALL));
175 f.render_widget(placeholder, chunks[1]);
176 }
177
178 let status = if let Some(e) = err {
179 format!("error: {e} [r] retry [q] quit")
180 } else {
181 "[r] refresh [q] quit (poll 2s, canary col shows tier policy)".into()
182 };
183 f.render_widget(Paragraph::new(status), chunks[2]);
184
185 let _ = app.state.lock().unwrap().as_ref().map(|s| {
186 s.tiers.iter().map(|t| t.canary.clone()).collect::<Vec<_>>()
187 });
188 }
189