Skip to main content

max / makenotwork

34.3 KB · 946 lines History Blame Raw
1 //! Drawing the model.
2 //!
3 //! `render(model, theme, now, frame)` is a pure function of the model, the
4 //! theme and the clock. `now` is threaded through rather than read from the
5 //! system clock precisely so that this stays true: with an ambient clock, every
6 //! snapshot test becomes time-dependent and the whole surface stops being
7 //! verifiable without a live daemon. That is unpleasant to retrofit and trivial
8 //! to keep. The theme is threaded the same way and for the same reason.
9 //!
10 //! Every colour here is a makeover intent (`crate::theme`), never a literal.
11 //! The intent names what a thing IS — a danger, a muted label, the surface a
12 //! modal floats on — and which colour that is belongs to the theme file. What
13 //! the terminal costs is geometry rather than colour, so nothing below leans on
14 //! a hue to carry a distinction that the layout should be carrying.
15
16 use chrono::{DateTime, Utc};
17 use makeover_tui::Theme;
18 use makeover_tui::makeover_layout::{Column, Priority, Width};
19 use makeover_tui::table::{self, Cell as TableCell, Sizing, TableStyle};
20 use ops_status::{Method, Node};
21 use ratatui::Frame;
22 use ratatui::layout::{Constraint, Layout, Rect};
23 use ratatui::style::{Modifier, Style};
24 use ratatui::text::{Line, Span};
25 use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, TableState, Tabs};
26
27 use crate::model::{Model, Prompt, SourceState, Tab};
28 use crate::value;
29
30 pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame) {
31 let [header, body, footer] = Layout::vertical([
32 Constraint::Length(1),
33 Constraint::Min(1),
34 Constraint::Length(1),
35 ])
36 .areas(frame.area());
37
38 render_header(model, theme, now, frame, header);
39 match model.tab {
40 Tab::Rollup => render_rollup(model, theme, now, frame, body),
41 Tab::Source(i) => match model.sources.get(i) {
42 Some(source) => render_source(source, theme, now, frame, body),
43 None => frame.render_widget(Paragraph::new("no such source").style(muted(theme)), body),
44 },
45 }
46 // A prompt floats over whatever tab is showing: the state behind it keeps
47 // updating on every poll, which is the point of not blocking on the modal.
48 if model.prompt.is_some() {
49 render_prompt(model, theme, frame, body);
50 }
51 render_footer(model, theme, frame, footer);
52 }
53
54 /// Secondary text: labels, hints, the things you read past.
55 fn muted(theme: &Theme) -> Style {
56 Style::default().fg(theme.content_muted)
57 }
58
59 /// The selected row, on every list and table here.
60 ///
61 /// A raised surface rather than a reversed one. Reversing swaps a span's own
62 /// foreground into the background, so a selected `FAIL` row used to paint its
63 /// danger colour behind the text and lose the one signal the row carries;
64 /// giving selection a surface of its own leaves every status colour intact on
65 /// top of it.
66 fn selected(theme: &Theme) -> Style {
67 Style::default()
68 .bg(theme.surface_raised)
69 .fg(theme.content_primary)
70 }
71
72 /// A bordered container: the frame in the theme's border colour, the title in
73 /// secondary content so it reads as a label on the box rather than as content.
74 fn container(theme: &Theme, title: &'static str) -> Block<'static> {
75 Block::bordered()
76 .border_style(Style::default().fg(theme.line_border))
77 .title(Span::styled(
78 title,
79 Style::default().fg(theme.content_secondary),
80 ))
81 }
82
83 fn render_header(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
84 let worst = model.worst(now);
85 let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area);
86
87 // The rollup mark is a filled chip: the worst status is the one thing on
88 // screen that has to be readable from across the room, so it takes the
89 // status colour as a background rather than as text.
90 let status = value::status_style(theme, worst);
91 frame.render_widget(
92 Paragraph::new(Span::styled(
93 format!(" {} ", value::status_mark(worst)),
94 Style::default()
95 .bg(status.fg.unwrap_or(theme.content_primary))
96 .fg(theme.surface_page)
97 .add_modifier(Modifier::BOLD),
98 )),
99 mark,
100 );
101 frame.render_widget(
102 Tabs::new(model.tab_titles())
103 .select(model.tab_index())
104 .style(muted(theme))
105 .highlight_style(selected(theme).add_modifier(Modifier::BOLD))
106 .divider(" "),
107 tabs,
108 );
109 }
110
111 fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
112 let text = match &model.message {
113 // A fired action's outcome, which is transient and worth noticing.
114 Some(message) => Span::styled(
115 format!(" {message}"),
116 Style::default().fg(theme.status_warning),
117 ),
118 None => Span::styled(
119 " tab/shift-tab switch up/down move enter open 1-9 jump q quit",
120 muted(theme),
121 ),
122 };
123 frame.render_widget(
124 Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)),
125 area,
126 );
127 }
128
129 // ---------------------------------------------------------------------------
130 // Rollup
131 // ---------------------------------------------------------------------------
132
133 /// The rollup's columns, left to right.
134 ///
135 /// The mark's name is empty because the name is what the header row draws, and
136 /// this column's header always was blank: the glyph says what it is.
137 ///
138 /// Nothing here is `Optional`. A rollup with the status or the source name
139 /// dropped is not a narrower rollup, it is a different screen, and the age is
140 /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail
141 /// absorbs what is left, which is what the old `Min(10)` was saying.
142 const ROLLUP_COLUMNS: [Column<'static>; 4] = [
143 Column {
144 name: "",
145 width: Width::Fixed,
146 priority: Priority::Essential,
147 sortable: false,
148 sorted: None,
149 },
150 Column {
151 name: "source",
152 width: Width::Content,
153 priority: Priority::Essential,
154 sortable: false,
155 sorted: None,
156 },
157 Column {
158 name: "age",
159 width: Width::Content,
160 priority: Priority::Secondary,
161 sortable: false,
162 sorted: None,
163 },
164 Column {
165 name: "detail",
166 width: Width::Fill,
167 priority: Priority::Essential,
168 sortable: false,
169 sorted: None,
170 },
171 ];
172
173 /// The tracks the hand-written `Constraint`s carried, lifted rather than
174 /// re-chosen. The two `Width::Content` columns measure themselves from the
175 /// cells and use these only as a floor, so a run of short source names stops
176 /// spending fourteen columns to say `pom`.
177 const ROLLUP_SIZING: Sizing<'static> = Sizing {
178 lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)],
179 fallback: 8,
180 };
181
182 /// Every source at once, worst first.
183 ///
184 /// Without this magicmirror is N tabs you still have to visit one at a time,
185 /// which is the situation it replaces, with extra steps.
186 fn render_rollup(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
187 let order = model.rollup_order(now);
188 let rows: Vec<Vec<TableCell>> = order
189 .iter()
190 .map(|&index| {
191 let source = &model.sources[index];
192 let status = source.status(now);
193 let age = match source.age(now) {
194 Some(age) => value::duration(age.num_seconds()),
195 None => "-".into(),
196 };
197 vec![
198 // The mark styles its own span rather than the cell: a status
199 // colour is this app's, not a part the table module knows, and
200 // a span's style sits on top of the cell's.
201 TableCell::new(
202 "",
203 Span::styled(
204 value::status_mark(status),
205 value::status_style(theme, status),
206 ),
207 ),
208 TableCell::new("source", source.name.clone()),
209 TableCell::new("age", Span::styled(age, muted(theme))),
210 TableCell::new("detail", source.summary(now)),
211 ]
212 })
213 .collect();
214
215 // The block first, because narrowing is measured against the width the
216 // table actually gets rather than the width of the area around it. Two
217 // columns of border is the difference between "detail fits" and "detail
218 // is cut", which is exactly the decision the cutoff is making.
219 let block = container(theme, " all sources ");
220 let inner = block.inner(area);
221 let table = table::table(
222 &ROLLUP_COLUMNS,
223 &rows,
224 &ROLLUP_SIZING,
225 &TableStyle::from_theme(theme),
226 inner.width,
227 )
228 .block(block);
229
230 // Selection through the widget's own highlight rather than a per-row style.
231 // `TableStyle::from_theme` carries it on the background alone, which is what
232 // leaves a FAIL row's danger colour on top of it -- the same reason the
233 // local `selected` helper gives a surface instead of reversing.
234 let mut state = TableState::default().with_selected(Some(model.rollup_selected));
235 frame.render_stateful_widget(table, area, &mut state);
236 }
237
238 // ---------------------------------------------------------------------------
239 // One source
240 // ---------------------------------------------------------------------------
241
242 fn render_source(
243 source: &SourceState,
244 theme: &Theme,
245 now: DateTime<Utc>,
246 frame: &mut Frame,
247 area: Rect,
248 ) {
249 let [list_area, detail_area] =
250 Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area);
251
252 let rows = source.rows();
253 let items: Vec<ListItem> = rows
254 .iter()
255 .enumerate()
256 .map(|(i, row)| {
257 let indent = " ".repeat(row.depth);
258 let spans = vec![
259 Span::styled(
260 format!("{:<5}", value::status_mark(row.node.status)),
261 value::status_style(theme, row.node.status),
262 ),
263 Span::styled(
264 format!("{indent}{}", row.node.label),
265 Style::default().fg(theme.content_primary),
266 ),
267 Span::styled(format!(" ({})", row.node.kind), muted(theme)),
268 ];
269 // Selection is the row's surface, applied under the spans rather
270 // than patched into each one, so a status colour survives being
271 // selected.
272 let item = ListItem::new(Line::from(spans));
273 if i == source.selected {
274 item.style(selected(theme))
275 } else {
276 item
277 }
278 })
279 .collect();
280
281 let title = format!(" {} ", source.name);
282 let list = if items.is_empty() {
283 List::new(vec![ListItem::new(Line::from(Span::styled(
284 source.summary(now),
285 muted(theme),
286 )))])
287 } else {
288 List::new(items)
289 };
290 frame.render_widget(
291 list.block(
292 Block::bordered()
293 .border_style(Style::default().fg(theme.line_border))
294 .title(Span::styled(
295 title,
296 Style::default().fg(theme.content_secondary),
297 )),
298 ),
299 list_area,
300 );
301
302 render_detail(source, theme, now, frame, detail_area);
303 }
304
305 /// The selected node's fields and conditions.
306 ///
307 /// Conditions are the half that usually gets dropped, and the half that pays:
308 /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves
309 /// an SSH.
310 fn render_detail(
311 source: &SourceState,
312 theme: &Theme,
313 now: DateTime<Utc>,
314 frame: &mut Frame,
315 area: Rect,
316 ) {
317 let width = area.width.saturating_sub(4) as usize;
318 let mut lines: Vec<Line> = Vec::new();
319
320 match source.selected_node() {
321 Some(node) => {
322 lines.push(Line::from(vec![
323 Span::styled(
324 value::status_mark(node.status),
325 value::status_style(theme, node.status),
326 ),
327 Span::raw(" "),
328 Span::styled(
329 node.label.clone(),
330 Style::default()
331 .fg(theme.content_primary)
332 .add_modifier(Modifier::BOLD),
333 ),
334 ]));
335 lines.extend(field_lines(theme, node, now, width));
336 lines.extend(condition_lines(theme, node, width));
337 if !node.actions.is_empty() {
338 // The hint tells the operator whether Enter does anything here,
339 // so a read-only source does not look broken when a keypress is
340 // ignored.
341 let hint = if source.allow_actions {
342 " (enter to run)"
343 } else {
344 " (read-only)"
345 };
346 lines.push(Line::from(vec![
347 Span::styled(
348 format!("actions: {}", node.actions.join(", ")),
349 muted(theme),
350 ),
351 Span::styled(hint, muted(theme)),
352 ]));
353 }
354 }
355 None => lines.push(Line::from(Span::styled(source.summary(now), muted(theme)))),
356 }
357
358 frame.render_widget(
359 Paragraph::new(lines).block(container(theme, " detail ")),
360 area,
361 );
362 }
363
364 fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
365 let label_width = node
366 .fields
367 .iter()
368 .map(|f| f.label.chars().count())
369 .max()
370 .unwrap_or(0);
371
372 node.fields
373 .iter()
374 .map(|field| {
375 let budget = width.saturating_sub(label_width + 2);
376 let rendered = value::render(&field.value, now, budget);
377 let style = match &field.value {
378 ops_status::Value::Progress { value, max, .. } => {
379 value::progress_style(theme, *value, *max)
380 }
381 other => value::style(theme, other),
382 };
383 Line::from(vec![
384 Span::styled(format!("{:<label_width$} ", field.label), muted(theme)),
385 Span::styled(rendered, style),
386 ])
387 })
388 .collect()
389 }
390
391 fn condition_lines(theme: &Theme, node: &Node, width: usize) -> Vec<Line<'static>> {
392 node.conditions
393 .iter()
394 .map(|condition| {
395 let mut spans = vec![
396 Span::styled(
397 format!("{:<5}", value::status_mark(condition.status)),
398 value::status_style(theme, condition.status),
399 ),
400 Span::styled(
401 condition.condition_type.clone(),
402 Style::default().fg(theme.content_primary),
403 ),
404 ];
405 if let Some(detail) = &condition.detail {
406 let budget = width.saturating_sub(condition.condition_type.chars().count() + 8);
407 spans.push(Span::styled(
408 format!(" {}", truncate(detail, budget.max(8))),
409 muted(theme),
410 ));
411 }
412 Line::from(spans)
413 })
414 .collect()
415 }
416
417 // ---------------------------------------------------------------------------
418 // Action prompts
419 // ---------------------------------------------------------------------------
420
421 /// The modal path to firing an action: pick, then clear its guard.
422 ///
423 /// A `danger` action shows a red header and asks the operator to type its key;
424 /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The
425 /// resolved host is left off — the tab already names the source — so the line
426 /// stays short and shows the method and path the request will use.
427 fn render_prompt(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
428 let Some(prompt) = &model.prompt else { return };
429 let source = model.sources.get(prompt_source(prompt));
430 let plain = Style::default().fg(theme.content_primary);
431 let danger_style = Style::default().fg(theme.status_danger);
432
433 let (title, title_style, lines) = match prompt {
434 Prompt::Pick { keys, selected, .. } => {
435 let mut lines = Vec::new();
436 for (i, key) in keys.iter().enumerate() {
437 let action = source.and_then(|s| s.action(key));
438 let label = action.map_or(key.as_str(), |a| a.label.as_str());
439 let danger = action.is_some_and(|a| a.danger);
440 let marker = if i == *selected { "> " } else { " " };
441 // Danger is the foreground and selection the surface, so a
442 // selected danger action is still red rather than swapped into
443 // its own background.
444 let mut style = if danger { danger_style } else { plain };
445 if i == *selected {
446 style = style.bg(theme.surface_raised);
447 }
448 lines.push(Line::from(Span::styled(
449 format!("{marker}{}. {label} [{key}]", i + 1),
450 style,
451 )));
452 }
453 lines.push(Line::from(""));
454 lines.push(Line::from(Span::styled(
455 "enter run esc cancel",
456 muted(theme),
457 )));
458 (
459 " run action ",
460 Style::default().fg(theme.line_border),
461 lines,
462 )
463 }
464 Prompt::Confirm { key, .. } => {
465 let action = source.and_then(|s| s.action(key));
466 let lines = vec![
467 action_summary_line(theme, action, key),
468 Line::from(""),
469 Line::from(Span::styled(
470 "press y to confirm esc cancel",
471 muted(theme),
472 )),
473 ];
474 (" confirm ", Style::default().fg(theme.line_border), lines)
475 }
476 Prompt::Type { key, typed, .. } => {
477 let lines = vec![
478 Line::from(Span::styled(
479 "DANGER",
480 danger_style.add_modifier(Modifier::BOLD),
481 )),
482 action_summary_line(theme, source.and_then(|s| s.action(key)), key),
483 Line::from(""),
484 Line::from(Span::styled(
485 format!("type '{key}' to confirm:"),
486 muted(theme),
487 )),
488 Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)),
489 Line::from(""),
490 Line::from(Span::styled("esc cancel", muted(theme))),
491 ];
492 (" DANGER ", danger_style.add_modifier(Modifier::BOLD), lines)
493 }
494 };
495
496 let width = lines
497 .iter()
498 .map(Line::width)
499 .chain(std::iter::once(title.len()))
500 .max()
501 .unwrap_or(0) as u16
502 + 4;
503 let height = lines.len() as u16 + 2;
504 let popup = centered(area, width, height);
505
506 // Clear what is under the popup so the tab behind does not show through,
507 // then paint the overlay surface: makeover's `surface.overlay` is the
508 // intent for exactly this, a surface that floats above the page.
509 frame.render_widget(Clear, popup);
510 frame.render_widget(
511 Paragraph::new(lines)
512 .style(Style::default().bg(theme.surface_overlay))
513 .block(
514 Block::bordered()
515 .title(Span::styled(title, title_style))
516 .border_style(title_style),
517 ),
518 popup,
519 );
520 }
521
522 /// One line naming what an action will do: `Roll back POST /rollback/b`.
523 fn action_summary_line(
524 theme: &Theme,
525 action: Option<&ops_status::Action>,
526 key: &str,
527 ) -> Line<'static> {
528 match action {
529 Some(action) => Line::from(vec![
530 Span::styled(
531 action.label.clone(),
532 Style::default()
533 .fg(theme.content_primary)
534 .add_modifier(Modifier::BOLD),
535 ),
536 Span::styled(
537 format!(" {} {}", method_str(action.method), action.url),
538 Style::default().fg(theme.content_secondary),
539 ),
540 ]),
541 // The action was retracted by a poll since the prompt opened; say so
542 // rather than render a blank confirmation.
543 None => Line::from(Span::styled(
544 format!("{key}: no longer offered"),
545 Style::default().fg(theme.status_warning),
546 )),
547 }
548 }
549
550 fn method_str(method: Method) -> &'static str {
551 match method {
552 Method::Get => "GET",
553 Method::Post => "POST",
554 Method::Put => "PUT",
555 Method::Delete => "DELETE",
556 }
557 }
558
559 fn prompt_source(prompt: &Prompt) -> usize {
560 match prompt {
561 Prompt::Pick { source, .. }
562 | Prompt::Confirm { source, .. }
563 | Prompt::Type { source, .. } => *source,
564 }
565 }
566
567 /// A rectangle of the given size centered in `area`, clamped so it always fits.
568 fn centered(area: Rect, width: u16, height: u16) -> Rect {
569 let width = width.min(area.width);
570 let height = height.min(area.height);
571 Rect {
572 x: area.x + (area.width - width) / 2,
573 y: area.y + (area.height - height) / 2,
574 width,
575 height,
576 }
577 }
578
579 /// Clip to a character budget, marking that something was cut.
580 fn truncate(text: &str, max: usize) -> String {
581 // A detail string is producer-supplied and may carry newlines; the pane is
582 // line-oriented, so flatten first.
583 let flat: String = text.replace('\n', " ");
584 if flat.chars().count() <= max {
585 return flat;
586 }
587 let kept: String = flat.chars().take(max.saturating_sub(1)).collect();
588 format!("{kept}")
589 }
590
591 #[cfg(test)]
592 mod tests {
593 use super::*;
594 use crate::model::SourceState;
595 use chrono::TimeDelta;
596 use ops_status::{Condition, Field, Payload, Status, Value};
597 use ratatui::Terminal;
598 use ratatui::backend::TestBackend;
599
600 fn now() -> DateTime<Utc> {
601 "2026-07-21T18:00:00Z".parse().unwrap()
602 }
603
604 /// Render a model into a fixed-size buffer and return it as text lines.
605 ///
606 /// This is the whole payoff of keeping render pure: the entire surface is
607 /// verifiable with no daemon running and no terminal attached.
608 fn draw(model: &Model, now: DateTime<Utc>, width: u16, height: u16) -> Vec<String> {
609 let theme = crate::theme::tests::fixed();
610 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
611 terminal
612 .draw(|frame| render(model, &theme, now, frame))
613 .unwrap();
614 let buffer = terminal.backend().buffer().clone();
615 (0..buffer.area.height)
616 .map(|y| {
617 (0..buffer.area.width)
618 .map(|x| buffer[(x, y)].symbol().to_string())
619 .collect::<String>()
620 .trim_end()
621 .to_string()
622 })
623 .collect()
624 }
625
626 fn node(id: &str, label: &str, status: Status) -> Node {
627 Node {
628 id: id.into(),
629 kind: "tier".into(),
630 label: label.into(),
631 status,
632 fields: Vec::new(),
633 conditions: Vec::new(),
634 children: Vec::new(),
635 actions: Vec::new(),
636 }
637 }
638
639 fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
640 let mut s = SourceState::new(name, TimeDelta::seconds(60));
641 let mut p = Payload::new(name, at);
642 p.nodes = nodes;
643 s.observe(p, at);
644 s
645 }
646
647 fn joined(lines: &[String]) -> String {
648 lines.join("\n")
649 }
650
651 #[test]
652 fn the_rollup_leads_with_the_worst_source() {
653 let model = Model::new(vec![
654 source("sando", now(), vec![node("a", "tier a", Status::Ok)]),
655 source("bento", now(), vec![node("b", "goingson", Status::Failed)]),
656 ]);
657 let lines = draw(&model, now(), 80, 12);
658 let text = joined(&lines);
659
660 assert!(text.contains("rollup"), "{text}");
661 // Skip the tab bar, which names every source regardless of order.
662 let body = &lines[1..];
663 let bento = body.iter().position(|l| l.contains("bento")).unwrap();
664 let sando = body.iter().position(|l| l.contains("sando")).unwrap();
665 assert!(bento < sando, "the failing source must be on top:\n{text}");
666 assert!(text.contains("FAIL"), "{text}");
667 }
668
669 #[test]
670 fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() {
671 let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
672 let text = joined(&draw(&model, now(), 80, 12));
673 assert!(
674 text.contains("????"),
675 "an unreachable source must be loud:\n{text}"
676 );
677 assert!(text.contains("waiting for first poll"), "{text}");
678 }
679
680 #[test]
681 fn a_stale_source_shows_its_age_in_the_rollup() {
682 let model = Model::new(vec![source(
683 "pom",
684 now() - TimeDelta::hours(4),
685 vec![node("backup", "backup", Status::Ok)],
686 )]);
687 let text = joined(&draw(&model, now(), 80, 12));
688 assert!(text.contains("4h"), "the age must be visible:\n{text}");
689 assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}");
690 }
691
692 #[test]
693 fn a_narrow_rollup_drops_the_age_before_it_drops_the_detail() {
694 // What the hand-written `Constraint`s could not do: at 80 columns every
695 // column is drawn, and at a width where they no longer all fit the
696 // priority decides which one goes rather than the order they were
697 // written in. Age is the only Secondary column, so it is the only one
698 // that can go.
699 let model = Model::new(vec![source(
700 "pom",
701 now() - TimeDelta::hours(4),
702 vec![node("backup", "backup", Status::Ok)],
703 )]);
704
705 let wide = joined(&draw(&model, now(), 80, 12));
706 assert!(wide.contains("age"), "the age column at 80 wide:\n{wide}");
707
708 let narrow = joined(&draw(&model, now(), 24, 12));
709 assert!(!narrow.contains("age"), "age must drop first:\n{narrow}");
710 assert!(narrow.contains("pom"), "the source stays:\n{narrow}");
711 assert!(narrow.contains("detail"), "the detail stays:\n{narrow}");
712 }
713
714 #[test]
715 fn a_source_tab_lists_its_nodes_with_children_indented() {
716 let mut parent = node("tier:b", "b (prod-1)", Status::Ok);
717 parent.children = vec!["node:prod-1".into()];
718 let child = node("node:prod-1", "prod-1", Status::Ok);
719
720 let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]);
721 model.select_tab(1);
722 let lines = draw(&model, now(), 80, 20);
723 let text = joined(&lines);
724
725 let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap();
726 let child_row = lines.iter().position(|l| l.contains("prod-1 (")).unwrap();
727 assert!(parent_row < child_row, "{text}");
728 // The child is indented relative to its parent.
729 let parent_col = lines[parent_row].find("b (prod-1)").unwrap();
730 let child_col = lines[child_row].find("prod-1").unwrap();
731 assert!(child_col > parent_col, "child must be indented:\n{text}");
732 }
733
734 #[test]
735 fn the_detail_pane_shows_conditions_with_their_why() {
736 let mut n = node("tier:b", "b", Status::Ok);
737 n.conditions = vec![Condition {
738 condition_type: "burn_in".into(),
739 status: Status::Pending,
740 since: None,
741 detail: Some("17 hours remaining of 48".into()),
742 }];
743 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
744 model.select_tab(1);
745 let text = joined(&draw(&model, now(), 80, 20));
746
747 assert!(text.contains("burn_in"), "{text}");
748 assert!(
749 text.contains("17 hours remaining"),
750 "a condition without its why is useless:\n{text}"
751 );
752 }
753
754 #[test]
755 fn a_progress_field_renders_as_a_bar() {
756 let mut n = node("tier:b", "b", Status::Ok);
757 n.fields = vec![Field::new(
758 "burn-in",
759 Value::Progress {
760 value: 31.0,
761 max: 48.0,
762 unit: Some("hour".into()),
763 },
764 )];
765 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
766 model.select_tab(1);
767 let text = joined(&draw(&model, now(), 80, 20));
768
769 assert!(text.contains("31/48 hour"), "{text}");
770 assert!(
771 text.contains('#'),
772 "a progress value must draw a bar:\n{text}"
773 );
774 }
775
776 #[test]
777 fn an_instant_renders_relative_to_the_passed_in_clock() {
778 let mut n = node("tier:b", "b", Status::Ok);
779 n.fields = vec![Field::new(
780 "built",
781 Value::Instant {
782 value: now() - TimeDelta::minutes(3),
783 },
784 )];
785 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
786 model.select_tab(1);
787 let text = joined(&draw(&model, now(), 80, 20));
788 assert!(text.contains("3m 0s ago"), "{text}");
789 }
790
791 #[test]
792 fn render_is_deterministic_for_a_fixed_clock() {
793 // The property every snapshot test rests on.
794 let mut n = node("tier:b", "b", Status::Ok);
795 n.fields = vec![Field::new(
796 "built",
797 Value::Instant {
798 value: now() - TimeDelta::minutes(3),
799 },
800 )];
801 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
802 model.select_tab(1);
803 assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20));
804 }
805
806 #[test]
807 fn an_unknown_value_kind_still_renders_as_text() {
808 // Version skew: a producer one release ahead must not blank the pane.
809 let field: Field =
810 serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap();
811 let mut n = node("tier:b", "b", Status::Ok);
812 n.fields = vec![field];
813 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
814 model.select_tab(1);
815 let text = joined(&draw(&model, now(), 80, 20));
816 assert!(text.contains("temp"), "{text}");
817 assert!(text.contains("41"), "{text}");
818 }
819
820 #[test]
821 fn a_narrow_terminal_does_not_panic() {
822 // Every widget here has to survive a width no layout was designed for.
823 let mut n = node("tier:b", "a rather long tier label", Status::Failed);
824 n.fields = vec![Field::new(
825 "path",
826 Value::Path {
827 value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(),
828 },
829 )];
830 n.conditions = vec![Condition {
831 condition_type: "node_health".into(),
832 status: Status::Failed,
833 since: None,
834 detail: Some("prod-1 unhealthy: connection refused after 30s".into()),
835 }];
836 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
837 model.select_tab(1);
838 for width in [8_u16, 12, 20, 40] {
839 for height in [4_u16, 8, 20] {
840 let _ = draw(&model, now(), width, height);
841 }
842 }
843 }
844
845 #[test]
846 fn a_multiline_detail_is_flattened_not_sprawled() {
847 assert_eq!(truncate("a\nb", 40), "a b");
848 assert!(truncate(&"x".repeat(100), 10).ends_with(''));
849 assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10);
850 }
851
852 fn action(label: &str, danger: bool) -> ops_status::Action {
853 ops_status::Action {
854 label: label.into(),
855 method: ops_status::Method::Post,
856 url: "/rollback/b".into(),
857 confirm: true,
858 danger,
859 body: None,
860 }
861 }
862
863 /// A source with one node declaring `keys`, actions allowed, on its tab.
864 fn actionable(keys: &[(&str, bool)]) -> Model {
865 let mut n = node("tier:b", "b (prod-1)", Status::Ok);
866 n.actions = keys.iter().map(|(k, _)| k.to_string()).collect();
867 let mut p = Payload::new("sando", now());
868 p.nodes = vec![n];
869 p.actions = keys
870 .iter()
871 .map(|(k, d)| (k.to_string(), action(k, *d)))
872 .collect();
873 let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
874 s.observe(p, now());
875 let mut m = Model::new(vec![s]);
876 m.select_tab(1);
877 m
878 }
879
880 #[test]
881 fn the_detail_hint_says_whether_actions_can_run() {
882 let mut m = actionable(&[("rollback-b", true)]);
883 let text = joined(&draw(&m, now(), 80, 20));
884 assert!(text.contains("rollback-b"), "{text}");
885 assert!(text.contains("enter to run"), "{text}");
886
887 m.sources[0].allow_actions = false;
888 let text = joined(&draw(&m, now(), 80, 20));
889 assert!(
890 text.contains("read-only"),
891 "a disabled source must say so:\n{text}"
892 );
893 }
894
895 #[test]
896 fn the_picker_lists_a_nodes_actions() {
897 let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]);
898 m.open_actions();
899 let text = joined(&draw(&m, now(), 80, 20));
900 assert!(text.contains("run action"), "{text}");
901 assert!(text.contains("[promote-b]"), "{text}");
902 assert!(text.contains("[rollback-b]"), "{text}");
903 assert!(text.contains("enter run"), "{text}");
904 }
905
906 #[test]
907 fn a_danger_prompt_shows_the_key_to_type() {
908 let mut m = actionable(&[("rollback-b", true)]);
909 m.open_actions();
910 m.prompt_enter(); // Pick -> Type (danger)
911 let text = joined(&draw(&m, now(), 80, 20));
912 assert!(
913 text.contains("DANGER"),
914 "a danger action must be loud:\n{text}"
915 );
916 assert!(
917 text.contains("type 'rollback-b'"),
918 "the exact key to type must be shown:\n{text}"
919 );
920 }
921
922 #[test]
923 fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() {
924 let mut m = actionable(&[("promote-b", false)]);
925 m.open_actions();
926 m.prompt_enter(); // Pick -> Confirm (confirm, not danger)
927 // A poll drops the action while the confirm box is up.
928 let mut p = Payload::new("sando", now());
929 p.nodes = vec![node("tier:b", "b", Status::Ok)];
930 m.sources[0].observe(p, now());
931 let text = joined(&draw(&m, now(), 80, 20));
932 assert!(text.contains("no longer offered"), "{text}");
933 }
934
935 #[test]
936 fn the_footer_shows_a_message_when_there_is_one() {
937 let mut model = Model::new(vec![source("sando", now(), vec![])]);
938 let text = joined(&draw(&model, now(), 80, 12));
939 assert!(text.contains("q quit"), "{text}");
940
941 model.message = Some("refreshing".into());
942 let text = joined(&draw(&model, now(), 80, 12));
943 assert!(text.contains("refreshing"), "{text}");
944 }
945 }
946