1use 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#[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
58pub 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
86pub 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
99pub 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
112pub 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
121pub 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
133pub 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
183pub 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 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 }
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 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 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 assert!(text.contains("faucet run · demo-pipeline"), "{text}");
407 assert!(text.contains("1m05s"), "{text}");
408 assert!(text.contains("12,402 out"), "{text}");
409 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 assert!(text.contains("dlq"), "{text}");
418 assert!(text.contains("bookmark"), "{text}");
419 assert!(text.contains("ago"), "{text}");
420 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 assert!(text.contains("row"), "{text}");
437 assert!(text.contains("out"), "{text}");
438 assert!(text.contains("status"), "{text}");
439 assert!(text.contains("running"), "{text}");
440 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 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 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}