Skip to main content

max / makenotwork

52.4 KB · 1447 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, Status};
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, Paragraph, TableState, Tabs};
26
27 use crate::model::{LiveRow, Model, Prompt, SourceState, StoreRow, 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::Live => render_live(model, theme, now, frame, body),
41 Tab::Logs => render_logs(model, theme, frame, body),
42 Tab::Store => render_store(model, theme, now, frame, body),
43 }
44 // A prompt floats over whatever tab is showing: the state behind it keeps
45 // updating on every poll, which is the point of not blocking on the modal.
46 if model.prompt.is_some() {
47 render_prompt(model, theme, frame, body);
48 }
49 render_footer(model, theme, frame, footer);
50 }
51
52 /// Secondary text: labels, hints, the things you read past.
53 fn muted(theme: &Theme) -> Style {
54 Style::default().fg(theme.content_muted)
55 }
56
57 /// The selected row, on every list and table here.
58 ///
59 /// A raised surface rather than a reversed one. Reversing swaps a span's own
60 /// foreground into the background, so a selected `FAIL` row used to paint its
61 /// danger colour behind the text and lose the one signal the row carries;
62 /// giving selection a surface of its own leaves every status colour intact on
63 /// top of it.
64 fn selected(theme: &Theme) -> Style {
65 Style::default()
66 .bg(theme.surface_raised)
67 .fg(theme.content_primary)
68 }
69
70 /// A bordered container: the frame in the theme's border colour, the title in
71 /// secondary content so it reads as a label on the box rather than as content.
72 fn container(theme: &Theme, title: &'static str) -> Block<'static> {
73 Block::bordered()
74 .border_style(Style::default().fg(theme.line_border))
75 .title(Span::styled(
76 title,
77 Style::default().fg(theme.content_secondary),
78 ))
79 }
80
81 fn render_header(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
82 let worst = model.worst(now);
83 let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area);
84
85 // The worst-status mark is a filled chip: the worst status is the one thing on
86 // screen that has to be readable from across the room, so it takes the
87 // status colour as a background rather than as text.
88 let status = value::status_style(theme, worst);
89 frame.render_widget(
90 Paragraph::new(Span::styled(
91 format!(" {} ", value::status_mark(worst)),
92 Style::default()
93 .bg(status.fg.unwrap_or(theme.content_primary))
94 .fg(theme.surface_page)
95 .add_modifier(Modifier::BOLD),
96 )),
97 mark,
98 );
99 frame.render_widget(
100 Tabs::new(Tab::titles())
101 .select(model.tab_index())
102 .style(muted(theme))
103 .highlight_style(selected(theme).add_modifier(Modifier::BOLD))
104 .divider(" "),
105 tabs,
106 );
107 }
108
109 fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
110 let text = match &model.message {
111 // A fired action's outcome, which is transient and worth noticing.
112 Some(message) => Span::styled(
113 format!(" {message}"),
114 Style::default().fg(theme.status_warning),
115 ),
116 None => Span::styled(
117 " tab/shift-tab switch up/down move enter run action 1-3 jump q quit",
118 muted(theme),
119 ),
120 };
121 frame.render_widget(
122 Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)),
123 area,
124 );
125 }
126
127 // ---------------------------------------------------------------------------
128 // Live
129 // ---------------------------------------------------------------------------
130
131 /// The live tab's columns, left to right.
132 ///
133 /// The mark's name is empty because the name is what the header row draws, and
134 /// this column's header always was blank: the glyph says what it is.
135 ///
136 /// Nothing here is `Optional`. A live view with the status or the source name
137 /// dropped is not a narrower view, it is a different screen, and the age is
138 /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail
139 /// absorbs what is left, which is what the old `Min(10)` was saying.
140 const LIVE_COLUMNS: [Column<'static>; 4] = [
141 Column {
142 name: "",
143 width: Width::Fixed,
144 priority: Priority::Essential,
145 sortable: false,
146 sorted: None,
147 },
148 Column {
149 name: "source",
150 width: Width::Content,
151 priority: Priority::Essential,
152 sortable: false,
153 sorted: None,
154 },
155 Column {
156 name: "age",
157 width: Width::Content,
158 priority: Priority::Secondary,
159 sortable: false,
160 sorted: None,
161 },
162 Column {
163 name: "detail",
164 width: Width::Fill,
165 priority: Priority::Essential,
166 sortable: false,
167 sorted: None,
168 },
169 ];
170
171 /// The tracks the hand-written `Constraint`s carried, lifted rather than
172 /// re-chosen. The two `Width::Content` columns measure themselves from the
173 /// cells and use these only as a floor, so a run of short source names stops
174 /// spending fourteen columns to say `pom`.
175 const LIVE_SIZING: Sizing<'static> = Sizing {
176 lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)],
177 fallback: 8,
178 };
179
180 /// Every source at once, worst first, with each source's nodes under it.
181 ///
182 /// The nodes are indented into the same table rather than given a pane of their
183 /// own: there is one cursor, and what it is on is what the detail pane below
184 /// explains. That is the whole of what the per-source tabs used to do, minus
185 /// the visiting them one at a time.
186 fn render_live(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
187 // The table takes the majority and the detail pane what is left. A fixed
188 // height for the detail would eat the whole body on a short terminal, which
189 // is the one case where the list is the thing you need.
190 let [table_area, detail_area] =
191 Layout::vertical([Constraint::Percentage(60), Constraint::Min(3)]).areas(area);
192
193 let live = model.live_rows(now);
194 let rows: Vec<Vec<TableCell>> = live
195 .iter()
196 .map(|row| match row {
197 LiveRow::Source { index } => {
198 let source = &model.sources[*index];
199 let status = source.status(now);
200 let age = match source.age(now) {
201 Some(age) => value::duration(age.num_seconds()),
202 None => "-".into(),
203 };
204 vec![
205 // The mark styles its own span rather than the cell: a
206 // status colour is this app's, not a part the table module
207 // knows, and a span's style sits on top of the cell's.
208 TableCell::new(
209 "",
210 Span::styled(
211 value::status_mark(status),
212 value::status_style(theme, status),
213 ),
214 ),
215 TableCell::new(
216 "source",
217 Span::styled(
218 source.name.clone(),
219 Style::default()
220 .fg(theme.content_primary)
221 .add_modifier(Modifier::BOLD),
222 ),
223 ),
224 TableCell::new("age", Span::styled(age, muted(theme))),
225 TableCell::new("detail", source.summary(now)),
226 ]
227 }
228 LiveRow::Node { node, depth, .. } => vec![
229 TableCell::new(
230 "",
231 Span::styled(
232 value::status_mark(node.status),
233 value::status_style(theme, node.status),
234 ),
235 ),
236 TableCell::new("source", format!("{}{}", " ".repeat(*depth), node.label)),
237 // A node has no age of its own; the source line above it carries
238 // the one age there is, and repeating it would say that each node
239 // was measured separately.
240 TableCell::new("age", Span::styled(String::new(), muted(theme))),
241 TableCell::new("detail", Span::styled(node.kind.clone(), muted(theme))),
242 ],
243 })
244 .collect();
245
246 // The block first, because narrowing is measured against the width the
247 // table actually gets rather than the width of the area around it. Two
248 // columns of border is the difference between "detail fits" and "detail
249 // is cut", which is exactly the decision the cutoff is making.
250 let block = container(theme, " all sources ");
251 let inner = block.inner(table_area);
252 let table = table::table(
253 &LIVE_COLUMNS,
254 &rows,
255 &LIVE_SIZING,
256 &TableStyle::from_theme(theme),
257 inner.width,
258 )
259 .block(block);
260
261 // Selection through the widget's own highlight rather than a per-row style.
262 // `TableStyle::from_theme` carries it on the background alone, which is what
263 // leaves a FAIL row's danger colour on top of it -- the same reason the
264 // local `selected` helper gives a surface instead of reversing.
265 let mut state = TableState::default().with_selected(Some(model.selected));
266 frame.render_stateful_widget(table, table_area, &mut state);
267
268 let row = live.get(model.selected);
269 let source = row.map(|r| &model.sources[r.source_index()]);
270 render_detail(
271 source,
272 row.and_then(LiveRow::node),
273 theme,
274 now,
275 frame,
276 detail_area,
277 );
278 }
279
280 /// The selected node's fields and conditions.
281 ///
282 /// Conditions are the half that usually gets dropped, and the half that pays:
283 /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves
284 /// an SSH.
285 fn render_detail(
286 source: Option<&SourceState>,
287 node: Option<&Node>,
288 theme: &Theme,
289 now: DateTime<Utc>,
290 frame: &mut Frame,
291 area: Rect,
292 ) {
293 let width = area.width.saturating_sub(4) as usize;
294 let mut lines: Vec<Line> = Vec::new();
295
296 match node {
297 Some(node) => {
298 lines.push(Line::from(vec![
299 Span::styled(
300 value::status_mark(node.status),
301 value::status_style(theme, node.status),
302 ),
303 Span::raw(" "),
304 Span::styled(
305 node.label.clone(),
306 Style::default()
307 .fg(theme.content_primary)
308 .add_modifier(Modifier::BOLD),
309 ),
310 ]));
311 lines.extend(field_lines(theme, node, now, width));
312 lines.extend(condition_lines(theme, node, width));
313 if !node.actions.is_empty() {
314 // The hint tells the operator whether Enter does anything here,
315 // so a read-only source does not look broken when a keypress is
316 // ignored.
317 let hint = if source.is_some_and(|s| s.allow_actions) {
318 " (enter to run)"
319 } else {
320 " (read-only)"
321 };
322 lines.push(Line::from(vec![
323 Span::styled(
324 format!("actions: {}", node.actions.join(", ")),
325 muted(theme),
326 ),
327 Span::styled(hint, muted(theme)),
328 ]));
329 }
330 }
331 // A source line, or an empty list. Either way the source's own summary
332 // is the thing worth saying: it is why the source has no nodes to
333 // select, when it has none.
334 None => lines.push(Line::from(Span::styled(
335 source.map_or_else(|| "no sources".to_string(), |source| source.summary(now)),
336 muted(theme),
337 ))),
338 }
339
340 frame.render_widget(
341 Paragraph::new(lines).block(container(theme, " detail ")),
342 area,
343 );
344 }
345
346 // ---------------------------------------------------------------------------
347 // Logs
348 // ---------------------------------------------------------------------------
349
350 /// The logs tab's columns.
351 ///
352 /// The source is a column on every line rather than a heading over a section,
353 /// so a line read on its own still says who said it. The grouping is still
354 /// there: the rows arrive grouped by source and the column makes the boundaries
355 /// visible without costing an index that does not line up with the cursor.
356 const LOG_COLUMNS: [Column<'static>; 5] = [
357 Column {
358 name: "when",
359 width: Width::Content,
360 priority: Priority::Essential,
361 sortable: false,
362 sorted: None,
363 },
364 Column {
365 name: "source",
366 width: Width::Content,
367 priority: Priority::Essential,
368 sortable: false,
369 sorted: None,
370 },
371 Column {
372 name: "",
373 width: Width::Fixed,
374 priority: Priority::Secondary,
375 sortable: false,
376 sorted: None,
377 },
378 Column {
379 name: "event",
380 width: Width::Content,
381 priority: Priority::Essential,
382 sortable: false,
383 sorted: None,
384 },
385 Column {
386 name: "detail",
387 width: Width::Fill,
388 priority: Priority::Optional,
389 sortable: false,
390 sorted: None,
391 },
392 ];
393
394 const LOG_SIZING: Sizing<'static> = Sizing {
395 lengths: &[
396 ("when", 9),
397 ("source", 10),
398 ("", 4),
399 ("event", 20),
400 ("detail", 10),
401 ],
402 fallback: 8,
403 };
404
405 /// What every source has said lately, grouped by which one said it.
406 ///
407 /// The events are contract (`ops_status::Event`) and nothing here knows what any
408 /// of them mean, which is the same bargain the rest of the shell makes: a new
409 /// daemon that emits events gets this screen for free.
410 fn render_logs(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
411 let logs = model.log_rows();
412 if logs.is_empty() {
413 frame.render_widget(
414 Paragraph::new(Line::from(Span::styled(
415 "no source has reported an event",
416 muted(theme),
417 )))
418 .block(container(theme, " logs ")),
419 area,
420 );
421 return;
422 }
423
424 let rows: Vec<Vec<TableCell>> = logs
425 .iter()
426 .map(|row| {
427 let status = row.event.status;
428 vec![
429 TableCell::new(
430 "when",
431 Span::styled(row.event.at.format("%H:%M:%S").to_string(), muted(theme)),
432 ),
433 TableCell::new("source", row.source.to_string()),
434 TableCell::new(
435 "",
436 match status {
437 Some(status) => Span::styled(
438 value::status_mark(status),
439 value::status_style(theme, status),
440 ),
441 // An event with no status is a note, not a verdict.
442 // Blank rather than a guessed mark: inventing "ok" here
443 // is exactly the domain knowledge the shell refuses.
444 None => Span::raw(""),
445 },
446 ),
447 TableCell::new("event", row.event.label.clone()),
448 TableCell::new(
449 "detail",
450 Span::styled(row.event.detail.clone().unwrap_or_default(), muted(theme)),
451 ),
452 ]
453 })
454 .collect();
455
456 let block = container(theme, " logs ");
457 let inner = block.inner(area);
458 let table = table::table(
459 &LOG_COLUMNS,
460 &rows,
461 &LOG_SIZING,
462 &TableStyle::from_theme(theme),
463 inner.width,
464 )
465 .block(block);
466 let mut state = TableState::default().with_selected(Some(model.logs_scroll));
467 frame.render_stateful_widget(table, area, &mut state);
468 }
469
470 // ---------------------------------------------------------------------------
471 // Store
472 // ---------------------------------------------------------------------------
473
474 /// The store tab's columns.
475 ///
476 /// `series` is the operator's own label for the number, not the store's series
477 /// name: the store cannot say what a series means, so the config does, and it is
478 /// the config's word that goes on screen. `labels` is the producer's dimension
479 /// text, carried through verbatim rather than parsed into columns this crate
480 /// would have to invent.
481 const STORE_COLUMNS: [Column<'static>; 5] = [
482 Column {
483 name: "store",
484 width: Width::Content,
485 priority: Priority::Secondary,
486 sortable: false,
487 sorted: None,
488 },
489 Column {
490 name: "series",
491 width: Width::Content,
492 priority: Priority::Essential,
493 sortable: false,
494 sorted: None,
495 },
496 Column {
497 name: "value",
498 width: Width::Content,
499 priority: Priority::Essential,
500 sortable: false,
501 sorted: None,
502 },
503 Column {
504 name: "age",
505 width: Width::Content,
506 priority: Priority::Secondary,
507 sortable: false,
508 sorted: None,
509 },
510 Column {
511 name: "labels",
512 width: Width::Fill,
513 priority: Priority::Optional,
514 sortable: false,
515 sorted: None,
516 },
517 ];
518
519 const STORE_SIZING: Sizing<'static> = Sizing {
520 lengths: &[
521 ("store", 10),
522 ("series", 20),
523 ("value", 12),
524 ("age", 8),
525 ("labels", 10),
526 ],
527 fallback: 8,
528 };
529
530 /// The series a configured store has recorded.
531 ///
532 /// The one screen here that renders something other than the `ops-status`
533 /// contract. What keeps that break narrow is that every row below comes from a
534 /// series the config named: nothing is discovered, nothing is inferred, and a
535 /// series nobody named is not on this screen.
536 fn render_store(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
537 let store_rows = model.store_rows();
538 if store_rows.is_empty() {
539 frame.render_widget(
540 Paragraph::new(Line::from(Span::styled(
541 "no [[store]] configured",
542 muted(theme),
543 )))
544 .block(container(theme, " store ")),
545 area,
546 );
547 return;
548 }
549
550 let rows: Vec<Vec<TableCell>> = store_rows
551 .iter()
552 .map(|row| match row {
553 StoreRow::Unavailable { store, reason } => vec![
554 TableCell::new("store", (*store).to_string()),
555 TableCell::new(
556 "series",
557 Span::styled(
558 "unavailable",
559 Style::default()
560 .fg(theme.status_danger)
561 .add_modifier(Modifier::BOLD),
562 ),
563 ),
564 TableCell::new("value", Span::styled(String::new(), muted(theme))),
565 TableCell::new("age", Span::styled(String::new(), muted(theme))),
566 TableCell::new(
567 "labels",
568 Span::styled(
569 (*reason).to_string(),
570 Style::default().fg(theme.status_danger),
571 ),
572 ),
573 ],
574 StoreRow::Missing { store, spec } => vec![
575 TableCell::new("store", (*store).to_string()),
576 TableCell::new("series", spec.label.clone()),
577 // A named series with nothing behind it is shown, not skipped:
578 // a soak target that has never reported is the thing worth
579 // noticing, and omitting it would be indistinguishable from
580 // never having configured it.
581 TableCell::new(
582 "value",
583 Span::styled(
584 "no observations",
585 value::status_style(theme, Status::Unknown),
586 ),
587 ),
588 TableCell::new("age", Span::styled(String::new(), muted(theme))),
589 TableCell::new("labels", Span::styled(String::new(), muted(theme))),
590 ],
591 StoreRow::Value {
592 store,
593 spec,
594 reading,
595 } => vec![
596 TableCell::new("store", (*store).to_string()),
597 TableCell::new("series", spec.label.clone()),
598 TableCell::new(
599 "value",
600 // `quantity` is what the `ops-status` contract's own
601 // magnitude values render through, so a number on this tab
602 // reads the same as one on the live tab. The unit is the
603 // config's word, since the store has none.
604 Span::styled(
605 value::quantity(reading.value, spec.unit.as_deref()),
606 Style::default().fg(theme.content_primary),
607 ),
608 ),
609 TableCell::new(
610 "age",
611 Span::styled(
612 value::duration((now - reading.at).num_seconds()),
613 muted(theme),
614 ),
615 ),
616 TableCell::new("labels", Span::styled(reading.labels.clone(), muted(theme))),
617 ],
618 })
619 .collect();
620
621 let block = container(theme, " store ");
622 let inner = block.inner(area);
623 let table = table::table(
624 &STORE_COLUMNS,
625 &rows,
626 &STORE_SIZING,
627 &TableStyle::from_theme(theme),
628 inner.width,
629 )
630 .block(block);
631 let mut state = TableState::default().with_selected(Some(model.store_scroll));
632 frame.render_stateful_widget(table, area, &mut state);
633 }
634
635 fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
636 let label_width = node
637 .fields
638 .iter()
639 .map(|f| f.label.chars().count())
640 .max()
641 .unwrap_or(0);
642
643 node.fields
644 .iter()
645 .map(|field| {
646 let budget = width.saturating_sub(label_width + 2);
647 let rendered = value::render(&field.value, now, budget);
648 let style = match &field.value {
649 ops_status::Value::Progress { value, max, .. } => {
650 value::progress_style(theme, *value, *max)
651 }
652 other => value::style(theme, other),
653 };
654 Line::from(vec![
655 Span::styled(format!("{:<label_width$} ", field.label), muted(theme)),
656 Span::styled(rendered, style),
657 ])
658 })
659 .collect()
660 }
661
662 fn condition_lines(theme: &Theme, node: &Node, width: usize) -> Vec<Line<'static>> {
663 node.conditions
664 .iter()
665 .map(|condition| {
666 let mut spans = vec![
667 Span::styled(
668 format!("{:<5}", value::status_mark(condition.status)),
669 value::status_style(theme, condition.status),
670 ),
671 Span::styled(
672 condition.condition_type.clone(),
673 Style::default().fg(theme.content_primary),
674 ),
675 ];
676 if let Some(detail) = &condition.detail {
677 let budget = width.saturating_sub(condition.condition_type.chars().count() + 8);
678 spans.push(Span::styled(
679 format!(" {}", truncate(detail, budget.max(8))),
680 muted(theme),
681 ));
682 }
683 Line::from(spans)
684 })
685 .collect()
686 }
687
688 // ---------------------------------------------------------------------------
689 // Action prompts
690 // ---------------------------------------------------------------------------
691
692 /// The modal path to firing an action: pick, then clear its guard.
693 ///
694 /// A `danger` action shows a red header and asks the operator to type its key;
695 /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The
696 /// resolved host is left off — the tab already names the source — so the line
697 /// stays short and shows the method and path the request will use.
698 fn render_prompt(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
699 let Some(prompt) = &model.prompt else { return };
700 let source = model.sources.get(prompt_source(prompt));
701 let plain = Style::default().fg(theme.content_primary);
702 let danger_style = Style::default().fg(theme.status_danger);
703
704 let (title, title_style, lines) = match prompt {
705 Prompt::Pick { keys, selected, .. } => {
706 let mut lines = Vec::new();
707 for (i, key) in keys.iter().enumerate() {
708 let action = source.and_then(|s| s.action(key));
709 let label = action.map_or(key.as_str(), |a| a.label.as_str());
710 let danger = action.is_some_and(|a| a.danger);
711 let marker = if i == *selected { "> " } else { " " };
712 // Danger is the foreground and selection the surface, so a
713 // selected danger action is still red rather than swapped into
714 // its own background.
715 let mut style = if danger { danger_style } else { plain };
716 if i == *selected {
717 style = style.bg(theme.surface_raised);
718 }
719 lines.push(Line::from(Span::styled(
720 format!("{marker}{}. {label} [{key}]", i + 1),
721 style,
722 )));
723 }
724 lines.push(Line::from(""));
725 lines.push(Line::from(Span::styled(
726 "enter run esc cancel",
727 muted(theme),
728 )));
729 (
730 " run action ",
731 Style::default().fg(theme.line_border),
732 lines,
733 )
734 }
735 Prompt::Confirm { key, .. } => {
736 let action = source.and_then(|s| s.action(key));
737 let lines = vec![
738 action_summary_line(theme, action, key),
739 Line::from(""),
740 Line::from(Span::styled(
741 "press y to confirm esc cancel",
742 muted(theme),
743 )),
744 ];
745 (" confirm ", Style::default().fg(theme.line_border), lines)
746 }
747 Prompt::Type { key, typed, .. } => {
748 let lines = vec![
749 Line::from(Span::styled(
750 "DANGER",
751 danger_style.add_modifier(Modifier::BOLD),
752 )),
753 action_summary_line(theme, source.and_then(|s| s.action(key)), key),
754 Line::from(""),
755 Line::from(Span::styled(
756 format!("type '{key}' to confirm:"),
757 muted(theme),
758 )),
759 Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)),
760 Line::from(""),
761 Line::from(Span::styled("esc cancel", muted(theme))),
762 ];
763 (" DANGER ", danger_style.add_modifier(Modifier::BOLD), lines)
764 }
765 };
766
767 let width = lines
768 .iter()
769 .map(Line::width)
770 .chain(std::iter::once(title.len()))
771 .max()
772 .unwrap_or(0) as u16
773 + 4;
774 let height = lines.len() as u16 + 2;
775 let popup = centered(area, width, height);
776
777 // Clear what is under the popup so the tab behind does not show through,
778 // then paint the overlay surface: makeover's `surface.overlay` is the
779 // intent for exactly this, a surface that floats above the page.
780 frame.render_widget(Clear, popup);
781 frame.render_widget(
782 Paragraph::new(lines)
783 .style(Style::default().bg(theme.surface_overlay))
784 .block(
785 Block::bordered()
786 .title(Span::styled(title, title_style))
787 .border_style(title_style),
788 ),
789 popup,
790 );
791 }
792
793 /// One line naming what an action will do: `Roll back POST /rollback/b`.
794 fn action_summary_line(
795 theme: &Theme,
796 action: Option<&ops_status::Action>,
797 key: &str,
798 ) -> Line<'static> {
799 match action {
800 Some(action) => Line::from(vec![
801 Span::styled(
802 action.label.clone(),
803 Style::default()
804 .fg(theme.content_primary)
805 .add_modifier(Modifier::BOLD),
806 ),
807 Span::styled(
808 format!(" {} {}", method_str(action.method), action.url),
809 Style::default().fg(theme.content_secondary),
810 ),
811 ]),
812 // The action was retracted by a poll since the prompt opened; say so
813 // rather than render a blank confirmation.
814 None => Line::from(Span::styled(
815 format!("{key}: no longer offered"),
816 Style::default().fg(theme.status_warning),
817 )),
818 }
819 }
820
821 fn method_str(method: Method) -> &'static str {
822 match method {
823 Method::Get => "GET",
824 Method::Post => "POST",
825 Method::Put => "PUT",
826 Method::Delete => "DELETE",
827 }
828 }
829
830 fn prompt_source(prompt: &Prompt) -> usize {
831 match prompt {
832 Prompt::Pick { source, .. }
833 | Prompt::Confirm { source, .. }
834 | Prompt::Type { source, .. } => *source,
835 }
836 }
837
838 /// A rectangle of the given size centered in `area`, clamped so it always fits.
839 fn centered(area: Rect, width: u16, height: u16) -> Rect {
840 let width = width.min(area.width);
841 let height = height.min(area.height);
842 Rect {
843 x: area.x + (area.width - width) / 2,
844 y: area.y + (area.height - height) / 2,
845 width,
846 height,
847 }
848 }
849
850 /// Clip to a character budget, marking that something was cut.
851 fn truncate(text: &str, max: usize) -> String {
852 // A detail string is producer-supplied and may carry newlines; the pane is
853 // line-oriented, so flatten first.
854 let flat: String = text.replace('\n', " ");
855 if flat.chars().count() <= max {
856 return flat;
857 }
858 let kept: String = flat.chars().take(max.saturating_sub(1)).collect();
859 format!("{kept}")
860 }
861
862 #[cfg(test)]
863 mod tests {
864 use super::*;
865 use crate::model::SourceState;
866 use chrono::TimeDelta;
867 use ops_status::{Condition, Field, Payload, Status, Value};
868 use ratatui::Terminal;
869 use ratatui::backend::TestBackend;
870
871 fn now() -> DateTime<Utc> {
872 "2026-07-21T18:00:00Z".parse().unwrap()
873 }
874
875 /// Render a model into a fixed-size buffer and return it as text lines.
876 ///
877 /// This is the whole payoff of keeping render pure: the entire surface is
878 /// verifiable with no daemon running and no terminal attached.
879 fn draw(model: &Model, now: DateTime<Utc>, width: u16, height: u16) -> Vec<String> {
880 let theme = crate::theme::tests::fixed();
881 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
882 terminal
883 .draw(|frame| render(model, &theme, now, frame))
884 .unwrap();
885 let buffer = terminal.backend().buffer().clone();
886 (0..buffer.area.height)
887 .map(|y| {
888 (0..buffer.area.width)
889 .map(|x| buffer[(x, y)].symbol().to_string())
890 .collect::<String>()
891 .trim_end()
892 .to_string()
893 })
894 .collect()
895 }
896
897 fn node(id: &str, label: &str, status: Status) -> Node {
898 Node {
899 id: id.into(),
900 kind: "tier".into(),
901 label: label.into(),
902 status,
903 fields: Vec::new(),
904 conditions: Vec::new(),
905 children: Vec::new(),
906 actions: Vec::new(),
907 }
908 }
909
910 fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
911 let mut s = SourceState::new(name, TimeDelta::seconds(60));
912 let mut p = Payload::new(name, at);
913 p.nodes = nodes;
914 s.observe(p, at);
915 s
916 }
917
918 fn joined(lines: &[String]) -> String {
919 lines.join("\n")
920 }
921
922 #[test]
923 fn the_live_tab_leads_with_the_worst_source() {
924 let model = Model::new(vec![
925 source("sando", now(), vec![node("a", "tier a", Status::Ok)]),
926 source("bento", now(), vec![node("b", "goingson", Status::Failed)]),
927 ]);
928 let lines = draw(&model, now(), 80, 12);
929 let text = joined(&lines);
930
931 assert!(
932 text.contains("live"),
933 "the tab bar names the fixed tabs:\n{text}"
934 );
935 assert!(text.contains("logs"), "{text}");
936 assert!(text.contains("store"), "{text}");
937 // Skip the tab bar, which names every tab regardless of order.
938 let body = &lines[1..];
939 let bento = body.iter().position(|l| l.contains("bento")).unwrap();
940 let sando = body.iter().position(|l| l.contains("sando")).unwrap();
941 assert!(bento < sando, "the failing source must be on top:\n{text}");
942 assert!(text.contains("FAIL"), "{text}");
943 }
944
945 #[test]
946 fn no_source_gets_a_tab_of_its_own() {
947 // The restructure, asserted directly: two sources, three tabs, and the
948 // tab bar names none of them.
949 let model = Model::new(vec![
950 source("sando", now(), vec![node("a", "tier a", Status::Ok)]),
951 source("bento", now(), vec![node("b", "goingson", Status::Ok)]),
952 ]);
953 let bar = draw(&model, now(), 80, 12)[0].clone();
954 assert!(
955 bar.contains("live") && bar.contains("logs") && bar.contains("store"),
956 "{bar}"
957 );
958 assert!(
959 !bar.contains("sando"),
960 "a source must not own a tab:\n{bar}"
961 );
962 assert!(!bar.contains("bento"), "{bar}");
963 }
964
965 #[test]
966 fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() {
967 let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
968 let text = joined(&draw(&model, now(), 80, 12));
969 assert!(
970 text.contains("????"),
971 "an unreachable source must be loud:\n{text}"
972 );
973 assert!(text.contains("waiting for first poll"), "{text}");
974 }
975
976 #[test]
977 fn a_stale_source_shows_its_age_on_the_live_tab() {
978 let model = Model::new(vec![source(
979 "pom",
980 now() - TimeDelta::hours(4),
981 vec![node("backup", "backup", Status::Ok)],
982 )]);
983 let text = joined(&draw(&model, now(), 80, 12));
984 assert!(text.contains("4h"), "the age must be visible:\n{text}");
985 assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}");
986 }
987
988 #[test]
989 fn a_narrow_live_tab_drops_the_age_before_it_drops_the_detail() {
990 // What the hand-written `Constraint`s could not do: at 80 columns every
991 // column is drawn, and at a width where they no longer all fit the
992 // priority decides which one goes rather than the order they were
993 // written in. Age is the only Secondary column, so it is the only one
994 // that can go.
995 let model = Model::new(vec![source(
996 "pom",
997 now() - TimeDelta::hours(4),
998 vec![node("backup", "backup", Status::Ok)],
999 )]);
1000
1001 let wide = joined(&draw(&model, now(), 80, 12));
1002 assert!(wide.contains("age"), "the age column at 80 wide:\n{wide}");
1003
1004 let narrow = joined(&draw(&model, now(), 24, 12));
1005 assert!(!narrow.contains("age"), "age must drop first:\n{narrow}");
1006 assert!(narrow.contains("pom"), "the source stays:\n{narrow}");
1007 assert!(narrow.contains("detail"), "the detail stays:\n{narrow}");
1008 }
1009
1010 #[test]
1011 fn the_live_tab_nests_nodes_under_their_source_and_children_under_those() {
1012 let mut parent = node("tier:b", "b (prod-1)", Status::Ok);
1013 parent.children = vec!["node:prod-1".into()];
1014 let child = node("node:prod-1", "prod-1", Status::Ok);
1015
1016 let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]);
1017 model.selected = 1;
1018 let lines = draw(&model, now(), 80, 20);
1019 let text = joined(&lines);
1020
1021 let source_row = lines
1022 .iter()
1023 .position(|l| l.contains("sando") && !l.contains("live"))
1024 .unwrap();
1025 let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap();
1026 let child_row = lines
1027 .iter()
1028 .rposition(|l| l.contains("prod-1") && !l.contains("b (prod-1)"))
1029 .unwrap();
1030 assert!(
1031 source_row < parent_row,
1032 "the source leads its nodes:\n{text}"
1033 );
1034 assert!(parent_row < child_row, "{text}");
1035
1036 // Each level is indented relative to the one above it.
1037 let source_col = lines[source_row].find("sando").unwrap();
1038 let parent_col = lines[parent_row].find("b (prod-1)").unwrap();
1039 let child_col = lines[child_row].find("prod-1").unwrap();
1040 assert!(
1041 source_col < parent_col,
1042 "a node is indented under its source:\n{text}"
1043 );
1044 assert!(parent_col < child_col, "child must be indented:\n{text}");
1045 }
1046
1047 #[test]
1048 fn the_detail_pane_shows_conditions_with_their_why() {
1049 let mut n = node("tier:b", "b", Status::Ok);
1050 n.conditions = vec![Condition {
1051 condition_type: "burn_in".into(),
1052 status: Status::Pending,
1053 since: None,
1054 detail: Some("17 hours remaining of 48".into()),
1055 }];
1056 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1057 model.selected = 1;
1058 let text = joined(&draw(&model, now(), 80, 20));
1059
1060 assert!(text.contains("burn_in"), "{text}");
1061 assert!(
1062 text.contains("17 hours remaining"),
1063 "a condition without its why is useless:\n{text}"
1064 );
1065 }
1066
1067 #[test]
1068 fn a_progress_field_renders_as_a_bar() {
1069 let mut n = node("tier:b", "b", Status::Ok);
1070 n.fields = vec![Field::new(
1071 "burn-in",
1072 Value::Progress {
1073 value: 31.0,
1074 max: 48.0,
1075 unit: Some("hour".into()),
1076 },
1077 )];
1078 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1079 model.selected = 1;
1080 let text = joined(&draw(&model, now(), 80, 20));
1081
1082 assert!(text.contains("31/48 hour"), "{text}");
1083 assert!(
1084 text.contains('#'),
1085 "a progress value must draw a bar:\n{text}"
1086 );
1087 }
1088
1089 #[test]
1090 fn an_instant_renders_relative_to_the_passed_in_clock() {
1091 let mut n = node("tier:b", "b", Status::Ok);
1092 n.fields = vec![Field::new(
1093 "built",
1094 Value::Instant {
1095 value: now() - TimeDelta::minutes(3),
1096 },
1097 )];
1098 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1099 model.selected = 1;
1100 let text = joined(&draw(&model, now(), 80, 20));
1101 assert!(text.contains("3m 0s ago"), "{text}");
1102 }
1103
1104 #[test]
1105 fn render_is_deterministic_for_a_fixed_clock() {
1106 // The property every snapshot test rests on.
1107 let mut n = node("tier:b", "b", Status::Ok);
1108 n.fields = vec![Field::new(
1109 "built",
1110 Value::Instant {
1111 value: now() - TimeDelta::minutes(3),
1112 },
1113 )];
1114 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1115 model.selected = 1;
1116 assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20));
1117 }
1118
1119 #[test]
1120 fn an_unknown_value_kind_still_renders_as_text() {
1121 // Version skew: a producer one release ahead must not blank the pane.
1122 let field: Field =
1123 serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap();
1124 let mut n = node("tier:b", "b", Status::Ok);
1125 n.fields = vec![field];
1126 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1127 model.selected = 1;
1128 let text = joined(&draw(&model, now(), 80, 20));
1129 assert!(text.contains("temp"), "{text}");
1130 assert!(text.contains("41"), "{text}");
1131 }
1132
1133 #[test]
1134 fn a_narrow_terminal_does_not_panic() {
1135 // Every widget here has to survive a width no layout was designed for.
1136 let mut n = node("tier:b", "a rather long tier label", Status::Failed);
1137 n.fields = vec![Field::new(
1138 "path",
1139 Value::Path {
1140 value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(),
1141 },
1142 )];
1143 n.conditions = vec![Condition {
1144 condition_type: "node_health".into(),
1145 status: Status::Failed,
1146 since: None,
1147 detail: Some("prod-1 unhealthy: connection refused after 30s".into()),
1148 }];
1149 let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1150 model.selected = 1;
1151 for width in [8_u16, 12, 20, 40] {
1152 for height in [4_u16, 8, 20] {
1153 let _ = draw(&model, now(), width, height);
1154 }
1155 }
1156 }
1157
1158 #[test]
1159 fn a_multiline_detail_is_flattened_not_sprawled() {
1160 assert_eq!(truncate("a\nb", 40), "a b");
1161 assert!(truncate(&"x".repeat(100), 10).ends_with(''));
1162 assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10);
1163 }
1164
1165 fn action(label: &str, danger: bool) -> ops_status::Action {
1166 ops_status::Action {
1167 label: label.into(),
1168 method: ops_status::Method::Post,
1169 url: "/rollback/b".into(),
1170 confirm: true,
1171 danger,
1172 body: None,
1173 }
1174 }
1175
1176 /// A source with one node declaring `keys`, actions allowed, on its tab.
1177 fn actionable(keys: &[(&str, bool)]) -> Model {
1178 let mut n = node("tier:b", "b (prod-1)", Status::Ok);
1179 n.actions = keys.iter().map(|(k, _)| k.to_string()).collect();
1180 let mut p = Payload::new("sando", now());
1181 p.nodes = vec![n];
1182 p.actions = keys
1183 .iter()
1184 .map(|(k, d)| (k.to_string(), action(k, *d)))
1185 .collect();
1186 let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
1187 s.observe(p, now());
1188 let mut m = Model::new(vec![s]);
1189 // Row 0 is the source line, row 1 its only node.
1190 m.selected = 1;
1191 m
1192 }
1193
1194 #[test]
1195 fn the_detail_hint_says_whether_actions_can_run() {
1196 let mut m = actionable(&[("rollback-b", true)]);
1197 let text = joined(&draw(&m, now(), 80, 20));
1198 assert!(text.contains("rollback-b"), "{text}");
1199 assert!(text.contains("enter to run"), "{text}");
1200
1201 m.sources[0].allow_actions = false;
1202 let text = joined(&draw(&m, now(), 80, 20));
1203 assert!(
1204 text.contains("read-only"),
1205 "a disabled source must say so:\n{text}"
1206 );
1207 }
1208
1209 #[test]
1210 fn the_picker_lists_a_nodes_actions() {
1211 let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]);
1212 m.open_actions(now());
1213 let text = joined(&draw(&m, now(), 80, 20));
1214 assert!(text.contains("run action"), "{text}");
1215 assert!(text.contains("[promote-b]"), "{text}");
1216 assert!(text.contains("[rollback-b]"), "{text}");
1217 assert!(text.contains("enter run"), "{text}");
1218 }
1219
1220 #[test]
1221 fn a_danger_prompt_shows_the_key_to_type() {
1222 let mut m = actionable(&[("rollback-b", true)]);
1223 m.open_actions(now());
1224 m.prompt_enter(); // Pick -> Type (danger)
1225 let text = joined(&draw(&m, now(), 80, 20));
1226 assert!(
1227 text.contains("DANGER"),
1228 "a danger action must be loud:\n{text}"
1229 );
1230 assert!(
1231 text.contains("type 'rollback-b'"),
1232 "the exact key to type must be shown:\n{text}"
1233 );
1234 }
1235
1236 #[test]
1237 fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() {
1238 let mut m = actionable(&[("promote-b", false)]);
1239 m.open_actions(now());
1240 m.prompt_enter(); // Pick -> Confirm (confirm, not danger)
1241 // A poll drops the action while the confirm box is up.
1242 let mut p = Payload::new("sando", now());
1243 p.nodes = vec![node("tier:b", "b", Status::Ok)];
1244 m.sources[0].observe(p, now());
1245 let text = joined(&draw(&m, now(), 80, 20));
1246 assert!(text.contains("no longer offered"), "{text}");
1247 }
1248
1249 fn with_events(name: &str, events: Vec<ops_status::Event>) -> SourceState {
1250 let mut s = SourceState::new(name, TimeDelta::seconds(60));
1251 let mut p = Payload::new(name, now());
1252 p.events = events;
1253 s.observe(p, now());
1254 s
1255 }
1256
1257 fn ev(minutes_ago: i64, label: &str, status: Option<Status>) -> ops_status::Event {
1258 ops_status::Event {
1259 at: now() - TimeDelta::minutes(minutes_ago),
1260 label: label.into(),
1261 status,
1262 detail: None,
1263 node_id: None,
1264 }
1265 }
1266
1267 #[test]
1268 fn the_logs_tab_shows_every_sources_events_with_who_said_it() {
1269 let mut model = Model::new(vec![
1270 with_events("zebra", vec![ev(5, "sweep finished", Some(Status::Ok))]),
1271 with_events(
1272 "alpha",
1273 vec![ev(1, "promote refused", Some(Status::Failed))],
1274 ),
1275 ]);
1276 model.tab = crate::model::Tab::Logs;
1277 let lines = draw(&model, now(), 80, 14);
1278 let text = joined(&lines);
1279
1280 assert!(text.contains("promote refused"), "{text}");
1281 assert!(text.contains("sweep finished"), "{text}");
1282 // Every line says who said it, so a line read alone is still readable.
1283 assert!(text.contains("alpha"), "{text}");
1284 assert!(text.contains("zebra"), "{text}");
1285 // Grouped by source, in name order.
1286 let alpha = lines.iter().position(|l| l.contains("alpha")).unwrap();
1287 let zebra = lines.iter().position(|l| l.contains("zebra")).unwrap();
1288 assert!(alpha < zebra, "sources group in name order:\n{text}");
1289 // An event's own status colours it through the same marks as a node's.
1290 assert!(text.contains("FAIL"), "{text}");
1291 }
1292
1293 #[test]
1294 fn a_logs_tab_with_nothing_in_it_says_so_rather_than_showing_an_empty_box() {
1295 let mut model = Model::new(vec![source("sando", now(), vec![])]);
1296 model.tab = crate::model::Tab::Logs;
1297 let text = joined(&draw(&model, now(), 80, 14));
1298 assert!(text.contains("no source has reported an event"), "{text}");
1299 }
1300
1301 #[test]
1302 fn an_event_with_no_status_is_a_note_and_gets_no_mark() {
1303 let mut model = Model::new(vec![with_events(
1304 "sando",
1305 vec![ev(1, "config reloaded", None)],
1306 )]);
1307 model.tab = crate::model::Tab::Logs;
1308 let lines = draw(&model, now(), 80, 14);
1309 let text = joined(&lines);
1310 let row = lines
1311 .iter()
1312 .find(|l| l.contains("config reloaded"))
1313 .unwrap_or_else(|| panic!("{text}"));
1314 // Only the event's own row: the header chip carries the worst status
1315 // across every source, which is a different claim.
1316 for mark in ["ok", "FAIL", "degr", "????"] {
1317 assert!(
1318 !row.contains(mark),
1319 "a note must not be given a verdict ({mark}):\n{text}"
1320 );
1321 }
1322 }
1323
1324 fn stored(
1325 series: &[(&str, &str, Option<&str>)],
1326 readings: Vec<crate::store::Reading>,
1327 ) -> Model {
1328 let mut store = crate::model::StoreState::new(
1329 "witchbroom",
1330 series
1331 .iter()
1332 .map(|(s, label, unit)| crate::config::Series {
1333 name: (*s).to_string(),
1334 label: (*label).to_string(),
1335 unit: unit.map(ToString::to_string),
1336 })
1337 .collect(),
1338 );
1339 store.observe(readings, now());
1340 let mut model = Model::new(vec![]).with_stores(vec![store]);
1341 model.tab = crate::model::Tab::Store;
1342 model
1343 }
1344
1345 fn stored_at(
1346 series: &str,
1347 labels: &str,
1348 value: f64,
1349 at: DateTime<Utc>,
1350 ) -> crate::store::Reading {
1351 crate::store::Reading {
1352 series: series.into(),
1353 labels: labels.into(),
1354 value,
1355 at,
1356 }
1357 }
1358
1359 #[test]
1360 fn the_store_tab_shows_a_configured_series_with_its_label_and_unit() {
1361 let model = stored(
1362 &[("soak.coverage_edges", "Coverage reached", Some("edges"))],
1363 vec![stored_at(
1364 "soak.coverage_edges",
1365 r#"{"repo":"mnw-server"}"#,
1366 41_200.0,
1367 now() - TimeDelta::hours(2),
1368 )],
1369 );
1370 let text = joined(&draw(&model, now(), 100, 14));
1371
1372 // The config's label, not the store's series name: the store cannot say
1373 // what a number means, so what is on screen is what the operator said.
1374 assert!(text.contains("Coverage reached"), "{text}");
1375 assert!(!text.contains("soak.coverage_edges"), "{text}");
1376 assert!(
1377 text.contains("edges"),
1378 "the unit comes from config:\n{text}"
1379 );
1380 assert!(text.contains("41.2k"), "{text}");
1381 assert!(text.contains("2h"), "how old the number is:\n{text}");
1382 // The producer's dimensions, verbatim rather than parsed into columns.
1383 assert!(text.contains("mnw-server"), "{text}");
1384 }
1385
1386 #[test]
1387 fn a_configured_series_with_nothing_behind_it_is_shown_not_skipped() {
1388 // A soak target that has never reported is the thing worth noticing.
1389 let model = stored(&[("soak.coverage_edges", "Coverage reached", None)], vec![]);
1390 let text = joined(&draw(&model, now(), 100, 14));
1391 assert!(text.contains("Coverage reached"), "{text}");
1392 assert!(text.contains("no observations"), "{text}");
1393 }
1394
1395 #[test]
1396 fn an_unreadable_store_is_visibly_unavailable_rather_than_an_empty_tab() {
1397 let mut model = stored(
1398 &[("s", "Something", None)],
1399 vec![stored_at("s", "{}", 7.0, now())],
1400 );
1401 model.stores[0].observe_error("unable to open database file");
1402 let lines = draw(&model, now(), 100, 14);
1403 let text = joined(&lines);
1404
1405 assert!(text.contains("unavailable"), "{text}");
1406 assert!(text.contains("unable to open database file"), "{text}");
1407 // Above the stale numbers, so they are not read as current.
1408 let bad = lines
1409 .iter()
1410 .position(|l| l.contains("unavailable"))
1411 .unwrap();
1412 let old = lines.iter().position(|l| l.contains("Something")).unwrap();
1413 assert!(bad < old, "{text}");
1414 }
1415
1416 #[test]
1417 fn a_store_tab_with_no_store_configured_says_so() {
1418 let mut model = Model::new(vec![source("sando", now(), vec![])]);
1419 model.tab = crate::model::Tab::Store;
1420 let text = joined(&draw(&model, now(), 80, 14));
1421 assert!(text.contains("no [[store]] configured"), "{text}");
1422 }
1423
1424 #[test]
1425 fn the_detail_pane_on_a_source_line_shows_that_sources_summary() {
1426 // The cursor starts on a source line, which has no node to explain.
1427 let model = Model::new(vec![source(
1428 "sando",
1429 now(),
1430 vec![node("a", "tier a", Status::Ok)],
1431 )]);
1432 let text = joined(&draw(&model, now(), 80, 20));
1433 assert!(text.contains("1 node ok"), "{text}");
1434 }
1435
1436 #[test]
1437 fn the_footer_shows_a_message_when_there_is_one() {
1438 let mut model = Model::new(vec![source("sando", now(), vec![])]);
1439 let text = joined(&draw(&model, now(), 80, 12));
1440 assert!(text.contains("q quit"), "{text}");
1441
1442 model.message = Some("refreshing".into());
1443 let text = joined(&draw(&model, now(), 80, 12));
1444 assert!(text.contains("refreshing"), "{text}");
1445 }
1446 }
1447