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