Skip to main content

max / makenotwork

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