//! Drawing the model. //! //! `render(model, theme, now, frame)` is a pure function of the model, the //! theme 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. The theme is threaded the same way and for the same reason. //! //! Every colour here is a makeover intent (`crate::theme`), never a literal. //! The intent names what a thing IS — a danger, a muted label, the surface a //! modal floats on — and which colour that is belongs to the theme file. What //! the terminal costs is geometry rather than colour, so nothing below leans on //! a hue to carry a distinction that the layout should be carrying. use chrono::{DateTime, Utc}; use makeover_tui::Theme; use makeover_tui::makeover_layout::{Column, Priority, Width}; use makeover_tui::table::{self, Cell as TableCell, Sizing, TableStyle}; use ops_status::{Method, Node, Status}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, TableState, Tabs}; use crate::model::{LiveRow, Model, Prompt, SourceState, StoreRow, Tab}; use crate::value; pub(crate) fn render(model: &Model, theme: &Theme, 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, theme, now, frame, header); match model.tab { Tab::Live => render_live(model, theme, now, frame, body), Tab::Logs => render_logs(model, theme, frame, body), Tab::Store => render_store(model, theme, now, frame, 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, theme, frame, body); } render_footer(model, theme, frame, footer); } /// Secondary text: labels, hints, the things you read past. fn muted(theme: &Theme) -> Style { Style::default().fg(theme.content_muted) } /// The selected row, on every list and table here. /// /// A raised surface rather than a reversed one. Reversing swaps a span's own /// foreground into the background, so a selected `FAIL` row used to paint its /// danger colour behind the text and lose the one signal the row carries; /// giving selection a surface of its own leaves every status colour intact on /// top of it. fn selected(theme: &Theme) -> Style { Style::default() .bg(theme.surface_raised) .fg(theme.content_primary) } /// A bordered container: the frame in the theme's border colour, the title in /// secondary content so it reads as a label on the box rather than as content. fn container(theme: &Theme, title: &'static str) -> Block<'static> { Block::bordered() .border_style(Style::default().fg(theme.line_border)) .title(Span::styled( title, Style::default().fg(theme.content_secondary), )) } fn render_header(model: &Model, theme: &Theme, 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); // The worst-status mark is a filled chip: the worst status is the one thing on // screen that has to be readable from across the room, so it takes the // status colour as a background rather than as text. let status = value::status_style(theme, worst); frame.render_widget( Paragraph::new(Span::styled( format!(" {} ", value::status_mark(worst)), Style::default() .bg(status.fg.unwrap_or(theme.content_primary)) .fg(theme.surface_page) .add_modifier(Modifier::BOLD), )), mark, ); frame.render_widget( Tabs::new(Tab::titles()) .select(model.tab_index()) .style(muted(theme)) .highlight_style(selected(theme).add_modifier(Modifier::BOLD)) .divider(" "), tabs, ); } fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { let text = match &model.message { // A fired action's outcome, which is transient and worth noticing. Some(message) => Span::styled( format!(" {message}"), Style::default().fg(theme.status_warning), ), None => Span::styled( " tab/shift-tab switch up/down move enter run action 1-3 jump q quit", muted(theme), ), }; frame.render_widget( Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)), area, ); } // --------------------------------------------------------------------------- // Live // --------------------------------------------------------------------------- /// The live tab's columns, left to right. /// /// The mark's name is empty because the name is what the header row draws, and /// this column's header always was blank: the glyph says what it is. /// /// Nothing here is `Optional`. A live view with the status or the source name /// dropped is not a narrower view, it is a different screen, and the age is /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail /// absorbs what is left, which is what the old `Min(10)` was saying. const LIVE_COLUMNS: [Column<'static>; 4] = [ Column { name: "", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "source", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "age", width: Width::Content, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "detail", width: Width::Fill, priority: Priority::Essential, sortable: false, sorted: None, }, ]; /// The tracks the hand-written `Constraint`s carried, lifted rather than /// re-chosen. The two `Width::Content` columns measure themselves from the /// cells and use these only as a floor, so a run of short source names stops /// spending fourteen columns to say `pom`. const LIVE_SIZING: Sizing<'static> = Sizing { lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)], fallback: 8, }; /// Every source at once, worst first, with each source's nodes under it. /// /// The nodes are indented into the same table rather than given a pane of their /// own: there is one cursor, and what it is on is what the detail pane below /// explains. That is the whole of what the per-source tabs used to do, minus /// the visiting them one at a time. fn render_live(model: &Model, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect) { // The table takes the majority and the detail pane what is left. A fixed // height for the detail would eat the whole body on a short terminal, which // is the one case where the list is the thing you need. let [table_area, detail_area] = Layout::vertical([Constraint::Percentage(60), Constraint::Min(3)]).areas(area); let live = model.live_rows(now); let rows: Vec> = live .iter() .map(|row| match row { LiveRow::Source { 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(), }; vec![ // The mark styles its own span rather than the cell: a // status colour is this app's, not a part the table module // knows, and a span's style sits on top of the cell's. TableCell::new( "", Span::styled( value::status_mark(status), value::status_style(theme, status), ), ), TableCell::new( "source", Span::styled( source.name.clone(), Style::default() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), ), ), TableCell::new("age", Span::styled(age, muted(theme))), TableCell::new("detail", source.summary(now)), ] } LiveRow::Node { node, depth, .. } => vec![ TableCell::new( "", Span::styled( value::status_mark(node.status), value::status_style(theme, node.status), ), ), TableCell::new("source", format!("{}{}", " ".repeat(*depth), node.label)), // A node has no age of its own; the source line above it carries // the one age there is, and repeating it would say that each node // was measured separately. TableCell::new("age", Span::styled(String::new(), muted(theme))), TableCell::new("detail", Span::styled(node.kind.clone(), muted(theme))), ], }) .collect(); // The block first, because narrowing is measured against the width the // table actually gets rather than the width of the area around it. Two // columns of border is the difference between "detail fits" and "detail // is cut", which is exactly the decision the cutoff is making. let block = container(theme, " all sources "); let inner = block.inner(table_area); let table = table::table( &LIVE_COLUMNS, &rows, &LIVE_SIZING, &TableStyle::from_theme(theme), inner.width, ) .block(block); // Selection through the widget's own highlight rather than a per-row style. // `TableStyle::from_theme` carries it on the background alone, which is what // leaves a FAIL row's danger colour on top of it -- the same reason the // local `selected` helper gives a surface instead of reversing. let mut state = TableState::default().with_selected(Some(model.selected)); frame.render_stateful_widget(table, table_area, &mut state); let row = live.get(model.selected); let source = row.map(|r| &model.sources[r.source_index()]); render_detail( source, row.and_then(LiveRow::node), theme, 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: Option<&SourceState>, node: Option<&Node>, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect, ) { let width = area.width.saturating_sub(4) as usize; let mut lines: Vec = Vec::new(); match node { Some(node) => { lines.push(Line::from(vec![ Span::styled( value::status_mark(node.status), value::status_style(theme, node.status), ), Span::raw(" "), Span::styled( node.label.clone(), Style::default() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), ), ])); lines.extend(field_lines(theme, node, now, width)); lines.extend(condition_lines(theme, 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.is_some_and(|s| s.allow_actions) { " (enter to run)" } else { " (read-only)" }; lines.push(Line::from(vec![ Span::styled( format!("actions: {}", node.actions.join(", ")), muted(theme), ), Span::styled(hint, muted(theme)), ])); } } // A source line, or an empty list. Either way the source's own summary // is the thing worth saying: it is why the source has no nodes to // select, when it has none. None => lines.push(Line::from(Span::styled( source.map_or_else(|| "no sources".to_string(), |source| source.summary(now)), muted(theme), ))), } frame.render_widget( Paragraph::new(lines).block(container(theme, " detail ")), area, ); } // --------------------------------------------------------------------------- // Logs // --------------------------------------------------------------------------- /// The logs tab's columns. /// /// The source is a column on every line rather than a heading over a section, /// so a line read on its own still says who said it. The grouping is still /// there: the rows arrive grouped by source and the column makes the boundaries /// visible without costing an index that does not line up with the cursor. const LOG_COLUMNS: [Column<'static>; 5] = [ Column { name: "when", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "source", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "", width: Width::Fixed, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "event", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "detail", width: Width::Fill, priority: Priority::Optional, sortable: false, sorted: None, }, ]; const LOG_SIZING: Sizing<'static> = Sizing { lengths: &[ ("when", 9), ("source", 10), ("", 4), ("event", 20), ("detail", 10), ], fallback: 8, }; /// What every source has said lately, grouped by which one said it. /// /// The events are contract (`ops_status::Event`) and nothing here knows what any /// of them mean, which is the same bargain the rest of the shell makes: a new /// daemon that emits events gets this screen for free. fn render_logs(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { let logs = model.log_rows(); if logs.is_empty() { frame.render_widget( Paragraph::new(Line::from(Span::styled( "no source has reported an event", muted(theme), ))) .block(container(theme, " logs ")), area, ); return; } let rows: Vec> = logs .iter() .map(|row| { let status = row.event.status; vec![ TableCell::new( "when", Span::styled(row.event.at.format("%H:%M:%S").to_string(), muted(theme)), ), TableCell::new("source", row.source.to_string()), TableCell::new( "", match status { Some(status) => Span::styled( value::status_mark(status), value::status_style(theme, status), ), // An event with no status is a note, not a verdict. // Blank rather than a guessed mark: inventing "ok" here // is exactly the domain knowledge the shell refuses. None => Span::raw(""), }, ), TableCell::new("event", row.event.label.clone()), TableCell::new( "detail", Span::styled(row.event.detail.clone().unwrap_or_default(), muted(theme)), ), ] }) .collect(); let block = container(theme, " logs "); let inner = block.inner(area); let table = table::table( &LOG_COLUMNS, &rows, &LOG_SIZING, &TableStyle::from_theme(theme), inner.width, ) .block(block); let mut state = TableState::default().with_selected(Some(model.logs_scroll)); frame.render_stateful_widget(table, area, &mut state); } // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- /// The store tab's columns. /// /// `series` is the operator's own label for the number, not the store's series /// name: the store cannot say what a series means, so the config does, and it is /// the config's word that goes on screen. `labels` is the producer's dimension /// text, carried through verbatim rather than parsed into columns this crate /// would have to invent. const STORE_COLUMNS: [Column<'static>; 5] = [ Column { name: "store", width: Width::Content, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "series", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "value", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "age", width: Width::Content, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "labels", width: Width::Fill, priority: Priority::Optional, sortable: false, sorted: None, }, ]; const STORE_SIZING: Sizing<'static> = Sizing { lengths: &[ ("store", 10), ("series", 20), ("value", 12), ("age", 8), ("labels", 10), ], fallback: 8, }; /// The series a configured store has recorded. /// /// The one screen here that renders something other than the `ops-status` /// contract. What keeps that break narrow is that every row below comes from a /// series the config named: nothing is discovered, nothing is inferred, and a /// series nobody named is not on this screen. fn render_store(model: &Model, theme: &Theme, now: DateTime, frame: &mut Frame, area: Rect) { let store_rows = model.store_rows(); if store_rows.is_empty() { frame.render_widget( Paragraph::new(Line::from(Span::styled( "no [[store]] configured", muted(theme), ))) .block(container(theme, " store ")), area, ); return; } let rows: Vec> = store_rows .iter() .map(|row| match row { StoreRow::Unavailable { store, reason } => vec![ TableCell::new("store", (*store).to_string()), TableCell::new( "series", Span::styled( "unavailable", Style::default() .fg(theme.status_danger) .add_modifier(Modifier::BOLD), ), ), TableCell::new("value", Span::styled(String::new(), muted(theme))), TableCell::new("age", Span::styled(String::new(), muted(theme))), TableCell::new( "labels", Span::styled( (*reason).to_string(), Style::default().fg(theme.status_danger), ), ), ], StoreRow::Missing { store, spec } => vec![ TableCell::new("store", (*store).to_string()), TableCell::new("series", spec.label.clone()), // A named series with nothing behind it is shown, not skipped: // a soak target that has never reported is the thing worth // noticing, and omitting it would be indistinguishable from // never having configured it. TableCell::new( "value", Span::styled( "no observations", value::status_style(theme, Status::Unknown), ), ), TableCell::new("age", Span::styled(String::new(), muted(theme))), TableCell::new("labels", Span::styled(String::new(), muted(theme))), ], StoreRow::Value { store, spec, reading, } => vec![ TableCell::new("store", (*store).to_string()), TableCell::new("series", spec.label.clone()), TableCell::new( "value", // `quantity` is what the `ops-status` contract's own // magnitude values render through, so a number on this tab // reads the same as one on the live tab. The unit is the // config's word, since the store has none. Span::styled( value::quantity(reading.value, spec.unit.as_deref()), Style::default().fg(theme.content_primary), ), ), TableCell::new( "age", Span::styled( value::duration((now - reading.at).num_seconds()), muted(theme), ), ), TableCell::new("labels", Span::styled(reading.labels.clone(), muted(theme))), ], }) .collect(); let block = container(theme, " store "); let inner = block.inner(area); let table = table::table( &STORE_COLUMNS, &rows, &STORE_SIZING, &TableStyle::from_theme(theme), inner.width, ) .block(block); let mut state = TableState::default().with_selected(Some(model.store_scroll)); frame.render_stateful_widget(table, area, &mut state); } fn field_lines(theme: &Theme, 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(theme, *value, *max) } other => value::style(theme, 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(theme, condition.status), ), Span::styled( condition.condition_type.clone(), Style::default().fg(theme.content_primary), ), ]; 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))), muted(theme), )); } 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, theme: &Theme, frame: &mut Frame, area: Rect) { let Some(prompt) = &model.prompt else { return }; let source = model.sources.get(prompt_source(prompt)); let plain = Style::default().fg(theme.content_primary); let danger_style = Style::default().fg(theme.status_danger); 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 { " " }; // Danger is the foreground and selection the surface, so a // selected danger action is still red rather than swapped into // its own background. let mut style = if danger { danger_style } else { plain }; if i == *selected { style = style.bg(theme.surface_raised); } lines.push(Line::from(Span::styled( format!("{marker}{}. {label} [{key}]", i + 1), style, ))); } lines.push(Line::from("")); lines.push(Line::from(Span::styled( "enter run esc cancel", muted(theme), ))); ( " run action ", Style::default().fg(theme.line_border), lines, ) } Prompt::Confirm { key, .. } => { let action = source.and_then(|s| s.action(key)); let lines = vec![ action_summary_line(theme, action, key), Line::from(""), Line::from(Span::styled( "press y to confirm esc cancel", muted(theme), )), ]; (" confirm ", Style::default().fg(theme.line_border), lines) } Prompt::Type { key, typed, .. } => { let lines = vec![ Line::from(Span::styled( "DANGER", danger_style.add_modifier(Modifier::BOLD), )), action_summary_line(theme, source.and_then(|s| s.action(key)), key), Line::from(""), Line::from(Span::styled( format!("type '{key}' to confirm:"), muted(theme), )), Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)), Line::from(""), Line::from(Span::styled("esc cancel", muted(theme))), ]; (" DANGER ", danger_style.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, // then paint the overlay surface: makeover's `surface.overlay` is the // intent for exactly this, a surface that floats above the page. frame.render_widget(Clear, popup); frame.render_widget( Paragraph::new(lines) .style(Style::default().bg(theme.surface_overlay)) .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( theme: &Theme, action: Option<&ops_status::Action>, key: &str, ) -> Line<'static> { match action { Some(action) => Line::from(vec![ Span::styled( action.label.clone(), Style::default() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {} {}", method_str(action.method), action.url), Style::default().fg(theme.content_secondary), ), ]), // 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(theme.status_warning), )), } } 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 theme = crate::theme::tests::fixed(); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| render(model, &theme, 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_live_tab_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("live"), "the tab bar names the fixed tabs:\n{text}" ); assert!(text.contains("logs"), "{text}"); assert!(text.contains("store"), "{text}"); // Skip the tab bar, which names every tab 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 no_source_gets_a_tab_of_its_own() { // The restructure, asserted directly: two sources, three tabs, and the // tab bar names none of them. let model = Model::new(vec![ source("sando", now(), vec![node("a", "tier a", Status::Ok)]), source("bento", now(), vec![node("b", "goingson", Status::Ok)]), ]); let bar = draw(&model, now(), 80, 12)[0].clone(); assert!( bar.contains("live") && bar.contains("logs") && bar.contains("store"), "{bar}" ); assert!( !bar.contains("sando"), "a source must not own a tab:\n{bar}" ); assert!(!bar.contains("bento"), "{bar}"); } #[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_on_the_live_tab() { 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_narrow_live_tab_drops_the_age_before_it_drops_the_detail() { // What the hand-written `Constraint`s could not do: at 80 columns every // column is drawn, and at a width where they no longer all fit the // priority decides which one goes rather than the order they were // written in. Age is the only Secondary column, so it is the only one // that can go. let model = Model::new(vec![source( "pom", now() - TimeDelta::hours(4), vec![node("backup", "backup", Status::Ok)], )]); let wide = joined(&draw(&model, now(), 80, 12)); assert!(wide.contains("age"), "the age column at 80 wide:\n{wide}"); let narrow = joined(&draw(&model, now(), 24, 12)); assert!(!narrow.contains("age"), "age must drop first:\n{narrow}"); assert!(narrow.contains("pom"), "the source stays:\n{narrow}"); assert!(narrow.contains("detail"), "the detail stays:\n{narrow}"); } #[test] fn the_live_tab_nests_nodes_under_their_source_and_children_under_those() { 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.selected = 1; let lines = draw(&model, now(), 80, 20); let text = joined(&lines); let source_row = lines .iter() .position(|l| l.contains("sando") && !l.contains("live")) .unwrap(); let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap(); let child_row = lines .iter() .rposition(|l| l.contains("prod-1") && !l.contains("b (prod-1)")) .unwrap(); assert!( source_row < parent_row, "the source leads its nodes:\n{text}" ); assert!(parent_row < child_row, "{text}"); // Each level is indented relative to the one above it. let source_col = lines[source_row].find("sando").unwrap(); let parent_col = lines[parent_row].find("b (prod-1)").unwrap(); let child_col = lines[child_row].find("prod-1").unwrap(); assert!( source_col < parent_col, "a node is indented under its source:\n{text}" ); assert!(parent_col < child_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.selected = 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.selected = 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.selected = 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.selected = 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.selected = 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.selected = 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]); // Row 0 is the source line, row 1 its only node. m.selected = 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(now()); 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(now()); 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(now()); 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}"); } fn with_events(name: &str, events: Vec) -> SourceState { let mut s = SourceState::new(name, TimeDelta::seconds(60)); let mut p = Payload::new(name, now()); p.events = events; s.observe(p, now()); s } fn ev(minutes_ago: i64, label: &str, status: Option) -> ops_status::Event { ops_status::Event { at: now() - TimeDelta::minutes(minutes_ago), label: label.into(), status, detail: None, node_id: None, } } #[test] fn the_logs_tab_shows_every_sources_events_with_who_said_it() { let mut model = Model::new(vec![ with_events("zebra", vec![ev(5, "sweep finished", Some(Status::Ok))]), with_events( "alpha", vec![ev(1, "promote refused", Some(Status::Failed))], ), ]); model.tab = crate::model::Tab::Logs; let lines = draw(&model, now(), 80, 14); let text = joined(&lines); assert!(text.contains("promote refused"), "{text}"); assert!(text.contains("sweep finished"), "{text}"); // Every line says who said it, so a line read alone is still readable. assert!(text.contains("alpha"), "{text}"); assert!(text.contains("zebra"), "{text}"); // Grouped by source, in name order. let alpha = lines.iter().position(|l| l.contains("alpha")).unwrap(); let zebra = lines.iter().position(|l| l.contains("zebra")).unwrap(); assert!(alpha < zebra, "sources group in name order:\n{text}"); // An event's own status colours it through the same marks as a node's. assert!(text.contains("FAIL"), "{text}"); } #[test] fn a_logs_tab_with_nothing_in_it_says_so_rather_than_showing_an_empty_box() { let mut model = Model::new(vec![source("sando", now(), vec![])]); model.tab = crate::model::Tab::Logs; let text = joined(&draw(&model, now(), 80, 14)); assert!(text.contains("no source has reported an event"), "{text}"); } #[test] fn an_event_with_no_status_is_a_note_and_gets_no_mark() { let mut model = Model::new(vec![with_events( "sando", vec![ev(1, "config reloaded", None)], )]); model.tab = crate::model::Tab::Logs; let lines = draw(&model, now(), 80, 14); let text = joined(&lines); let row = lines .iter() .find(|l| l.contains("config reloaded")) .unwrap_or_else(|| panic!("{text}")); // Only the event's own row: the header chip carries the worst status // across every source, which is a different claim. for mark in ["ok", "FAIL", "degr", "????"] { assert!( !row.contains(mark), "a note must not be given a verdict ({mark}):\n{text}" ); } } fn stored( series: &[(&str, &str, Option<&str>)], readings: Vec, ) -> Model { let mut store = crate::model::StoreState::new( "witchbroom", series .iter() .map(|(s, label, unit)| crate::config::Series { name: (*s).to_string(), label: (*label).to_string(), unit: unit.map(ToString::to_string), }) .collect(), ); store.observe(readings, now()); let mut model = Model::new(vec![]).with_stores(vec![store]); model.tab = crate::model::Tab::Store; model } fn stored_at( series: &str, labels: &str, value: f64, at: DateTime, ) -> crate::store::Reading { crate::store::Reading { series: series.into(), labels: labels.into(), value, at, } } #[test] fn the_store_tab_shows_a_configured_series_with_its_label_and_unit() { let model = stored( &[("soak.coverage_edges", "Coverage reached", Some("edges"))], vec![stored_at( "soak.coverage_edges", r#"{"repo":"mnw-server"}"#, 41_200.0, now() - TimeDelta::hours(2), )], ); let text = joined(&draw(&model, now(), 100, 14)); // The config's label, not the store's series name: the store cannot say // what a number means, so what is on screen is what the operator said. assert!(text.contains("Coverage reached"), "{text}"); assert!(!text.contains("soak.coverage_edges"), "{text}"); assert!( text.contains("edges"), "the unit comes from config:\n{text}" ); assert!(text.contains("41.2k"), "{text}"); assert!(text.contains("2h"), "how old the number is:\n{text}"); // The producer's dimensions, verbatim rather than parsed into columns. assert!(text.contains("mnw-server"), "{text}"); } #[test] fn a_configured_series_with_nothing_behind_it_is_shown_not_skipped() { // A soak target that has never reported is the thing worth noticing. let model = stored(&[("soak.coverage_edges", "Coverage reached", None)], vec![]); let text = joined(&draw(&model, now(), 100, 14)); assert!(text.contains("Coverage reached"), "{text}"); assert!(text.contains("no observations"), "{text}"); } #[test] fn an_unreadable_store_is_visibly_unavailable_rather_than_an_empty_tab() { let mut model = stored( &[("s", "Something", None)], vec![stored_at("s", "{}", 7.0, now())], ); model.stores[0].observe_error("unable to open database file"); let lines = draw(&model, now(), 100, 14); let text = joined(&lines); assert!(text.contains("unavailable"), "{text}"); assert!(text.contains("unable to open database file"), "{text}"); // Above the stale numbers, so they are not read as current. let bad = lines .iter() .position(|l| l.contains("unavailable")) .unwrap(); let old = lines.iter().position(|l| l.contains("Something")).unwrap(); assert!(bad < old, "{text}"); } #[test] fn a_store_tab_with_no_store_configured_says_so() { let mut model = Model::new(vec![source("sando", now(), vec![])]); model.tab = crate::model::Tab::Store; let text = joined(&draw(&model, now(), 80, 14)); assert!(text.contains("no [[store]] configured"), "{text}"); } #[test] fn the_detail_pane_on_a_source_line_shows_that_sources_summary() { // The cursor starts on a source line, which has no node to explain. let model = Model::new(vec![source( "sando", now(), vec![node("a", "tier a", Status::Ok)], )]); let text = joined(&draw(&model, now(), 80, 20)); assert!(text.contains("1 node ok"), "{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}"); } }