//! One terminal surface over every operator daemon that emits an `ops-status` //! payload. //! //! Design + rationale: maintainer wiki. //! //! //! Tabs, one per hooked-in source, plus a rollup that shows every source at //! once, worst first. The rollup is the reason the product exists: without it //! this is N tabs you still have to visit one at a time, which is the situation //! it replaces. //! //! The viewer knows nothing about tiers, gates, apps, or targets. It renders //! the shared contract in `ops-status` and nothing else, which is what lets a //! new daemon arrive with a UI already written. //! //! # Boundary //! //! This displays; it does not interrupt. PoM pushing failures into GoingsOn is //! what wakes you up. This is what you look at once you are already awake. A //! surface that tries to be both becomes one nobody watches. mod config; mod exec; mod model; mod poll; mod render; mod value; use std::path::PathBuf; use std::time::Duration; use anyhow::{Context, Result}; use chrono::Utc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use tokio::sync::mpsc; use crate::config::{Config, Source}; use crate::model::{FireRequest, Model, Prompt, PromptStep, SourceState}; /// How often the UI redraws when nothing has arrived. /// /// Relative timestamps ("3m ago") go stale on their own, so the screen has to /// repaint on a clock even when no source has said anything new. const TICK: Duration = Duration::from_millis(500); fn main() -> Result<()> { let path = config_path()?; let cfg = Config::load(&path)?; let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?; let _guard = runtime.enter(); let updates = poll::spawn_all(&cfg.sources); // Fired actions report back here. One channel for the whole session; a // handful of in-flight actions is the realistic ceiling. let (action_tx, action_rx) = mpsc::channel(cfg.sources.len().max(1) * 4); let sources = cfg .sources .iter() .map(|s| SourceState::new(&s.name, cfg.stale_after(s)).with_actions(s.allow_actions)) .collect(); let model = Model::new(sources); // The fallible variants: `init()` panics when there is no terminal, which // turns "you piped this into less" into a backtrace. let mut terminal = ratatui::try_init().context("this needs a terminal (no TTY attached)")?; let outcome = run( &mut terminal, model, updates, cfg.sources, action_tx, action_rx, ); let restored = ratatui::try_restore(); // Report the run's own failure first; a restore problem is the lesser news // and must not mask why the app actually stopped. outcome.and(restored.context("restoring the terminal")) } /// An explicit argument, `$OPS_VIEWER_CONFIG`, or the XDG default. fn config_path() -> Result { if let Some(arg) = std::env::args().nth(1) { return Ok(PathBuf::from(arg)); } if let Ok(path) = std::env::var("OPS_VIEWER_CONFIG") { return Ok(PathBuf::from(path)); } let home = std::env::var("HOME").context("HOME is unset and no config path was given")?; Ok(PathBuf::from(home).join(".config/ops-viewer/viewer.toml")) } // The TUI run loop owns model/sources/channels for the app's lifetime. #[allow(clippy::needless_pass_by_value)] fn run( terminal: &mut ratatui::DefaultTerminal, mut model: Model, mut updates: mpsc::Receiver, sources: Vec, action_tx: mpsc::Sender, mut action_rx: mpsc::Receiver, ) -> Result<()> { loop { let now = Utc::now(); terminal.draw(|frame| render::render(&model, now, frame))?; // Drain everything the pollers have produced without blocking, so a // burst of updates costs one redraw rather than one each. while let Ok(update) = updates.try_recv() { apply(&mut model, update); } // Fired-action outcomes land in the footer. while let Ok(outcome) = action_rx.try_recv() { model.message = Some(match outcome.result { Ok(code) => format!("{}: ok ({code})", outcome.key), Err(reason) => format!("{}: {reason}", outcome.key), }); } if event::poll(TICK)? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { let result = handle_key(&mut model, key); if let Some(request) = result.fire { dispatch(&mut model, &sources, request, &action_tx); } if result.flow == Flow::Quit { return Ok(()); } } } } /// Resolve a confirmed request against the live payload and fire it. /// /// The action is looked up again here, not trusted from when the prompt opened: /// a poll in between can retract it, and firing a `promote` the daemon no longer /// offers is exactly the surprise the whole confirm path exists to prevent. // request is consumed into the fired action. #[allow(clippy::needless_pass_by_value)] fn dispatch( model: &mut Model, sources: &[Source], request: FireRequest, action_tx: &mpsc::Sender, ) { let Some(source) = sources.get(request.source) else { return; }; let action = model .sources .get(request.source) .and_then(|s| s.action(&request.key)) .cloned(); match action { Some(action) => { exec::fire(source, action, request.key.clone(), action_tx.clone()); model.message = Some(format!("{}: sent", request.key)); } None => model.message = Some(format!("{}: no longer offered", request.key)), } } fn apply(model: &mut Model, update: poll::Update) { let Some(source) = model.sources.get_mut(update.index) else { return; }; match update.result { Ok(payload) => source.observe(payload, update.at), Err(error) => source.observe_error(error), } } #[derive(Debug, PartialEq)] enum Flow { Continue, Quit, } /// What one keypress asked of the loop: whether to keep running, and any /// confirmed action to fire. struct KeyResult { flow: Flow, fire: Option, } impl KeyResult { fn cont() -> Self { KeyResult { flow: Flow::Continue, fire: None, } } fn quit() -> Self { KeyResult { flow: Flow::Quit, fire: None, } } fn fire(request: FireRequest) -> Self { KeyResult { flow: Flow::Continue, fire: Some(request), } } } fn handle_key(model: &mut Model, key: KeyEvent) -> KeyResult { // Ctrl-C is the one key that means the same thing in every mode, including // mid-way through typing an action key to confirm it. if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return KeyResult::quit(); } // A prompt captures the keyboard: while it is open, keys drive it and // nothing else, so 'q' typed into a confirmation is text, not a quit. if model.prompt.is_some() { return handle_prompt_key(model, key); } let now = Utc::now(); model.message = None; match key.code { KeyCode::Char('q') | KeyCode::Esc => return KeyResult::quit(), KeyCode::Tab | KeyCode::Right => model.next_tab(), KeyCode::BackTab | KeyCode::Left => model.prev_tab(), KeyCode::Down | KeyCode::Char('j') => model.move_selection(1, now), KeyCode::Up | KeyCode::Char('k') => model.move_selection(-1, now), KeyCode::Enter => match model.tab { model::Tab::Rollup => model.open_selected(now), model::Tab::Source(_) => model.open_actions(), }, KeyCode::Char(c) if c.is_ascii_digit() => { model.select_tab(c.to_digit(10).unwrap_or(0) as usize); } _ => {} } KeyResult::cont() } /// Keys while a prompt is open. Each variant answers only the keys that make /// sense for it; Esc always cancels. fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> KeyResult { let step = match &model.prompt { Some(Prompt::Pick { .. }) => match key.code { KeyCode::Esc => model.cancel_prompt(), KeyCode::Down | KeyCode::Char('j') => { model.prompt_move(1); PromptStep::Idle } KeyCode::Up | KeyCode::Char('k') => { model.prompt_move(-1); PromptStep::Idle } KeyCode::Char(c) if c.is_ascii_digit() => { model.prompt_digit(c.to_digit(10).unwrap_or(0) as usize); PromptStep::Idle } KeyCode::Enter => model.prompt_enter(), _ => PromptStep::Idle, }, Some(Prompt::Confirm { .. }) => match key.code { // 'y' is the only key that fires; 'n'/Esc back out; the rest do // nothing, so a fat-fingered key neither fires nor loses the prompt. KeyCode::Char('y' | 'Y') => model.confirm_yes(), KeyCode::Esc | KeyCode::Char('n' | 'N') => model.cancel_prompt(), _ => PromptStep::Idle, }, Some(Prompt::Type { .. }) => match key.code { KeyCode::Esc => model.cancel_prompt(), KeyCode::Enter => model.prompt_enter(), KeyCode::Backspace => { model.prompt_backspace(); PromptStep::Idle } KeyCode::Char(c) => { model.prompt_push(c); PromptStep::Idle } _ => PromptStep::Idle, }, None => PromptStep::Idle, }; match step { PromptStep::Fire(request) => KeyResult::fire(request), PromptStep::Idle | PromptStep::Cancelled => KeyResult::cont(), } } #[cfg(test)] mod tests { use super::*; use chrono::TimeDelta; use ops_status::{Payload, Status}; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } fn model_with(names: &[&str]) -> Model { Model::new( names .iter() .map(|n| SourceState::new(*n, TimeDelta::seconds(60))) .collect(), ) } fn flow(model: &mut Model, code: KeyCode) -> Flow { handle_key(model, key(code)).flow } #[test] fn q_and_ctrl_c_quit_and_nothing_else_does() { let mut model = model_with(&["sando"]); assert_eq!(flow(&mut model, KeyCode::Char('q')), Flow::Quit); assert_eq!(flow(&mut model, KeyCode::Esc), Flow::Quit); assert_eq!( handle_key( &mut model, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) ) .flow, Flow::Quit ); // A bare 'c' is not a quit. assert_eq!(flow(&mut model, KeyCode::Char('c')), Flow::Continue); } #[test] fn number_keys_jump_straight_to_a_tab() { let mut model = model_with(&["sando", "bento"]); handle_key(&mut model, key(KeyCode::Char('2'))); assert_eq!(model.tab, model::Tab::Source(1)); handle_key(&mut model, key(KeyCode::Char('0'))); assert_eq!(model.tab, model::Tab::Rollup); // Out of range is ignored rather than panicking. handle_key(&mut model, key(KeyCode::Char('9'))); assert_eq!(model.tab, model::Tab::Rollup); } #[test] fn arrows_and_vim_keys_both_move() { let mut model = model_with(&["sando", "bento"]); handle_key(&mut model, key(KeyCode::Tab)); assert_eq!(model.tab, model::Tab::Source(0)); handle_key(&mut model, key(KeyCode::BackTab)); assert_eq!(model.tab, model::Tab::Rollup); handle_key(&mut model, key(KeyCode::Char('j'))); handle_key(&mut model, key(KeyCode::Char('k'))); assert_eq!(model.rollup_selected, 0); } #[test] fn a_keypress_clears_a_stale_message() { let mut model = model_with(&["sando"]); model.message = Some("something happened".into()); handle_key(&mut model, key(KeyCode::Tab)); assert!(model.message.is_none()); } #[test] fn a_successful_poll_replaces_a_previous_error() { let mut model = model_with(&["sando"]); apply( &mut model, poll::Update { index: 0, at: Utc::now(), result: Err("connection refused".into()), }, ); assert!(model.sources[0].error.is_some()); let at = Utc::now(); apply( &mut model, poll::Update { index: 0, at, result: Ok(Payload::new("sando", at)), }, ); assert!(model.sources[0].error.is_none()); assert!(model.sources[0].payload.is_some()); } fn ctrl(c: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) } /// A source with one node declaring a danger action, actions allowed, on /// its tab. fn armed(danger: bool) -> Model { use ops_status::{Action, Method, Node, Payload, Status}; let mut node = Node { id: "tier:b".into(), kind: "tier".into(), label: "b".into(), status: Status::Ok, fields: vec![], conditions: vec![], children: vec![], actions: vec!["rollback-b".into()], }; node.actions = vec!["rollback-b".into()]; let mut p = Payload::new("sando", Utc::now()); p.nodes = vec![node]; p.actions.insert( "rollback-b".into(), Action { label: "Roll back".into(), method: Method::Post, url: "/rollback/b".into(), confirm: true, danger, body: None, }, ); let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); s.observe(p, Utc::now()); let mut m = Model::new(vec![s]); m.select_tab(1); m } #[test] fn enter_on_a_source_node_opens_the_action_picker() { let mut m = armed(true); assert!(m.prompt.is_none()); handle_key(&mut m, key(KeyCode::Enter)); assert!(matches!(m.prompt, Some(Prompt::Pick { .. }))); } #[test] fn a_full_danger_path_types_the_key_and_yields_a_fire() { let mut m = armed(true); handle_key(&mut m, key(KeyCode::Enter)); // open picker handle_key(&mut m, key(KeyCode::Enter)); // pick -> Type (danger) assert!(matches!(m.prompt, Some(Prompt::Type { .. }))); for c in "rollback-b".chars() { handle_key(&mut m, key(KeyCode::Char(c))); } let result = handle_key(&mut m, key(KeyCode::Enter)); assert_eq!( result.fire, Some(FireRequest { source: 0, key: "rollback-b".into() }) ); assert_eq!(result.flow, Flow::Continue); } #[test] fn q_typed_into_a_danger_prompt_is_text_not_a_quit() { // The reason handle_key routes to the prompt before its own keymap: a // key containing 'q' must be typeable without quitting the app. let mut m = armed(true); handle_key(&mut m, key(KeyCode::Enter)); handle_key(&mut m, key(KeyCode::Enter)); // -> Type let result = handle_key(&mut m, key(KeyCode::Char('q'))); assert_eq!( result.flow, Flow::Continue, "'q' must not quit while typing" ); if let Some(Prompt::Type { typed, .. }) = &m.prompt { assert_eq!(typed, "q"); } else { panic!("left Type mode on a plain character"); } } #[test] fn ctrl_c_quits_even_mid_type() { // The one escape hatch that always works, so a half-typed confirmation // is never a trap. let mut m = armed(true); handle_key(&mut m, key(KeyCode::Enter)); handle_key(&mut m, key(KeyCode::Enter)); // -> Type assert_eq!(handle_key(&mut m, ctrl('c')).flow, Flow::Quit); } #[test] fn a_confirm_action_fires_on_y_and_backs_out_on_n() { let mut m = armed(false); // confirm, not danger handle_key(&mut m, key(KeyCode::Enter)); handle_key(&mut m, key(KeyCode::Enter)); // pick -> Confirm assert!(matches!(m.prompt, Some(Prompt::Confirm { .. }))); // 'n' cancels. let result = handle_key(&mut m, key(KeyCode::Char('n'))); assert_eq!(result.fire, None); assert!(m.prompt.is_none()); // Re-open and fire with 'y'. handle_key(&mut m, key(KeyCode::Enter)); handle_key(&mut m, key(KeyCode::Enter)); let result = handle_key(&mut m, key(KeyCode::Char('y'))); assert_eq!( result.fire, Some(FireRequest { source: 0, key: "rollback-b".into() }) ); } #[test] fn an_update_for_a_source_that_does_not_exist_is_ignored() { // Defensive: an index mismatch must not panic the UI thread. let mut model = model_with(&["sando"]); apply( &mut model, poll::Update { index: 42, at: Utc::now(), result: Err("whatever".into()), }, ); assert_eq!(model.sources[0].status(Utc::now()), Status::Unknown); } }