Skip to main content

rusticity_term/ui/
monitoring.rs

1use crate::common::render_vertical_scrollbar;
2use crate::ui::format_title;
3use ratatui::prelude::*;
4use ratatui::widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType, Paragraph};
5
6/// Trait for states that support monitoring with metrics loading
7pub trait MonitoringState {
8    fn is_metrics_loading(&self) -> bool;
9    fn set_metrics_loading(&mut self, loading: bool);
10    fn monitoring_scroll(&self) -> usize;
11    fn set_monitoring_scroll(&mut self, scroll: usize);
12    fn clear_metrics(&mut self);
13}
14
15pub struct MetricChart<'a> {
16    pub title: &'a str,
17    pub data: &'a [(i64, f64)],
18    pub y_axis_label: &'a str,
19    pub x_axis_label: Option<String>,
20}
21
22pub struct MultiDatasetChart<'a> {
23    pub title: &'a str,
24    pub datasets: Vec<(&'a str, &'a [(i64, f64)])>,
25    pub y_axis_label: &'a str,
26    pub y_axis_step: u32,
27    pub x_axis_label: Option<String>,
28}
29
30pub struct DualAxisChart<'a> {
31    pub title: &'a str,
32    pub left_dataset: (&'a str, &'a [(i64, f64)]),
33    pub right_dataset: (&'a str, &'a [(i64, f64)]),
34    pub left_y_label: &'a str,
35    pub right_y_label: &'a str,
36    pub x_axis_label: Option<String>,
37}
38
39pub fn render_monitoring_tab(
40    frame: &mut Frame,
41    area: Rect,
42    single_charts: &[MetricChart],
43    multi_charts: &[MultiDatasetChart],
44    dual_charts: &[DualAxisChart],
45    trailing_single_charts: &[MetricChart],
46    scroll_position: usize,
47) {
48    let available_height = area.height as usize;
49    let total_charts =
50        single_charts.len() + multi_charts.len() + dual_charts.len() + trailing_single_charts.len();
51
52    let mut y_offset = 0;
53    let mut chart_idx = 0;
54
55    for chart in single_charts.iter() {
56        if chart_idx < scroll_position {
57            chart_idx += 1;
58            continue;
59        }
60        if y_offset + 14 > available_height {
61            break;
62        }
63        let chart_height = 20.min((available_height - y_offset) as u16);
64        let chart_rect = Rect {
65            x: area.x,
66            y: area.y + y_offset as u16,
67            width: area.width.saturating_sub(1),
68            height: chart_height,
69        };
70        render_chart(frame, chart, chart_rect);
71        y_offset += 20;
72        chart_idx += 1;
73    }
74
75    for chart in multi_charts.iter() {
76        if chart_idx < scroll_position {
77            chart_idx += 1;
78            continue;
79        }
80        if y_offset + 14 > available_height {
81            break;
82        }
83        let chart_height = 20.min((available_height - y_offset) as u16);
84        let chart_rect = Rect {
85            x: area.x,
86            y: area.y + y_offset as u16,
87            width: area.width.saturating_sub(1),
88            height: chart_height,
89        };
90        render_multi_dataset_chart(frame, chart, chart_rect);
91        y_offset += 20;
92        chart_idx += 1;
93    }
94
95    for chart in dual_charts.iter() {
96        if chart_idx < scroll_position {
97            chart_idx += 1;
98            continue;
99        }
100        if y_offset + 14 > available_height {
101            break;
102        }
103        let chart_height = 20.min((available_height - y_offset) as u16);
104        let chart_rect = Rect {
105            x: area.x,
106            y: area.y + y_offset as u16,
107            width: area.width.saturating_sub(1),
108            height: chart_height,
109        };
110        render_dual_axis_chart(frame, chart, chart_rect);
111        y_offset += 20;
112        chart_idx += 1;
113    }
114
115    for chart in trailing_single_charts.iter() {
116        if chart_idx < scroll_position {
117            chart_idx += 1;
118            continue;
119        }
120        if y_offset + 14 > available_height {
121            break;
122        }
123        let chart_height = 20.min((available_height - y_offset) as u16);
124        let chart_rect = Rect {
125            x: area.x,
126            y: area.y + y_offset as u16,
127            width: area.width.saturating_sub(1),
128            height: chart_height,
129        };
130        render_chart(frame, chart, chart_rect);
131        y_offset += 20;
132        chart_idx += 1;
133    }
134
135    let total_height = total_charts * 20;
136    let scroll_offset = scroll_position * 20;
137    render_vertical_scrollbar(frame, area, total_height, scroll_offset);
138}
139
140fn x_axis_labels(min_x: f64, max_x: f64) -> Vec<Span<'static>> {
141    let mut labels = Vec::new();
142    let time_range = (max_x - min_x).max(1.0);
143    let target_labels = 6;
144    let raw_step = time_range / target_labels as f64;
145    let step = ((raw_step / 3600.0).ceil() as i64).max(1) * 3600;
146    let mut current = (min_x as i64 / step) * step;
147    while current <= max_x as i64 {
148        let time = chrono::DateTime::from_timestamp(current, 0)
149            .unwrap_or_default()
150            .format("%H:%M")
151            .to_string();
152        labels.push(Span::raw(time));
153        current += step;
154    }
155    labels
156}
157
158fn render_chart(frame: &mut Frame, chart: &MetricChart, area: Rect) {
159    let block = Block::default()
160        .title(format_title(chart.title))
161        .borders(Borders::ALL)
162        .border_type(BorderType::Rounded)
163        .border_style(Style::default().fg(Color::Gray));
164
165    if chart.data.is_empty() {
166        let inner = block.inner(area);
167        frame.render_widget(block, area);
168        let paragraph = Paragraph::new("--");
169        frame.render_widget(paragraph, inner);
170        return;
171    }
172
173    let data: Vec<(f64, f64)> = chart
174        .data
175        .iter()
176        .map(|(timestamp, value)| (*timestamp as f64, *value))
177        .collect();
178
179    let min_x = data.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
180    let max_x = data
181        .iter()
182        .map(|(x, _)| *x)
183        .fold(f64::NEG_INFINITY, f64::max);
184    let max_y = data
185        .iter()
186        .map(|(_, y)| *y)
187        .fold(0.0_f64, f64::max)
188        .max(1.0);
189
190    let dataset = Dataset::default()
191        .name(chart.title)
192        .marker(symbols::Marker::Braille)
193        .graph_type(GraphType::Line)
194        .style(Style::default().fg(Color::Cyan))
195        .data(&data);
196
197    let x_labels = x_axis_labels(min_x, max_x);
198
199    let mut x_axis = Axis::default()
200        .style(Style::default().fg(Color::Gray))
201        .bounds([min_x, max_x])
202        .labels(x_labels);
203
204    if let Some(label) = &chart.x_axis_label {
205        x_axis = x_axis.title(label.as_str());
206    }
207
208    let y_labels: Vec<Span> = {
209        let mut labels = Vec::new();
210        let mut current = 0.0;
211        let max = max_y * 1.1;
212        let step = if max <= 10.0 {
213            0.5
214        } else {
215            (max / 10.0).ceil()
216        };
217        while current <= max {
218            labels.push(Span::raw(format!("{:.1}", current)));
219            current += step;
220        }
221        labels
222    };
223
224    let y_axis = Axis::default()
225        .title(chart.y_axis_label)
226        .style(Style::default().fg(Color::Gray))
227        .bounds([0.0, max_y * 1.1])
228        .labels(y_labels);
229
230    let chart_widget = Chart::new(vec![dataset])
231        .block(block)
232        .x_axis(x_axis)
233        .y_axis(y_axis);
234
235    frame.render_widget(chart_widget, area);
236}
237
238fn render_multi_dataset_chart(frame: &mut Frame, chart: &MultiDatasetChart, area: Rect) {
239    let block = Block::default()
240        .title(format_title(chart.title))
241        .borders(Borders::ALL)
242        .border_type(BorderType::Rounded)
243        .border_style(Style::default().fg(Color::Gray));
244
245    let all_empty = chart.datasets.iter().all(|(_, data)| data.is_empty());
246    if all_empty {
247        let inner = block.inner(area);
248        frame.render_widget(block, area);
249        let paragraph = Paragraph::new("--");
250        frame.render_widget(paragraph, inner);
251        return;
252    }
253
254    let colors = [Color::Cyan, Color::Yellow, Color::Magenta];
255    let mut converted_data: Vec<Vec<(f64, f64)>> = Vec::new();
256    let mut min_x = f64::INFINITY;
257    let mut max_x = f64::NEG_INFINITY;
258    let mut max_y = 0.0_f64;
259
260    for (_, data) in chart.datasets.iter() {
261        if data.is_empty() {
262            converted_data.push(Vec::new());
263            continue;
264        }
265        let converted: Vec<(f64, f64)> = data
266            .iter()
267            .map(|(timestamp, value)| (*timestamp as f64, *value))
268            .collect();
269
270        min_x = min_x.min(
271            converted
272                .iter()
273                .map(|(x, _)| *x)
274                .fold(f64::INFINITY, f64::min),
275        );
276        max_x = max_x.max(
277            converted
278                .iter()
279                .map(|(x, _)| *x)
280                .fold(f64::NEG_INFINITY, f64::max),
281        );
282        max_y = max_y.max(converted.iter().map(|(_, y)| *y).fold(0.0_f64, f64::max));
283
284        converted_data.push(converted);
285    }
286
287    let mut datasets_vec = Vec::new();
288    for (idx, ((name, _), data)) in chart.datasets.iter().zip(converted_data.iter()).enumerate() {
289        if data.is_empty() {
290            continue;
291        }
292        let dataset = Dataset::default()
293            .name(*name)
294            .marker(symbols::Marker::Braille)
295            .graph_type(GraphType::Line)
296            .style(Style::default().fg(colors[idx % colors.len()]))
297            .data(data);
298
299        datasets_vec.push(dataset);
300    }
301
302    max_y = max_y.max(1.0);
303
304    let x_labels = x_axis_labels(min_x, max_x);
305
306    let mut x_axis = Axis::default()
307        .style(Style::default().fg(Color::Gray))
308        .bounds([min_x, max_x])
309        .labels(x_labels);
310
311    if let Some(label) = &chart.x_axis_label {
312        x_axis = x_axis.title(label.as_str());
313    }
314
315    let y_labels: Vec<Span> = {
316        let mut labels = Vec::new();
317        let mut current = 0.0;
318        let max = max_y * 1.1;
319        let step = chart.y_axis_step as f64;
320        while current <= max {
321            let label = if step >= 1000.0 {
322                format!("{}K", (current / 1000.0) as u32)
323            } else {
324                format!("{:.0}", current)
325            };
326            labels.push(Span::raw(label));
327            current += step;
328        }
329        labels
330    };
331
332    let y_axis = Axis::default()
333        .title(chart.y_axis_label)
334        .style(Style::default().fg(Color::Gray))
335        .bounds([0.0, max_y * 1.1])
336        .labels(y_labels);
337
338    let chart_widget = Chart::new(datasets_vec)
339        .block(block)
340        .x_axis(x_axis)
341        .y_axis(y_axis);
342
343    frame.render_widget(chart_widget, area);
344}
345
346fn render_dual_axis_chart(frame: &mut Frame, chart: &DualAxisChart, area: Rect) {
347    let block = Block::default()
348        .title(format_title(chart.title))
349        .borders(Borders::ALL)
350        .border_type(BorderType::Rounded)
351        .border_style(Style::default().fg(Color::Gray));
352
353    let (left_name, left_data) = chart.left_dataset;
354    let (right_name, right_data) = chart.right_dataset;
355
356    if left_data.is_empty() && right_data.is_empty() {
357        let inner = block.inner(area);
358        frame.render_widget(block, area);
359        let paragraph = Paragraph::new("--");
360        frame.render_widget(paragraph, inner);
361        return;
362    }
363
364    let left_converted: Vec<(f64, f64)> = left_data
365        .iter()
366        .map(|(timestamp, value)| (*timestamp as f64, *value))
367        .collect();
368
369    let right_converted: Vec<(f64, f64)> = right_data
370        .iter()
371        .map(|(timestamp, value)| (*timestamp as f64, *value))
372        .collect();
373
374    let mut min_x = f64::INFINITY;
375    let mut max_x = f64::NEG_INFINITY;
376    let mut max_left_y = 0.0_f64;
377    let max_right_y = 100.0;
378
379    if !left_converted.is_empty() {
380        min_x = min_x.min(
381            left_converted
382                .iter()
383                .map(|(x, _)| *x)
384                .fold(f64::INFINITY, f64::min),
385        );
386        max_x = max_x.max(
387            left_converted
388                .iter()
389                .map(|(x, _)| *x)
390                .fold(f64::NEG_INFINITY, f64::max),
391        );
392        max_left_y = left_converted
393            .iter()
394            .map(|(_, y)| *y)
395            .fold(0.0_f64, f64::max);
396    }
397
398    if !right_converted.is_empty() {
399        min_x = min_x.min(
400            right_converted
401                .iter()
402                .map(|(x, _)| *x)
403                .fold(f64::INFINITY, f64::min),
404        );
405        max_x = max_x.max(
406            right_converted
407                .iter()
408                .map(|(x, _)| *x)
409                .fold(f64::NEG_INFINITY, f64::max),
410        );
411    }
412
413    max_left_y = max_left_y.max(1.0);
414
415    let normalized_right: Vec<(f64, f64)> = right_converted
416        .iter()
417        .map(|(x, y)| (*x, y * max_left_y / max_right_y))
418        .collect();
419
420    let left_dataset = Dataset::default()
421        .name(left_name)
422        .marker(symbols::Marker::Braille)
423        .graph_type(GraphType::Line)
424        .style(Style::default().fg(Color::Red))
425        .data(&left_converted);
426
427    let right_dataset = Dataset::default()
428        .name(right_name)
429        .marker(symbols::Marker::Braille)
430        .graph_type(GraphType::Line)
431        .style(Style::default().fg(Color::Green))
432        .data(&normalized_right);
433
434    let x_labels = x_axis_labels(min_x, max_x);
435
436    let mut x_axis = Axis::default()
437        .style(Style::default().fg(Color::Gray))
438        .bounds([min_x, max_x])
439        .labels(x_labels);
440
441    if let Some(label) = &chart.x_axis_label {
442        x_axis = x_axis.title(label.as_str());
443    }
444
445    let y_labels: Vec<Span> = {
446        let mut labels = Vec::new();
447        let mut current = 0.0;
448        let max = max_left_y * 1.1;
449        let step = if max <= 10.0 {
450            0.5
451        } else {
452            (max / 10.0).ceil()
453        };
454        while current <= max {
455            labels.push(Span::raw(format!("{:.0}", current)));
456            current += step;
457        }
458        labels
459    };
460
461    let y_axis = Axis::default()
462        .title(chart.left_y_label)
463        .style(Style::default().fg(Color::Gray))
464        .bounds([0.0, max_left_y * 1.1])
465        .labels(y_labels);
466
467    let chart_widget = Chart::new(vec![left_dataset, right_dataset])
468        .block(block)
469        .x_axis(x_axis)
470        .y_axis(y_axis);
471
472    frame.render_widget(chart_widget, area);
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_metric_chart_creation() {
481        let data = vec![(1700000000, 5.0), (1700000060, 10.0)];
482        let chart = MetricChart {
483            title: "Test Metric",
484            data: &data,
485            y_axis_label: "Count",
486            x_axis_label: None,
487        };
488        assert_eq!(chart.title, "Test Metric");
489        assert_eq!(chart.data.len(), 2);
490        assert_eq!(chart.y_axis_label, "Count");
491        assert_eq!(chart.x_axis_label, None);
492    }
493
494    #[test]
495    fn test_empty_chart_data() {
496        let data: Vec<(i64, f64)> = vec![];
497        let chart = MetricChart {
498            title: "Empty Chart",
499            data: &data,
500            y_axis_label: "Value",
501            x_axis_label: None,
502        };
503        assert!(chart.data.is_empty());
504    }
505
506    #[test]
507    fn test_metric_chart_with_x_axis_label() {
508        let data = vec![(1700000000, 5.0), (1700000060, 10.0)];
509        let chart = MetricChart {
510            title: "Invocations",
511            data: &data,
512            y_axis_label: "Count",
513            x_axis_label: Some("Invocations [sum: 15]".to_string()),
514        };
515        assert_eq!(
516            chart.x_axis_label,
517            Some("Invocations [sum: 15]".to_string())
518        );
519    }
520
521    #[test]
522    fn test_multi_dataset_chart_creation() {
523        let min_data = vec![(1700000000, 100.0), (1700000060, 150.0)];
524        let avg_data = vec![(1700000000, 200.0), (1700000060, 250.0)];
525        let max_data = vec![(1700000000, 300.0), (1700000060, 350.0)];
526
527        let chart = MultiDatasetChart {
528            title: "Duration",
529            datasets: vec![
530                ("Minimum", &min_data),
531                ("Average", &avg_data),
532                ("Maximum", &max_data),
533            ],
534            y_axis_label: "Milliseconds",
535            y_axis_step: 1000,
536            x_axis_label: Some("Minimum [100], Average [200], Maximum [300]".to_string()),
537        };
538
539        assert_eq!(chart.title, "Duration");
540        assert_eq!(chart.datasets.len(), 3);
541        assert_eq!(chart.y_axis_label, "Milliseconds");
542        assert_eq!(chart.y_axis_step, 1000);
543    }
544
545    #[test]
546    fn test_multi_dataset_chart_empty() {
547        let empty: Vec<(i64, f64)> = vec![];
548        let chart = MultiDatasetChart {
549            title: "Empty Duration",
550            datasets: vec![
551                ("Minimum", &empty),
552                ("Average", &empty),
553                ("Maximum", &empty),
554            ],
555            y_axis_label: "Milliseconds",
556            y_axis_step: 1000,
557            x_axis_label: None,
558        };
559
560        assert!(chart.datasets.iter().all(|(_, data)| data.is_empty()));
561    }
562
563    #[test]
564    fn test_duration_label_format() {
565        let min = 100.0;
566        let avg = 200.5;
567        let max = 350.0;
568        let label = format!(
569            "Minimum [{:.0}], Average [{:.0}], Maximum [{:.0}]",
570            min, avg, max
571        );
572        assert_eq!(label, "Minimum [100], Average [200], Maximum [350]");
573    }
574
575    #[test]
576    fn test_y_axis_label_formatting_1k() {
577        let value = 1000.0;
578        let label = format!("{}K", (value / 1000.0) as u32);
579        assert_eq!(label, "1K");
580    }
581
582    #[test]
583    fn test_y_axis_label_formatting_5k() {
584        let value = 5000.0;
585        let label = format!("{}K", (value / 1000.0) as u32);
586        assert_eq!(label, "5K");
587    }
588
589    #[test]
590    fn test_y_axis_step_1000() {
591        let step = 1000;
592        assert_eq!(step, 1000);
593        let values = [0, 1000, 2000, 3000, 4000, 5000];
594        for (i, val) in values.iter().enumerate() {
595            assert_eq!(*val, i * step);
596        }
597    }
598
599    #[test]
600    fn test_duration_min_calculation() {
601        let data = [(1700000000, 100.0), (1700000060, 50.0), (1700000120, 75.0)];
602        let min: f64 = data
603            .iter()
604            .map(|(_, v)| v)
605            .fold(f64::INFINITY, |a, &b| a.min(b));
606        assert_eq!(min, 50.0);
607    }
608
609    #[test]
610    fn test_duration_avg_calculation() {
611        let data = [
612            (1700000000, 100.0),
613            (1700000060, 200.0),
614            (1700000120, 300.0),
615        ];
616        let avg: f64 = data.iter().map(|(_, v)| v).sum::<f64>() / data.len() as f64;
617        assert_eq!(avg, 200.0);
618    }
619
620    #[test]
621    fn test_duration_max_calculation() {
622        let data = [
623            (1700000000, 100.0),
624            (1700000060, 350.0),
625            (1700000120, 200.0),
626        ];
627        let max: f64 = data
628            .iter()
629            .map(|(_, v)| v)
630            .fold(f64::NEG_INFINITY, |a, &b| a.max(b));
631        assert_eq!(max, 350.0);
632    }
633
634    #[test]
635    fn test_duration_empty_data_min() {
636        let data: Vec<(i64, f64)> = vec![];
637        let min: f64 = data
638            .iter()
639            .map(|(_, v)| v)
640            .fold(f64::INFINITY, |a, &b| a.min(b));
641        assert!(min.is_infinite() && min.is_sign_positive());
642    }
643
644    #[test]
645    fn test_duration_empty_data_avg() {
646        let data: Vec<(i64, f64)> = vec![];
647        let avg: f64 = if !data.is_empty() {
648            data.iter().map(|(_, v)| v).sum::<f64>() / data.len() as f64
649        } else {
650            0.0
651        };
652        assert_eq!(avg, 0.0);
653    }
654
655    #[test]
656    fn test_dual_axis_chart_creation() {
657        let errors = vec![(1700000000, 5.0), (1700000060, 10.0)];
658        let success_rate = vec![(1700000000, 95.0), (1700000060, 90.0)];
659
660        let chart = DualAxisChart {
661            title: "Error count and success rate",
662            left_dataset: ("Errors", &errors),
663            right_dataset: ("Success rate", &success_rate),
664            left_y_label: "Count",
665            right_y_label: "%",
666            x_axis_label: Some("Errors [max: 10] and Success rate [min: 90%]".to_string()),
667        };
668
669        assert_eq!(chart.title, "Error count and success rate");
670        assert_eq!(chart.left_y_label, "Count");
671        assert_eq!(chart.right_y_label, "%");
672    }
673
674    #[test]
675    fn test_dual_axis_normalization() {
676        let max_left_y = 10.0;
677        let max_right_y = 100.0;
678        let right_value = 95.0;
679        let normalized = right_value * max_left_y / max_right_y;
680        assert_eq!(normalized, 9.5);
681    }
682
683    #[test]
684    fn test_chart_renders_with_14_lines_available() {
685        // Test that a chart will render when exactly 14 lines are available
686        let available_height = 34; // 20 for first chart + 14 for second
687        let y_offset = 20; // After first chart
688
689        // Should render: y_offset (20) + 14 = 34, which is NOT > 34
690        assert!(y_offset + 14 <= available_height);
691    }
692
693    #[test]
694    fn test_chart_skips_with_13_lines_available() {
695        // Test that a chart will NOT render when only 13 lines are available
696        let available_height = 33; // 20 for first chart + 13 remaining
697        let y_offset = 20; // After first chart
698
699        // Should NOT render: y_offset (20) + 14 = 34, which is > 33
700        assert!(y_offset + 14 > available_height);
701    }
702}