Skip to main content

faucet_cli/tui/
view.rs

1//! Rendering for the live TUI. The ratatui draw call is thin; everything
2//! that decides *what* to show (formatting, column selection under narrow
3//! widths, status labels) is a pure function with unit tests.
4
5use super::metrics::{RowStats, TuiModel};
6use ratatui::Frame;
7use ratatui::layout::{Constraint, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
11
12/// A column of the invocation table. Order = display order; `min_width` is
13/// the terminal width at which the column is still shown (columns with the
14/// highest thresholds drop first on narrow terminals).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Column {
17    Row,
18    Route,
19    In,
20    Out,
21    Rate,
22    Errors,
23    Dlq,
24    Bookmark,
25    Status,
26}
27
28impl Column {
29    fn header(self) -> &'static str {
30        match self {
31            Column::Row => "row",
32            Column::Route => "source → sink",
33            Column::In => "in",
34            Column::Out => "out",
35            Column::Rate => "rec/s",
36            Column::Errors => "errors",
37            Column::Dlq => "dlq",
38            Column::Bookmark => "bookmark",
39            Column::Status => "status",
40        }
41    }
42
43    fn constraint(self) -> Constraint {
44        match self {
45            Column::Row => Constraint::Min(8),
46            Column::Route => Constraint::Min(18),
47            Column::In => Constraint::Length(10),
48            Column::Out => Constraint::Length(10),
49            Column::Rate => Constraint::Length(9),
50            Column::Errors => Constraint::Length(7),
51            Column::Dlq => Constraint::Length(6),
52            Column::Bookmark => Constraint::Length(9),
53            Column::Status => Constraint::Length(8),
54        }
55    }
56}
57
58/// Which columns fit a terminal of `width` cells. Drops the least essential
59/// columns first; `row`, `out`, and `status` always survive.
60pub fn visible_columns(width: u16) -> Vec<Column> {
61    let mut cols = vec![Column::Row, Column::Out, Column::Status];
62    if width >= 44 {
63        cols.insert(2, Column::Rate);
64    }
65    if width >= 58 {
66        cols.insert(1, Column::Route);
67    }
68    if width >= 70 {
69        cols.insert(cols.len() - 2, Column::Errors);
70    }
71    if width >= 82 {
72        let at = cols.iter().position(|c| *c == Column::Out).unwrap();
73        cols.insert(at, Column::In);
74    }
75    if width >= 92 {
76        let at = cols.iter().position(|c| *c == Column::Status).unwrap();
77        cols.insert(at, Column::Dlq);
78    }
79    if width >= 104 {
80        let at = cols.iter().position(|c| *c == Column::Status).unwrap();
81        cols.insert(at, Column::Bookmark);
82    }
83    cols
84}
85
86/// `1234567` → `1,234,567`.
87pub fn format_count(n: u64) -> String {
88    let raw = n.to_string();
89    let mut out = String::with_capacity(raw.len() + raw.len() / 3);
90    for (i, c) in raw.chars().enumerate() {
91        if i > 0 && (raw.len() - i).is_multiple_of(3) {
92            out.push(',');
93        }
94        out.push(c);
95    }
96    out
97}
98
99/// Records/second with a sensible precision ramp.
100pub fn format_rate(rate: f64) -> String {
101    if rate <= 0.0 {
102        "-".to_string()
103    } else if rate < 10.0 {
104        format!("{rate:.1}")
105    } else if rate < 100_000.0 {
106        format_count(rate.round() as u64)
107    } else {
108        format!("{:.0}k", rate / 1000.0)
109    }
110}
111
112/// Age of the last persisted bookmark relative to `now_unix`, or `-`.
113pub fn format_bookmark_age(last_bookmark_unix: f64, now_unix: f64) -> String {
114    if last_bookmark_unix <= 0.0 {
115        return "-".to_string();
116    }
117    let secs = (now_unix - last_bookmark_unix).max(0.0) as u64;
118    format_elapsed(std::time::Duration::from_secs(secs)) + " ago"
119}
120
121/// `1h02m03s` / `4m05s` / `12s`.
122pub fn format_elapsed(d: std::time::Duration) -> String {
123    let s = d.as_secs();
124    if s >= 3600 {
125        format!("{}h{:02}m{:02}s", s / 3600, (s % 3600) / 60, s % 60)
126    } else if s >= 60 {
127        format!("{}m{:02}s", s / 60, s % 60)
128    } else {
129        format!("{s}s")
130    }
131}
132
133/// Status label + color for a row.
134pub fn row_status(row: &RowStats) -> (&'static str, Color) {
135    match row.finished {
136        Some(true) => ("done", Color::Green),
137        Some(false) => ("failed", Color::Red),
138        None if row.in_flight => ("running", Color::Cyan),
139        None => ("pending", Color::DarkGray),
140    }
141}
142
143fn cell_for(col: Column, id: &str, row: &RowStats, now_unix: f64) -> Cell<'static> {
144    match col {
145        Column::Row => Cell::from(if id.is_empty() { "-" } else { id }.to_string()),
146        Column::Route => Cell::from(format!(
147            "{} → {}",
148            if row.source.is_empty() {
149                "?"
150            } else {
151                &row.source
152            },
153            if row.sink.is_empty() { "?" } else { &row.sink },
154        )),
155        Column::In => Cell::from(format_count(row.records_in)),
156        Column::Out => Cell::from(format_count(row.records_out)),
157        Column::Rate => Cell::from(format_rate(row.rate)),
158        Column::Errors => {
159            let n = row.source_errors + row.sink_errors;
160            let cell = Cell::from(format_count(n));
161            if n > 0 {
162                cell.style(Style::default().fg(Color::Red))
163            } else {
164                cell
165            }
166        }
167        Column::Dlq => {
168            let cell = Cell::from(format_count(row.dlq_records));
169            if row.dlq_records > 0 {
170                cell.style(Style::default().fg(Color::Yellow))
171            } else {
172                cell
173            }
174        }
175        Column::Bookmark => Cell::from(format_bookmark_age(row.last_bookmark_unix, now_unix)),
176        Column::Status => {
177            let (label, color) = row_status(row);
178            Cell::from(label).style(Style::default().fg(color))
179        }
180    }
181}
182
183/// Draw one frame: header, invocation table, log pane, footer.
184pub fn draw(
185    frame: &mut Frame<'_>,
186    pipeline: &str,
187    model: &TuiModel,
188    elapsed: std::time::Duration,
189    logs: &[String],
190    cancelling: bool,
191) {
192    let area = frame.area();
193    let [header_a, table_a, logs_a, footer_a] = Layout::vertical([
194        Constraint::Length(1),
195        Constraint::Min(4),
196        Constraint::Length(6),
197        Constraint::Length(1),
198    ])
199    .areas(area);
200
201    frame.render_widget(header(pipeline, model, elapsed, cancelling), header_a);
202    render_table(frame, table_a, model);
203    render_logs(frame, logs_a, logs);
204    let footer = Paragraph::new(Line::from(vec![
205        Span::styled(" q ", Style::default().add_modifier(Modifier::BOLD)),
206        Span::raw("cancel (flush at page boundary) · "),
207        Span::styled("Ctrl-C ", Style::default().add_modifier(Modifier::BOLD)),
208        Span::raw("cancel"),
209    ]))
210    .style(Style::default().fg(Color::DarkGray));
211    frame.render_widget(footer, footer_a);
212}
213
214fn header<'a>(
215    pipeline: &'a str,
216    model: &TuiModel,
217    elapsed: std::time::Duration,
218    cancelling: bool,
219) -> Paragraph<'a> {
220    let mut spans = vec![
221        Span::styled(
222            format!(" faucet run · {pipeline} "),
223            Style::default().add_modifier(Modifier::BOLD),
224        ),
225        Span::raw(format!(
226            "· {} · {} out · {} rec/s",
227            format_elapsed(elapsed),
228            format_count(model.total_out),
229            format_rate(model.total_rate),
230        )),
231    ];
232    if cancelling {
233        spans.push(Span::styled(
234            " · cancelling…",
235            Style::default().fg(Color::Yellow),
236        ));
237    }
238    Paragraph::new(Line::from(spans))
239}
240
241fn render_table(frame: &mut Frame<'_>, area: Rect, model: &TuiModel) {
242    let cols = visible_columns(area.width);
243    let now_unix = std::time::SystemTime::now()
244        .duration_since(std::time::UNIX_EPOCH)
245        .map(|d| d.as_secs_f64())
246        .unwrap_or(0.0);
247    let header = Row::new(cols.iter().map(|c| Cell::from(c.header())))
248        .style(Style::default().add_modifier(Modifier::BOLD));
249    let rows = model.rows.iter().map(|(id, row)| {
250        Row::new(
251            cols.iter()
252                .map(|c| cell_for(*c, id, row, now_unix))
253                .collect::<Vec<_>>(),
254        )
255    });
256    let widths: Vec<Constraint> = cols.iter().map(|c| c.constraint()).collect();
257    let table = Table::new(rows, widths).header(header).block(
258        Block::default()
259            .borders(Borders::TOP)
260            .title(" invocations "),
261    );
262    frame.render_widget(table, area);
263}
264
265fn render_logs(frame: &mut Frame<'_>, area: Rect, logs: &[String]) {
266    let visible = area.height.saturating_sub(1) as usize;
267    let start = logs.len().saturating_sub(visible);
268    let text: Vec<Line<'_>> = logs[start..]
269        .iter()
270        .map(|l| Line::from(l.as_str()))
271        .collect();
272    let para = Paragraph::new(text)
273        .style(Style::default().fg(Color::DarkGray))
274        .block(Block::default().borders(Borders::TOP).title(" log "));
275    frame.render_widget(para, area);
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn counts_group_thousands() {
284        assert_eq!(format_count(0), "0");
285        assert_eq!(format_count(999), "999");
286        assert_eq!(format_count(1_000), "1,000");
287        assert_eq!(format_count(1_234_567), "1,234,567");
288    }
289
290    #[test]
291    fn rates_ramp_precision() {
292        assert_eq!(format_rate(0.0), "-");
293        assert_eq!(format_rate(3.24), "3.2");
294        assert_eq!(format_rate(1234.6), "1,235");
295        assert_eq!(format_rate(250_000.0), "250k");
296    }
297
298    #[test]
299    fn elapsed_formats() {
300        use std::time::Duration;
301        assert_eq!(format_elapsed(Duration::from_secs(12)), "12s");
302        assert_eq!(format_elapsed(Duration::from_secs(65)), "1m05s");
303        assert_eq!(format_elapsed(Duration::from_secs(3723)), "1h02m03s");
304    }
305
306    #[test]
307    fn bookmark_age() {
308        assert_eq!(format_bookmark_age(0.0, 100.0), "-");
309        assert_eq!(format_bookmark_age(40.0, 100.0), "1m00s ago");
310        // Clock skew (bookmark ahead of now) clamps to zero.
311        assert_eq!(format_bookmark_age(200.0, 100.0), "0s ago");
312    }
313
314    #[test]
315    fn narrow_terminals_drop_columns_but_keep_essentials() {
316        for width in [10u16, 30, 44, 58, 70, 82, 92, 104, 200] {
317            let cols = visible_columns(width);
318            assert!(cols.contains(&Column::Row), "width {width}");
319            assert!(cols.contains(&Column::Out), "width {width}");
320            assert!(cols.contains(&Column::Status), "width {width}");
321            // Wider never shows fewer columns.
322        }
323        let narrow = visible_columns(30).len();
324        let wide = visible_columns(200).len();
325        assert!(narrow < wide);
326        assert_eq!(visible_columns(200).len(), 9, "all columns at full width");
327        // Status stays last, row stays first at any width.
328        for width in [30u16, 60, 90, 200] {
329            let cols = visible_columns(width);
330            assert_eq!(*cols.first().unwrap(), Column::Row);
331            assert_eq!(*cols.last().unwrap(), Column::Status);
332        }
333    }
334
335    fn buffer_text(terminal: &ratatui::Terminal<ratatui::backend::TestBackend>) -> String {
336        terminal
337            .backend()
338            .buffer()
339            .content()
340            .iter()
341            .map(|cell| cell.symbol())
342            .collect()
343    }
344
345    fn sample_model() -> TuiModel {
346        let mut model = TuiModel::default();
347        let mut healthy = RowStats {
348            source: "rest".into(),
349            sink: "jsonl".into(),
350            records_in: 12_500,
351            records_out: 12_400,
352            rate: 830.0,
353            ..Default::default()
354        };
355        healthy.in_flight = true;
356        model.rows.insert("orders".into(), healthy);
357        model.rows.insert(
358            "users".into(),
359            RowStats {
360                source: "postgres".into(),
361                sink: "spanner".into(),
362                records_in: 4,
363                records_out: 2,
364                source_errors: 1,
365                sink_errors: 2,
366                dlq_records: 7,
367                // Recent enough that the rendered age ("1m30s ago") fits
368                // the bookmark column; an epoch-old bookmark would clip the
369                // "ago" suffix this test asserts on.
370                last_bookmark_unix: std::time::SystemTime::now()
371                    .duration_since(std::time::UNIX_EPOCH)
372                    .expect("clock")
373                    .as_secs_f64()
374                    - 90.0,
375                finished: Some(false),
376                ..Default::default()
377            },
378        );
379        model.total_out = 12_402;
380        model.total_rate = 830.0;
381        model
382    }
383
384    fn draw_at(width: u16, height: u16, model: &TuiModel, cancelling: bool) -> String {
385        let backend = ratatui::backend::TestBackend::new(width, height);
386        let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
387        terminal
388            .draw(|frame| {
389                draw(
390                    frame,
391                    "demo-pipeline",
392                    model,
393                    std::time::Duration::from_secs(65),
394                    &["log line one".to_string(), "log line two".to_string()],
395                    cancelling,
396                )
397            })
398            .expect("draw");
399        buffer_text(&terminal)
400    }
401
402    #[test]
403    fn draws_header_table_logs_and_footer_at_full_width() {
404        let text = draw_at(120, 24, &sample_model(), false);
405        // Header: pipeline name, elapsed, totals.
406        assert!(text.contains("faucet run · demo-pipeline"), "{text}");
407        assert!(text.contains("1m05s"), "{text}");
408        assert!(text.contains("12,402 out"), "{text}");
409        // Table: both rows with routes, counts, statuses.
410        assert!(text.contains("source → sink"), "{text}");
411        assert!(text.contains("rest → jsonl"), "{text}");
412        assert!(text.contains("postgres → spanner"), "{text}");
413        assert!(text.contains("12,400"), "{text}");
414        assert!(text.contains("running"), "{text}");
415        assert!(text.contains("failed"), "{text}");
416        // Errors + DLQ + bookmark columns present at this width.
417        assert!(text.contains("dlq"), "{text}");
418        assert!(text.contains("bookmark"), "{text}");
419        assert!(text.contains("ago"), "{text}");
420        // Log pane + footer.
421        assert!(text.contains("log line two"), "{text}");
422        assert!(text.contains("cancel (flush at page boundary)"), "{text}");
423        assert!(!text.contains("cancelling…"), "{text}");
424    }
425
426    #[test]
427    fn cancelling_banner_shows_when_requested() {
428        let text = draw_at(120, 24, &sample_model(), true);
429        assert!(text.contains("cancelling…"), "{text}");
430    }
431
432    #[test]
433    fn narrow_terminal_drops_optional_columns_but_renders() {
434        let text = draw_at(40, 16, &sample_model(), false);
435        // Essentials survive.
436        assert!(text.contains("row"), "{text}");
437        assert!(text.contains("out"), "{text}");
438        assert!(text.contains("status"), "{text}");
439        assert!(text.contains("running"), "{text}");
440        // Route/bookmark columns are gone at 40 cells.
441        assert!(!text.contains("source → sink"), "{text}");
442        assert!(!text.contains("bookmark"), "{text}");
443    }
444
445    #[test]
446    fn empty_model_renders_headers_and_placeholder_free_table() {
447        let text = draw_at(80, 12, &TuiModel::default(), false);
448        assert!(text.contains("faucet run · demo-pipeline"), "{text}");
449        assert!(text.contains("invocations"), "{text}");
450        assert!(text.contains("0 out"), "{text}");
451    }
452
453    #[test]
454    fn anonymous_row_and_unknown_connectors_render_placeholders() {
455        let mut model = TuiModel::default();
456        model.rows.insert(String::new(), RowStats::default());
457        let text = draw_at(120, 16, &model, false);
458        // Empty row id renders as `-`, unknown connectors as `?`.
459        assert!(text.contains("? → ?"), "{text}");
460        assert!(text.contains("pending"), "{text}");
461    }
462
463    #[test]
464    fn log_pane_shows_only_the_tail_that_fits() {
465        let logs: Vec<String> = (0..40).map(|i| format!("logline-{i:02}")).collect();
466        let backend = ratatui::backend::TestBackend::new(100, 20);
467        let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
468        terminal
469            .draw(|frame| {
470                draw(
471                    frame,
472                    "p",
473                    &TuiModel::default(),
474                    std::time::Duration::from_secs(1),
475                    &logs,
476                    false,
477                )
478            })
479            .expect("draw");
480        let text = buffer_text(&terminal);
481        // The pane is 6 rows (1 border + 5 lines): newest lines win.
482        assert!(text.contains("logline-39"), "{text}");
483        assert!(!text.contains("logline-00"), "{text}");
484    }
485
486    #[test]
487    fn statuses_map_from_row_state() {
488        let mut row = RowStats::default();
489        assert_eq!(row_status(&row).0, "pending");
490        row.in_flight = true;
491        assert_eq!(row_status(&row).0, "running");
492        row.finished = Some(true);
493        assert_eq!(row_status(&row).0, "done");
494        row.finished = Some(false);
495        assert_eq!(row_status(&row).0, "failed");
496    }
497}