rusticity_term/ui/
monitoring.rs

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