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