Skip to main content

max / makenotwork

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