//! Drawing the model. //! //! `render(model, now, frame)` is a pure function of the model and the clock. //! `now` is threaded through rather than read from the system clock precisely //! so that this stays true: with an ambient clock, every snapshot test becomes //! time-dependent and the whole surface stops being verifiable without a live //! daemon. That is unpleasant to retrofit and trivial to keep. use chrono::{DateTime, Utc}; use ops_status::{Method, Node}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs}; use crate::model::{Model, Prompt, SourceState, Tab}; use crate::value; pub(crate) fn render(model: &Model, now: DateTime, frame: &mut Frame) { let [header, body, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ]) .areas(frame.area()); render_header(model, now, frame, header); match model.tab { Tab::Rollup => render_rollup(model, now, frame, body), Tab::Source(i) => match model.sources.get(i) { Some(source) => render_source(source, now, frame, body), None => frame.render_widget(Paragraph::new("no such source"), body), }, } // A prompt floats over whatever tab is showing: the state behind it keeps // updating on every poll, which is the point of not blocking on the modal. if model.prompt.is_some() { render_prompt(model, frame, body); } render_footer(model, frame, footer); } fn render_header(model: &Model, now: DateTime, frame: &mut Frame, area: Rect) { let worst = model.worst(now); let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area); frame.render_widget( Paragraph::new(Span::styled( format!(" {} ", value::status_mark(worst)), value::status_style(worst).add_modifier(Modifier::REVERSED), )), mark, ); frame.render_widget( Tabs::new(model.tab_titles()) .select(model.tab_index()) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .divider(" "), tabs, ); } fn render_footer(model: &Model, frame: &mut Frame, area: Rect) { let text = match &model.message { Some(message) => Span::from(format!(" {message}")).yellow(), None => { Span::from(" tab/shift-tab switch up/down move enter open 1-9 jump q quit").dim() } }; frame.render_widget(Paragraph::new(Line::from(text)), area); } // --------------------------------------------------------------------------- // Rollup // --------------------------------------------------------------------------- /// Every source at once, worst first. /// /// Without this the viewer is N tabs you still have to visit one at a time, /// which is the situation it replaces, with extra steps. fn render_rollup(model: &Model, now: DateTime, frame: &mut Frame, area: Rect) { let order = model.rollup_order(now); let rows: Vec = order .iter() .enumerate() .map(|(row, &index)| { let source = &model.sources[index]; let status = source.status(now); let age = match source.age(now) { Some(age) => value::duration(age.num_seconds()), None => "-".into(), }; let style = if row == model.rollup_selected { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default() }; Row::new(vec![ Cell::from(value::status_mark(status)).style(value::status_style(status)), Cell::from(source.name.clone()), Cell::from(age), Cell::from(source.summary(now)), ]) .style(style) }) .collect(); let table = Table::new( rows, [ Constraint::Length(4), Constraint::Length(14), Constraint::Length(8), Constraint::Min(10), ], ) .header( Row::new(vec!["", "source", "age", "detail"]) .style(Style::default().add_modifier(Modifier::DIM)), ) .block(Block::bordered().title(" all sources ")); frame.render_widget(table, area); } // --------------------------------------------------------------------------- // One source // --------------------------------------------------------------------------- fn render_source(source: &SourceState, now: DateTime, frame: &mut Frame, area: Rect) { let [list_area, detail_area] = Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area); let rows = source.rows(); let items: Vec = rows .iter() .enumerate() .map(|(i, row)| { let indent = " ".repeat(row.depth); let mut spans = vec![ Span::styled( format!("{:<5}", value::status_mark(row.node.status)), value::status_style(row.node.status), ), Span::raw(format!("{indent}{}", row.node.label)), Span::styled(format!(" ({})", row.node.kind), Style::default().dim()), ]; if i == source.selected { spans = spans .into_iter() .map(|s| s.patch_style(Style::default().add_modifier(Modifier::REVERSED))) .collect(); } ListItem::new(Line::from(spans)) }) .collect(); let title = format!(" {} ", source.name); let list = if items.is_empty() { List::new(vec![ListItem::new(Line::from( Span::from(source.summary(now)).dim(), ))]) } else { List::new(items) }; frame.render_widget(list.block(Block::bordered().title(title)), list_area); render_detail(source, now, frame, detail_area); } /// The selected node's fields and conditions. /// /// Conditions are the half that usually gets dropped, and the half that pays: /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves /// an SSH. fn render_detail(source: &SourceState, now: DateTime, frame: &mut Frame, area: Rect) { let width = area.width.saturating_sub(4) as usize; let mut lines: Vec = Vec::new(); match source.selected_node() { Some(node) => { lines.push(Line::from(vec![ Span::styled( value::status_mark(node.status), value::status_style(node.status), ), Span::raw(" "), Span::styled(node.label.clone(), Style::default().bold()), ])); lines.extend(field_lines(node, now, width)); lines.extend(condition_lines(node, width)); if !node.actions.is_empty() { // The hint tells the operator whether Enter does anything here, // so a read-only source does not look broken when a keypress is // ignored. let hint = if source.allow_actions { " (enter to run)" } else { " (read-only)" }; lines.push(Line::from(vec![ Span::from(format!("actions: {}", node.actions.join(", "))).dim(), Span::from(hint).dim(), ])); } } None => lines.push(Line::from(Span::from(source.summary(now)).dim())), } frame.render_widget( Paragraph::new(lines).block(Block::bordered().title(" detail ")), area, ); } fn field_lines(node: &Node, now: DateTime, width: usize) -> Vec> { let label_width = node .fields .iter() .map(|f| f.label.chars().count()) .max() .unwrap_or(0); node.fields .iter() .map(|field| { let budget = width.saturating_sub(label_width + 2); let rendered = value::render(&field.value, now, budget); let style = match &field.value { ops_status::Value::Progress { value, max, .. } => { value::progress_style(*value, *max) } other => value::style(other), }; Line::from(vec![ Span::styled( format!("{: Vec> { node.conditions .iter() .map(|condition| { let mut spans = vec![ Span::styled( format!("{:<5}", value::status_mark(condition.status)), value::status_style(condition.status), ), Span::raw(condition.condition_type.clone()), ]; if let Some(detail) = &condition.detail { let budget = width.saturating_sub(condition.condition_type.chars().count() + 8); spans.push(Span::styled( format!(" {}", truncate(detail, budget.max(8))), Style::default().dim(), )); } Line::from(spans) }) .collect() } // --------------------------------------------------------------------------- // Action prompts // --------------------------------------------------------------------------- /// The modal path to firing an action: pick, then clear its guard. /// /// A `danger` action shows a red header and asks the operator to type its key; /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The /// resolved host is left off — the tab already names the source — so the line /// stays short and shows the method and path the request will use. fn render_prompt(model: &Model, frame: &mut Frame, area: Rect) { let Some(prompt) = &model.prompt else { return }; let source = model.sources.get(prompt_source(prompt)); let (title, title_style, lines) = match prompt { Prompt::Pick { keys, selected, .. } => { let mut lines = Vec::new(); for (i, key) in keys.iter().enumerate() { let action = source.and_then(|s| s.action(key)); let label = action.map_or(key.as_str(), |a| a.label.as_str()); let danger = action.is_some_and(|a| a.danger); let marker = if i == *selected { "> " } else { " " }; let mut style = Style::default(); if danger { style = style.fg(Color::Red); } if i == *selected { style = style.add_modifier(Modifier::REVERSED); } lines.push(Line::from(Span::styled( format!("{marker}{}. {label} [{key}]", i + 1), style, ))); } lines.push(Line::from("")); lines.push(Line::from(Span::from("enter run esc cancel").dim())); (" run action ", Style::default(), lines) } Prompt::Confirm { key, .. } => { let action = source.and_then(|s| s.action(key)); let lines = vec![ action_summary_line(action, key), Line::from(""), Line::from(Span::from("press y to confirm esc cancel").dim()), ]; (" confirm ", Style::default(), lines) } Prompt::Type { key, typed, .. } => { let action = source.and_then(|s| s.action(key)); let lines = vec![ Line::from(Span::styled( "DANGER", Style::default().fg(Color::Red).bold(), )), action_summary_line(action, key), Line::from(""), Line::from(Span::from(format!("type '{key}' to confirm:")).dim()), Line::from(Span::styled( format!("> {typed}\u{258f}"), Style::default().fg(Color::Red), )), Line::from(""), Line::from(Span::from("esc cancel").dim()), ]; ( " DANGER ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), lines, ) } }; let width = lines .iter() .map(Line::width) .chain(std::iter::once(title.len())) .max() .unwrap_or(0) as u16 + 4; let height = lines.len() as u16 + 2; let popup = centered(area, width, height); // Clear what is under the popup so the tab behind does not show through. frame.render_widget(Clear, popup); frame.render_widget( Paragraph::new(lines).block( Block::bordered() .title(Span::styled(title, title_style)) .border_style(title_style), ), popup, ); } /// One line naming what an action will do: `Roll back POST /rollback/b`. fn action_summary_line(action: Option<&ops_status::Action>, key: &str) -> Line<'static> { match action { Some(action) => Line::from(vec![ Span::styled(action.label.clone(), Style::default().bold()), Span::from(format!(" {} {}", method_str(action.method), action.url)), ]), // The action was retracted by a poll since the prompt opened; say so // rather than render a blank confirmation. None => Line::from(Span::styled( format!("{key}: no longer offered"), Style::default().fg(Color::Yellow), )), } } fn method_str(method: Method) -> &'static str { match method { Method::Get => "GET", Method::Post => "POST", Method::Put => "PUT", Method::Delete => "DELETE", } } fn prompt_source(prompt: &Prompt) -> usize { match prompt { Prompt::Pick { source, .. } | Prompt::Confirm { source, .. } | Prompt::Type { source, .. } => *source, } } /// A rectangle of the given size centered in `area`, clamped so it always fits. fn centered(area: Rect, width: u16, height: u16) -> Rect { let width = width.min(area.width); let height = height.min(area.height); Rect { x: area.x + (area.width - width) / 2, y: area.y + (area.height - height) / 2, width, height, } } /// Clip to a character budget, marking that something was cut. fn truncate(text: &str, max: usize) -> String { // A detail string is producer-supplied and may carry newlines; the pane is // line-oriented, so flatten first. let flat: String = text.replace('\n', " "); if flat.chars().count() <= max { return flat; } let kept: String = flat.chars().take(max.saturating_sub(1)).collect(); format!("{kept}…") } #[cfg(test)] mod tests { use super::*; use crate::model::SourceState; use chrono::TimeDelta; use ops_status::{Condition, Field, Payload, Status, Value}; use ratatui::Terminal; use ratatui::backend::TestBackend; fn now() -> DateTime { "2026-07-21T18:00:00Z".parse().unwrap() } /// Render a model into a fixed-size buffer and return it as text lines. /// /// This is the whole payoff of keeping render pure: the entire surface is /// verifiable with no daemon running and no terminal attached. fn draw(model: &Model, now: DateTime, width: u16, height: u16) -> Vec { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal.draw(|frame| render(model, now, frame)).unwrap(); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) .map(|y| { (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol().to_string()) .collect::() .trim_end() .to_string() }) .collect() } fn node(id: &str, label: &str, status: Status) -> Node { Node { id: id.into(), kind: "tier".into(), label: label.into(), status, fields: Vec::new(), conditions: Vec::new(), children: Vec::new(), actions: Vec::new(), } } fn source(name: &str, at: DateTime, nodes: Vec) -> SourceState { let mut s = SourceState::new(name, TimeDelta::seconds(60)); let mut p = Payload::new(name, at); p.nodes = nodes; s.observe(p, at); s } fn joined(lines: &[String]) -> String { lines.join("\n") } #[test] fn the_rollup_leads_with_the_worst_source() { let model = Model::new(vec![ source("sando", now(), vec![node("a", "tier a", Status::Ok)]), source("bento", now(), vec![node("b", "goingson", Status::Failed)]), ]); let lines = draw(&model, now(), 80, 12); let text = joined(&lines); assert!(text.contains("rollup"), "{text}"); // Skip the tab bar, which names every source regardless of order. let body = &lines[1..]; let bento = body.iter().position(|l| l.contains("bento")).unwrap(); let sando = body.iter().position(|l| l.contains("sando")).unwrap(); assert!(bento < sando, "the failing source must be on top:\n{text}"); assert!(text.contains("FAIL"), "{text}"); } #[test] fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() { let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]); let text = joined(&draw(&model, now(), 80, 12)); assert!( text.contains("????"), "an unreachable source must be loud:\n{text}" ); assert!(text.contains("waiting for first poll"), "{text}"); } #[test] fn a_stale_source_shows_its_age_in_the_rollup() { let model = Model::new(vec![source( "pom", now() - TimeDelta::hours(4), vec![node("backup", "backup", Status::Ok)], )]); let text = joined(&draw(&model, now(), 80, 12)); assert!(text.contains("4h"), "the age must be visible:\n{text}"); assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}"); } #[test] fn a_source_tab_lists_its_nodes_with_children_indented() { let mut parent = node("tier:b", "b (prod-1)", Status::Ok); parent.children = vec!["node:prod-1".into()]; let child = node("node:prod-1", "prod-1", Status::Ok); let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]); model.select_tab(1); let lines = draw(&model, now(), 80, 20); let text = joined(&lines); let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap(); let child_row = lines.iter().position(|l| l.contains("prod-1 (")).unwrap(); assert!(parent_row < child_row, "{text}"); // The child is indented relative to its parent. let parent_col = lines[parent_row].find("b (prod-1)").unwrap(); let child_col = lines[child_row].find("prod-1").unwrap(); assert!(child_col > parent_col, "child must be indented:\n{text}"); } #[test] fn the_detail_pane_shows_conditions_with_their_why() { let mut n = node("tier:b", "b", Status::Ok); n.conditions = vec![Condition { condition_type: "burn_in".into(), status: Status::Pending, since: None, detail: Some("17 hours remaining of 48".into()), }]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("burn_in"), "{text}"); assert!( text.contains("17 hours remaining"), "a condition without its why is useless:\n{text}" ); } #[test] fn a_progress_field_renders_as_a_bar() { let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![Field::new( "burn-in", Value::Progress { value: 31.0, max: 48.0, unit: Some("hour".into()), }, )]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("31/48 hour"), "{text}"); assert!( text.contains('#'), "a progress value must draw a bar:\n{text}" ); } #[test] fn an_instant_renders_relative_to_the_passed_in_clock() { let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![Field::new( "built", Value::Instant { value: now() - TimeDelta::minutes(3), }, )]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("3m 0s ago"), "{text}"); } #[test] fn render_is_deterministic_for_a_fixed_clock() { // The property every snapshot test rests on. let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![Field::new( "built", Value::Instant { value: now() - TimeDelta::minutes(3), }, )]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20)); } #[test] fn an_unknown_value_kind_still_renders_as_text() { // Version skew: a producer one release ahead must not blank the pane. let field: Field = serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap(); let mut n = node("tier:b", "b", Status::Ok); n.fields = vec![field]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("temp"), "{text}"); assert!(text.contains("41"), "{text}"); } #[test] fn a_narrow_terminal_does_not_panic() { // Every widget here has to survive a width no layout was designed for. let mut n = node("tier:b", "a rather long tier label", Status::Failed); n.fields = vec![Field::new( "path", Value::Path { value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(), }, )]; n.conditions = vec![Condition { condition_type: "node_health".into(), status: Status::Failed, since: None, detail: Some("prod-1 unhealthy: connection refused after 30s".into()), }]; let mut model = Model::new(vec![source("sando", now(), vec![n])]); model.select_tab(1); for width in [8_u16, 12, 20, 40] { for height in [4_u16, 8, 20] { let _ = draw(&model, now(), width, height); } } } #[test] fn a_multiline_detail_is_flattened_not_sprawled() { assert_eq!(truncate("a\nb", 40), "a b"); assert!(truncate(&"x".repeat(100), 10).ends_with('…')); assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10); } fn action(label: &str, danger: bool) -> ops_status::Action { ops_status::Action { label: label.into(), method: ops_status::Method::Post, url: "/rollback/b".into(), confirm: true, danger, body: None, } } /// A source with one node declaring `keys`, actions allowed, on its tab. fn actionable(keys: &[(&str, bool)]) -> Model { let mut n = node("tier:b", "b (prod-1)", Status::Ok); n.actions = keys.iter().map(|(k, _)| k.to_string()).collect(); let mut p = Payload::new("sando", now()); p.nodes = vec![n]; p.actions = keys .iter() .map(|(k, d)| (k.to_string(), action(k, *d))) .collect(); let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); s.observe(p, now()); let mut m = Model::new(vec![s]); m.select_tab(1); m } #[test] fn the_detail_hint_says_whether_actions_can_run() { let mut m = actionable(&[("rollback-b", true)]); let text = joined(&draw(&m, now(), 80, 20)); assert!(text.contains("rollback-b"), "{text}"); assert!(text.contains("enter to run"), "{text}"); m.sources[0].allow_actions = false; let text = joined(&draw(&m, now(), 80, 20)); assert!( text.contains("read-only"), "a disabled source must say so:\n{text}" ); } #[test] fn the_picker_lists_a_nodes_actions() { let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]); m.open_actions(); let text = joined(&draw(&m, now(), 80, 20)); assert!(text.contains("run action"), "{text}"); assert!(text.contains("[promote-b]"), "{text}"); assert!(text.contains("[rollback-b]"), "{text}"); assert!(text.contains("enter run"), "{text}"); } #[test] fn a_danger_prompt_shows_the_key_to_type() { let mut m = actionable(&[("rollback-b", true)]); m.open_actions(); m.prompt_enter(); // Pick -> Type (danger) let text = joined(&draw(&m, now(), 80, 20)); assert!( text.contains("DANGER"), "a danger action must be loud:\n{text}" ); assert!( text.contains("type 'rollback-b'"), "the exact key to type must be shown:\n{text}" ); } #[test] fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() { let mut m = actionable(&[("promote-b", false)]); m.open_actions(); m.prompt_enter(); // Pick -> Confirm (confirm, not danger) // A poll drops the action while the confirm box is up. let mut p = Payload::new("sando", now()); p.nodes = vec![node("tier:b", "b", Status::Ok)]; m.sources[0].observe(p, now()); let text = joined(&draw(&m, now(), 80, 20)); assert!(text.contains("no longer offered"), "{text}"); } #[test] fn the_footer_shows_a_message_when_there_is_one() { let mut model = Model::new(vec![source("sando", now(), vec![])]); let text = joined(&draw(&model, now(), 80, 12)); assert!(text.contains("q quit"), "{text}"); model.message = Some("refreshing".into()); let text = joined(&draw(&model, now(), 80, 12)); assert!(text.contains("refreshing"), "{text}"); } }