//! 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;