Skip to main content

max / makenotwork

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