Skip to main content

max / makenotwork

17.4 KB · 525 lines History Blame Raw
1 //! One terminal surface over every operator daemon that emits an `ops-status`
2 //! payload.
3 //!
4 //! Design + rationale: maintainer wiki.
5 //! <!-- wiki: release-viewer-overview -->
6 //!
7 //! Tabs, one per hooked-in source, plus a rollup that shows every source at
8 //! once, worst first. The rollup is the reason the product exists: without it
9 //! this is N tabs you still have to visit one at a time, which is the situation
10 //! it replaces.
11 //!
12 //! The viewer knows nothing about tiers, gates, apps, or targets. It renders
13 //! the shared contract in `ops-status` and nothing else, which is what lets a
14 //! new daemon arrive with a UI already written.
15 //!
16 //! # Boundary
17 //!
18 //! This displays; it does not interrupt. PoM pushing failures into GoingsOn is
19 //! what wakes you up. This is what you look at once you are already awake. A
20 //! surface that tries to be both becomes one nobody watches.
21
22 mod config;
23 mod exec;
24 mod model;
25 mod poll;
26 mod render;
27 mod value;
28
29 use std::path::PathBuf;
30 use std::time::Duration;
31
32 use anyhow::{Context, Result};
33 use chrono::Utc;
34 use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
35 use tokio::sync::mpsc;
36
37 use crate::config::{Config, Source};
38 use crate::model::{FireRequest, Model, Prompt, PromptStep, SourceState};
39
40 /// How often the UI redraws when nothing has arrived.
41 ///
42 /// Relative timestamps ("3m ago") go stale on their own, so the screen has to
43 /// repaint on a clock even when no source has said anything new.
44 const TICK: Duration = Duration::from_millis(500);
45
46 fn main() -> Result<()> {
47 let path = config_path()?;
48 let cfg = Config::load(&path)?;
49
50 let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?;
51 let _guard = runtime.enter();
52 let updates = poll::spawn_all(&cfg.sources);
53 // Fired actions report back here. One channel for the whole session; a
54 // handful of in-flight actions is the realistic ceiling.
55 let (action_tx, action_rx) = mpsc::channel(cfg.sources.len().max(1) * 4);
56
57 let sources = cfg
58 .sources
59 .iter()
60 .map(|s| SourceState::new(&s.name, cfg.stale_after(s)).with_actions(s.allow_actions))
61 .collect();
62 let model = Model::new(sources);
63
64 // The fallible variants: `init()` panics when there is no terminal, which
65 // turns "you piped this into less" into a backtrace.
66 let mut terminal = ratatui::try_init().context("this needs a terminal (no TTY attached)")?;
67 let outcome = run(
68 &mut terminal,
69 model,
70 updates,
71 cfg.sources,
72 action_tx,
73 action_rx,
74 );
75 let restored = ratatui::try_restore();
76 // Report the run's own failure first; a restore problem is the lesser news
77 // and must not mask why the app actually stopped.
78 outcome.and(restored.context("restoring the terminal"))
79 }
80
81 /// An explicit argument, `$OPS_VIEWER_CONFIG`, or the XDG default.
82 fn config_path() -> Result<PathBuf> {
83 if let Some(arg) = std::env::args().nth(1) {
84 return Ok(PathBuf::from(arg));
85 }
86 if let Ok(path) = std::env::var("OPS_VIEWER_CONFIG") {
87 return Ok(PathBuf::from(path));
88 }
89 let home = std::env::var("HOME").context("HOME is unset and no config path was given")?;
90 Ok(PathBuf::from(home).join(".config/ops-viewer/viewer.toml"))
91 }
92
93 // The TUI run loop owns model/sources/channels for the app's lifetime.
94 #[allow(clippy::needless_pass_by_value)]
95 fn run(
96 terminal: &mut ratatui::DefaultTerminal,
97 mut model: Model,
98 mut updates: mpsc::Receiver<poll::Update>,
99 sources: Vec<Source>,
100 action_tx: mpsc::Sender<exec::Outcome>,
101 mut action_rx: mpsc::Receiver<exec::Outcome>,
102 ) -> Result<()> {
103 loop {
104 let now = Utc::now();
105 terminal.draw(|frame| render::render(&model, now, frame))?;
106
107 // Drain everything the pollers have produced without blocking, so a
108 // burst of updates costs one redraw rather than one each.
109 while let Ok(update) = updates.try_recv() {
110 apply(&mut model, update);
111 }
112 // Fired-action outcomes land in the footer.
113 while let Ok(outcome) = action_rx.try_recv() {
114 model.message = Some(match outcome.result {
115 Ok(code) => format!("{}: ok ({code})", outcome.key),
116 Err(reason) => format!("{}: {reason}", outcome.key),
117 });
118 }
119
120 if event::poll(TICK)?
121 && let Event::Key(key) = event::read()?
122 && key.kind == KeyEventKind::Press
123 {
124 let result = handle_key(&mut model, key);
125 if let Some(request) = result.fire {
126 dispatch(&mut model, &sources, request, &action_tx);
127 }
128 if result.flow == Flow::Quit {
129 return Ok(());
130 }
131 }
132 }
133 }
134
135 /// Resolve a confirmed request against the live payload and fire it.
136 ///
137 /// The action is looked up again here, not trusted from when the prompt opened:
138 /// a poll in between can retract it, and firing a `promote` the daemon no longer
139 /// offers is exactly the surprise the whole confirm path exists to prevent.
140 // request is consumed into the fired action.
141 #[allow(clippy::needless_pass_by_value)]
142 fn dispatch(
143 model: &mut Model,
144 sources: &[Source],
145 request: FireRequest,
146 action_tx: &mpsc::Sender<exec::Outcome>,
147 ) {
148 let Some(source) = sources.get(request.source) else {
149 return;
150 };
151 let action = model
152 .sources
153 .get(request.source)
154 .and_then(|s| s.action(&request.key))
155 .cloned();
156 match action {
157 Some(action) => {
158 exec::fire(source, action, request.key.clone(), action_tx.clone());
159 model.message = Some(format!("{}: sent", request.key));
160 }
161 None => model.message = Some(format!("{}: no longer offered", request.key)),
162 }
163 }
164
165 fn apply(model: &mut Model, update: poll::Update) {
166 let Some(source) = model.sources.get_mut(update.index) else {
167 return;
168 };
169 match update.result {
170 Ok(payload) => source.observe(payload, update.at),
171 Err(error) => source.observe_error(error),
172 }
173 }
174
175 #[derive(Debug, PartialEq)]
176 enum Flow {
177 Continue,
178 Quit,
179 }
180
181 /// What one keypress asked of the loop: whether to keep running, and any
182 /// confirmed action to fire.
183 struct KeyResult {
184 flow: Flow,
185 fire: Option<FireRequest>,
186 }
187
188 impl KeyResult {
189 fn cont() -> Self {
190 KeyResult {
191 flow: Flow::Continue,
192 fire: None,
193 }
194 }
195 fn quit() -> Self {
196 KeyResult {
197 flow: Flow::Quit,
198 fire: None,
199 }
200 }
201 fn fire(request: FireRequest) -> Self {
202 KeyResult {
203 flow: Flow::Continue,
204 fire: Some(request),
205 }
206 }
207 }
208
209 fn handle_key(model: &mut Model, key: KeyEvent) -> KeyResult {
210 // Ctrl-C is the one key that means the same thing in every mode, including
211 // mid-way through typing an action key to confirm it.
212 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
213 return KeyResult::quit();
214 }
215
216 // A prompt captures the keyboard: while it is open, keys drive it and
217 // nothing else, so 'q' typed into a confirmation is text, not a quit.
218 if model.prompt.is_some() {
219 return handle_prompt_key(model, key);
220 }
221
222 let now = Utc::now();
223 model.message = None;
224 match key.code {
225 KeyCode::Char('q') | KeyCode::Esc => return KeyResult::quit(),
226 KeyCode::Tab | KeyCode::Right => model.next_tab(),
227 KeyCode::BackTab | KeyCode::Left => model.prev_tab(),
228 KeyCode::Down | KeyCode::Char('j') => model.move_selection(1, now),
229 KeyCode::Up | KeyCode::Char('k') => model.move_selection(-1, now),
230 KeyCode::Enter => match model.tab {
231 model::Tab::Rollup => model.open_selected(now),
232 model::Tab::Source(_) => model.open_actions(),
233 },
234 KeyCode::Char(c) if c.is_ascii_digit() => {
235 model.select_tab(c.to_digit(10).unwrap_or(0) as usize);
236 }
237 _ => {}
238 }
239 KeyResult::cont()
240 }
241
242 /// Keys while a prompt is open. Each variant answers only the keys that make
243 /// sense for it; Esc always cancels.
244 fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> KeyResult {
245 let step = match &model.prompt {
246 Some(Prompt::Pick { .. }) => match key.code {
247 KeyCode::Esc => model.cancel_prompt(),
248 KeyCode::Down | KeyCode::Char('j') => {
249 model.prompt_move(1);
250 PromptStep::Idle
251 }
252 KeyCode::Up | KeyCode::Char('k') => {
253 model.prompt_move(-1);
254 PromptStep::Idle
255 }
256 KeyCode::Char(c) if c.is_ascii_digit() => {
257 model.prompt_digit(c.to_digit(10).unwrap_or(0) as usize);
258 PromptStep::Idle
259 }
260 KeyCode::Enter => model.prompt_enter(),
261 _ => PromptStep::Idle,
262 },
263 Some(Prompt::Confirm { .. }) => match key.code {
264 // 'y' is the only key that fires; 'n'/Esc back out; the rest do
265 // nothing, so a fat-fingered key neither fires nor loses the prompt.
266 KeyCode::Char('y' | 'Y') => model.confirm_yes(),
267 KeyCode::Esc | KeyCode::Char('n' | 'N') => model.cancel_prompt(),
268 _ => PromptStep::Idle,
269 },
270 Some(Prompt::Type { .. }) => match key.code {
271 KeyCode::Esc => model.cancel_prompt(),
272 KeyCode::Enter => model.prompt_enter(),
273 KeyCode::Backspace => {
274 model.prompt_backspace();
275 PromptStep::Idle
276 }
277 KeyCode::Char(c) => {
278 model.prompt_push(c);
279 PromptStep::Idle
280 }
281 _ => PromptStep::Idle,
282 },
283 None => PromptStep::Idle,
284 };
285
286 match step {
287 PromptStep::Fire(request) => KeyResult::fire(request),
288 PromptStep::Idle | PromptStep::Cancelled => KeyResult::cont(),
289 }
290 }
291
292 #[cfg(test)]
293 mod tests {
294 use super::*;
295 use chrono::TimeDelta;
296 use ops_status::{Payload, Status};
297
298 fn key(code: KeyCode) -> KeyEvent {
299 KeyEvent::new(code, KeyModifiers::NONE)
300 }
301
302 fn model_with(names: &[&str]) -> Model {
303 Model::new(
304 names
305 .iter()
306 .map(|n| SourceState::new(*n, TimeDelta::seconds(60)))
307 .collect(),
308 )
309 }
310
311 fn flow(model: &mut Model, code: KeyCode) -> Flow {
312 handle_key(model, key(code)).flow
313 }
314
315 #[test]
316 fn q_and_ctrl_c_quit_and_nothing_else_does() {
317 let mut model = model_with(&["sando"]);
318 assert_eq!(flow(&mut model, KeyCode::Char('q')), Flow::Quit);
319 assert_eq!(flow(&mut model, KeyCode::Esc), Flow::Quit);
320 assert_eq!(
321 handle_key(
322 &mut model,
323 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
324 )
325 .flow,
326 Flow::Quit
327 );
328 // A bare 'c' is not a quit.
329 assert_eq!(flow(&mut model, KeyCode::Char('c')), Flow::Continue);
330 }
331
332 #[test]
333 fn number_keys_jump_straight_to_a_tab() {
334 let mut model = model_with(&["sando", "bento"]);
335 handle_key(&mut model, key(KeyCode::Char('2')));
336 assert_eq!(model.tab, model::Tab::Source(1));
337 handle_key(&mut model, key(KeyCode::Char('0')));
338 assert_eq!(model.tab, model::Tab::Rollup);
339 // Out of range is ignored rather than panicking.
340 handle_key(&mut model, key(KeyCode::Char('9')));
341 assert_eq!(model.tab, model::Tab::Rollup);
342 }
343
344 #[test]
345 fn arrows_and_vim_keys_both_move() {
346 let mut model = model_with(&["sando", "bento"]);
347 handle_key(&mut model, key(KeyCode::Tab));
348 assert_eq!(model.tab, model::Tab::Source(0));
349 handle_key(&mut model, key(KeyCode::BackTab));
350 assert_eq!(model.tab, model::Tab::Rollup);
351 handle_key(&mut model, key(KeyCode::Char('j')));
352 handle_key(&mut model, key(KeyCode::Char('k')));
353 assert_eq!(model.rollup_selected, 0);
354 }
355
356 #[test]
357 fn a_keypress_clears_a_stale_message() {
358 let mut model = model_with(&["sando"]);
359 model.message = Some("something happened".into());
360 handle_key(&mut model, key(KeyCode::Tab));
361 assert!(model.message.is_none());
362 }
363
364 #[test]
365 fn a_successful_poll_replaces_a_previous_error() {
366 let mut model = model_with(&["sando"]);
367 apply(
368 &mut model,
369 poll::Update {
370 index: 0,
371 at: Utc::now(),
372 result: Err("connection refused".into()),
373 },
374 );
375 assert!(model.sources[0].error.is_some());
376
377 let at = Utc::now();
378 apply(
379 &mut model,
380 poll::Update {
381 index: 0,
382 at,
383 result: Ok(Payload::new("sando", at)),
384 },
385 );
386 assert!(model.sources[0].error.is_none());
387 assert!(model.sources[0].payload.is_some());
388 }
389
390 fn ctrl(c: char) -> KeyEvent {
391 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
392 }
393
394 /// A source with one node declaring a danger action, actions allowed, on
395 /// its tab.
396 fn armed(danger: bool) -> Model {
397 use ops_status::{Action, Method, Node, Payload, Status};
398 let mut node = Node {
399 id: "tier:b".into(),
400 kind: "tier".into(),
401 label: "b".into(),
402 status: Status::Ok,
403 fields: vec![],
404 conditions: vec![],
405 children: vec![],
406 actions: vec!["rollback-b".into()],
407 };
408 node.actions = vec!["rollback-b".into()];
409 let mut p = Payload::new("sando", Utc::now());
410 p.nodes = vec![node];
411 p.actions.insert(
412 "rollback-b".into(),
413 Action {
414 label: "Roll back".into(),
415 method: Method::Post,
416 url: "/rollback/b".into(),
417 confirm: true,
418 danger,
419 body: None,
420 },
421 );
422 let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
423 s.observe(p, Utc::now());
424 let mut m = Model::new(vec![s]);
425 m.select_tab(1);
426 m
427 }
428
429 #[test]
430 fn enter_on_a_source_node_opens_the_action_picker() {
431 let mut m = armed(true);
432 assert!(m.prompt.is_none());
433 handle_key(&mut m, key(KeyCode::Enter));
434 assert!(matches!(m.prompt, Some(Prompt::Pick { .. })));
435 }
436
437 #[test]
438 fn a_full_danger_path_types_the_key_and_yields_a_fire() {
439 let mut m = armed(true);
440 handle_key(&mut m, key(KeyCode::Enter)); // open picker
441 handle_key(&mut m, key(KeyCode::Enter)); // pick -> Type (danger)
442 assert!(matches!(m.prompt, Some(Prompt::Type { .. })));
443 for c in "rollback-b".chars() {
444 handle_key(&mut m, key(KeyCode::Char(c)));
445 }
446 let result = handle_key(&mut m, key(KeyCode::Enter));
447 assert_eq!(
448 result.fire,
449 Some(FireRequest {
450 source: 0,
451 key: "rollback-b".into()
452 })
453 );
454 assert_eq!(result.flow, Flow::Continue);
455 }
456
457 #[test]
458 fn q_typed_into_a_danger_prompt_is_text_not_a_quit() {
459 // The reason handle_key routes to the prompt before its own keymap: a
460 // key containing 'q' must be typeable without quitting the app.
461 let mut m = armed(true);
462 handle_key(&mut m, key(KeyCode::Enter));
463 handle_key(&mut m, key(KeyCode::Enter)); // -> Type
464 let result = handle_key(&mut m, key(KeyCode::Char('q')));
465 assert_eq!(
466 result.flow,
467 Flow::Continue,
468 "'q' must not quit while typing"
469 );
470 if let Some(Prompt::Type { typed, .. }) = &m.prompt {
471 assert_eq!(typed, "q");
472 } else {
473 panic!("left Type mode on a plain character");
474 }
475 }
476
477 #[test]
478 fn ctrl_c_quits_even_mid_type() {
479 // The one escape hatch that always works, so a half-typed confirmation
480 // is never a trap.
481 let mut m = armed(true);
482 handle_key(&mut m, key(KeyCode::Enter));
483 handle_key(&mut m, key(KeyCode::Enter)); // -> Type
484 assert_eq!(handle_key(&mut m, ctrl('c')).flow, Flow::Quit);
485 }
486
487 #[test]
488 fn a_confirm_action_fires_on_y_and_backs_out_on_n() {
489 let mut m = armed(false); // confirm, not danger
490 handle_key(&mut m, key(KeyCode::Enter));
491 handle_key(&mut m, key(KeyCode::Enter)); // pick -> Confirm
492 assert!(matches!(m.prompt, Some(Prompt::Confirm { .. })));
493 // 'n' cancels.
494 let result = handle_key(&mut m, key(KeyCode::Char('n')));
495 assert_eq!(result.fire, None);
496 assert!(m.prompt.is_none());
497 // Re-open and fire with 'y'.
498 handle_key(&mut m, key(KeyCode::Enter));
499 handle_key(&mut m, key(KeyCode::Enter));
500 let result = handle_key(&mut m, key(KeyCode::Char('y')));
501 assert_eq!(
502 result.fire,
503 Some(FireRequest {
504 source: 0,
505 key: "rollback-b".into()
506 })
507 );
508 }
509
510 #[test]
511 fn an_update_for_a_source_that_does_not_exist_is_ignored() {
512 // Defensive: an index mismatch must not panic the UI thread.
513 let mut model = model_with(&["sando"]);
514 apply(
515 &mut model,
516 poll::Update {
517 index: 42,
518 at: Utc::now(),
519 result: Err("whatever".into()),
520 },
521 );
522 assert_eq!(model.sources[0].status(Utc::now()), Status::Unknown);
523 }
524 }
525