Skip to main content

max / makenotwork

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