1use crate::{
2 cli::{DataUnit, TrafficUnit},
3 config::Config,
4 device::{Device, NetworkReader},
5 input::InputEvent,
6 logger::TrafficLogger,
7 stats::StatsCalculator,
8};
9use anyhow::Result;
10use crossterm::event::{self, Event};
11use ratatui::{
12 backend::CrosstermBackend,
13 layout::{Constraint, Direction, Layout},
14 style::{Color, Modifier, Style},
15 text::{Line, Span},
16 widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph},
17 Frame, Terminal,
18};
19use std::{
20 collections::HashMap,
21 time::{Duration, Instant},
22};
23
24pub struct DisplayState {
25 pub current_device_index: usize,
26 pub devices: Vec<Device>,
27 pub show_multiple: bool,
28 pub show_graphs: bool,
29 pub paused: bool,
30 pub traffic_unit: TrafficUnit,
31 pub data_unit: DataUnit,
32 pub max_incoming: u64, pub max_outgoing: u64, pub zoom_level: f64, pub show_options: bool,
36 pub settings_message: Option<String>,
37}
38
39impl DisplayState {
40 pub fn new(devices: Vec<String>, config: &Config) -> Self {
41 let devices: Vec<Device> = devices.into_iter().map(Device::new).collect();
42
43 Self {
44 current_device_index: 0,
45 devices,
46 show_multiple: config.multiple_devices,
47 show_graphs: true,
48 paused: false,
49 traffic_unit: config.get_traffic_unit(),
50 data_unit: config.get_data_unit(),
51 max_incoming: config.max_incoming,
52 max_outgoing: config.max_outgoing,
53 zoom_level: 1.0,
54 show_options: false,
55 settings_message: None,
56 }
57 }
58}
59
60pub fn run_ui(
61 interfaces: Vec<String>,
62 reader: Box<dyn NetworkReader>,
63 mut config: Config,
64 log_file: Option<String>,
65) -> Result<()> {
66 let backend = CrosstermBackend::new(std::io::stdout());
67 let mut terminal = Terminal::new(backend)?;
68
69 let mut state = DisplayState::new(interfaces, &config);
70 let mut stats_calculators: HashMap<String, StatsCalculator> = HashMap::new();
71 let mut logger = if log_file.is_some() {
72 Some(TrafficLogger::new(log_file)?)
73 } else {
74 None
75 };
76
77 for device in &state.devices {
79 stats_calculators.insert(
80 device.name.clone(),
81 StatsCalculator::new(Duration::from_secs(config.average_window as u64)),
82 );
83 }
84
85 let refresh_interval = Duration::from_millis(config.refresh_interval);
86 let mut last_update = Instant::now();
87
88 loop {
89 let poll_interval = if config.high_performance {
91 (config.refresh_interval / 5).clamp(100, 200)
92 } else {
93 (config.refresh_interval / 10).clamp(50, 100)
94 };
95 if event::poll(Duration::from_millis(poll_interval))? {
96 if let Event::Key(key_event) = event::read()? {
97 let input_event = InputEvent::from_key_event(key_event);
98
99 if handle_input(&mut state, &mut stats_calculators, input_event, &mut config)? {
100 break; }
102 }
103 }
104
105 if !state.paused && last_update.elapsed() >= refresh_interval {
107 let mut high_traffic_detected = false;
108
109 for device in &mut state.devices {
110 if device.update(reader.as_ref()).is_err() {
111 continue;
113 }
114
115 if let Some(calculator) = stats_calculators.get_mut(&device.name) {
116 calculator.add_sample(device.stats.clone());
117
118 let (speed_in, speed_out) = calculator.current_speed();
120 let (packets_in, packets_out) = calculator.total_packets();
121 let total_speed = speed_in + speed_out;
122 if total_speed > 100_000_000 || packets_in + packets_out > 1000 {
123 high_traffic_detected = true;
124 }
125
126 if let Some(ref mut logger) = logger {
128 let _ = logger.log_traffic(&device.name, calculator);
129 }
130 }
131 }
132
133 if high_traffic_detected && !config.high_performance {
135 crate::security::enable_high_performance_security(true);
136 }
137
138 last_update = Instant::now();
139 }
140
141 terminal.draw(|f| {
143 draw_ui(f, &state, &stats_calculators, &config);
144 })?;
145 }
146
147 Ok(())
148}
149
150fn handle_input(
151 state: &mut DisplayState,
152 stats_calculators: &mut HashMap<String, StatsCalculator>,
153 event: InputEvent,
154 config: &mut Config,
155) -> Result<bool> {
156 match event {
158 InputEvent::NextPanel
159 | InputEvent::PrevPanel
160 | InputEvent::NextItem
161 | InputEvent::PrevItem => {
162 return Ok(false);
164 }
165 _ => {}
166 }
167
168 if state.show_options {
170 match event {
171 InputEvent::ShowOptions | InputEvent::Quit => {
172 state.show_options = false;
173 state.settings_message = None; return Ok(false);
175 }
176 InputEvent::ToggleTrafficUnits => {
178 state.traffic_unit = state.traffic_unit.next();
179 return Ok(false);
180 }
181 InputEvent::ToggleDataUnits => {
182 state.data_unit = state.data_unit.next();
183 return Ok(false);
184 }
185 InputEvent::ToggleGraphs | InputEvent::ToggleMultiple => {
188 return Ok(false);
190 }
191 InputEvent::Pause => {
192 state.paused = !state.paused;
193 return Ok(false);
194 }
195 InputEvent::ZoomIn => {
196 state.zoom_level = (state.zoom_level * 1.5).min(10.0);
197 return Ok(false);
198 }
199 InputEvent::ZoomOut => {
200 state.zoom_level = (state.zoom_level / 1.5).max(0.1);
201 return Ok(false);
202 }
203 InputEvent::SaveSettings => {
204 config.traffic_format = state.traffic_unit.to_string().to_string();
206 config.data_format = state.data_unit.to_string().to_string();
207 config.multiple_devices = state.show_multiple;
208 config.max_incoming = state.max_incoming;
209 config.max_outgoing = state.max_outgoing;
210
211 match config.save() {
213 Ok(_) => {
214 state.settings_message =
215 Some("✅ Settings saved to ~/.netwatch".to_string())
216 }
217 Err(e) => state.settings_message = Some(format!("❌ Save failed: {e}")),
218 }
219 return Ok(false);
220 }
221 InputEvent::IncreaseRefresh => {
222 config.refresh_interval = (config.refresh_interval.saturating_sub(50)).max(50); return Ok(false);
224 }
225 InputEvent::DecreaseRefresh => {
226 config.refresh_interval = (config.refresh_interval + 50).min(2000); return Ok(false);
228 }
229 InputEvent::IncreaseAverage => {
230 config.average_window = (config.average_window + 30).min(1800); return Ok(false);
232 }
233 InputEvent::DecreaseAverage => {
234 config.average_window = (config.average_window.saturating_sub(30)).max(30); return Ok(false);
236 }
237 InputEvent::ReloadSettings => {
238 match Config::load() {
240 Ok(new_config) => {
241 *config = new_config;
242 state.traffic_unit = config.get_traffic_unit();
244 state.data_unit = config.get_data_unit();
245 state.show_multiple = config.multiple_devices;
246 state.max_incoming = config.max_incoming;
247 state.max_outgoing = config.max_outgoing;
248 state.settings_message =
249 Some("✅ Settings reloaded from ~/.netwatch".to_string());
250 }
251 Err(e) => {
252 state.settings_message = Some(format!("❌ Reload failed: {e}"));
253 }
254 }
255 return Ok(false);
256 }
257 _ => {
258 return Ok(false);
260 }
261 }
262 }
263
264 match event {
265 InputEvent::Quit => return Ok(true),
266
267 InputEvent::NextDevice => {
268 if !state.devices.is_empty() {
269 state.current_device_index = (state.current_device_index + 1) % state.devices.len();
270 }
271 }
272
273 InputEvent::PrevDevice => {
274 if !state.devices.is_empty() {
275 state.current_device_index = if state.current_device_index == 0 {
276 state.devices.len() - 1
277 } else {
278 state.current_device_index - 1
279 };
280 }
281 }
282
283 InputEvent::Reset => {
284 if let Some(device) = state.devices.get(state.current_device_index) {
286 if let Some(calculator) = stats_calculators.get_mut(&device.name) {
287 calculator.reset();
288 }
289 }
290 }
291
292 InputEvent::Pause => {
293 state.paused = !state.paused;
294 }
295
296 InputEvent::ToggleTrafficUnits => {
297 state.traffic_unit = state.traffic_unit.next();
298 }
299
300 InputEvent::ToggleDataUnits => {
301 state.data_unit = state.data_unit.next();
302 }
303
304 InputEvent::ToggleGraphs => {
305 state.show_graphs = !state.show_graphs;
306 }
307
308 InputEvent::ToggleMultiple => {
309 state.show_multiple = !state.show_multiple;
310 }
311
312 InputEvent::ZoomIn => {
313 state.zoom_level = (state.zoom_level * 1.5).min(10.0);
314 }
315
316 InputEvent::ZoomOut => {
317 state.zoom_level = (state.zoom_level / 1.5).max(0.1);
318 }
319
320 InputEvent::ShowOptions => {
321 state.show_options = !state.show_options;
322 }
323
324 InputEvent::SaveSettings => {
325 config.traffic_format = state.traffic_unit.to_string().to_string();
327 config.data_format = state.data_unit.to_string().to_string();
328 config.multiple_devices = state.show_multiple;
329 config.max_incoming = state.max_incoming;
330 config.max_outgoing = state.max_outgoing;
331
332 if let Err(e) = config.save() {
334 eprintln!("Failed to save settings: {e}");
335 }
336 }
337
338 InputEvent::ReloadSettings => {
339 if let Ok(new_config) = Config::load() {
341 *config = new_config;
342 state.traffic_unit = config.get_traffic_unit();
344 state.data_unit = config.get_data_unit();
345 state.show_multiple = config.multiple_devices;
346 state.max_incoming = config.max_incoming;
347 state.max_outgoing = config.max_outgoing;
348 }
349 }
350
351 InputEvent::IncreaseRefresh
352 | InputEvent::DecreaseRefresh
353 | InputEvent::IncreaseAverage
354 | InputEvent::DecreaseAverage => {
355 }
357
358 InputEvent::NextPanel
359 | InputEvent::PrevPanel
360 | InputEvent::NextItem
361 | InputEvent::PrevItem => {
362 }
364
365 InputEvent::Unknown => {
366 }
368 }
369
370 Ok(false)
371}
372
373fn draw_ui(
374 f: &mut Frame,
375 state: &DisplayState,
376 stats_calculators: &HashMap<String, StatsCalculator>,
377 config: &Config,
378) {
379 if state.show_multiple {
380 draw_multiple_devices_view(f, state, stats_calculators);
381 } else {
382 draw_single_device_view(f, state, stats_calculators, config);
383 }
384}
385
386fn draw_single_device_view(
387 f: &mut Frame,
388 state: &DisplayState,
389 stats_calculators: &HashMap<String, StatsCalculator>,
390 config: &Config,
391) {
392 let chunks = Layout::default()
393 .direction(Direction::Vertical)
394 .margin(1)
395 .constraints([
396 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
400 .split(f.area());
401
402 if let Some(device) = state.devices.get(state.current_device_index) {
404 draw_header(f, chunks[0], &device.name, state.paused);
406
407 if state.show_graphs {
409 draw_placeholder_graphs(f, chunks[1], device, stats_calculators, state);
411 } else {
412 draw_placeholder_stats(f, chunks[1], device, stats_calculators, state);
414 }
415
416 draw_status_line(f, chunks[2], state);
418
419 if state.show_options {
421 draw_options_overlay(f, f.area(), state, config);
422 }
423 }
424}
425
426fn draw_multiple_devices_view(
427 f: &mut Frame,
428 state: &DisplayState,
429 stats_calculators: &HashMap<String, StatsCalculator>,
430) {
431 let chunks = Layout::default()
432 .direction(Direction::Vertical)
433 .margin(1)
434 .constraints([
435 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
439 .split(f.area());
440
441 let header_text = if state.paused {
443 "netwatch - Multiple Devices View [PAUSED]"
444 } else {
445 "netwatch - Multiple Devices View"
446 };
447
448 let header = Paragraph::new(header_text)
449 .style(
450 Style::default()
451 .fg(Color::Cyan)
452 .add_modifier(Modifier::BOLD),
453 )
454 .block(Block::default().borders(Borders::ALL));
455 f.render_widget(header, chunks[0]);
456
457 if state.devices.is_empty() {
459 let no_devices = Paragraph::new("No network devices found")
460 .block(Block::default().borders(Borders::ALL).title("Devices"))
461 .style(Style::default().fg(Color::Red));
462 f.render_widget(no_devices, chunks[1]);
463 } else {
464 draw_devices_table(f, chunks[1], state, stats_calculators);
465 }
466
467 draw_multiple_devices_status_line(f, chunks[2], state);
469}
470
471fn draw_devices_table(
472 f: &mut Frame,
473 area: ratatui::layout::Rect,
474 state: &DisplayState,
475 stats_calculators: &HashMap<String, StatsCalculator>,
476) {
477 let mut table_content = String::new();
479 table_content.push_str("┌─────────────────┬──────────────┬──────────────┬──────────────┬──────────────┬─────────────────┐\n");
480 table_content.push_str("│ Device │ Current │ Current │ Average │ Average │ Total │\n");
481 table_content.push_str("│ │ In (↓) │ Out (↑) │ In (↓) │ Out (↑) │ In/Out │\n");
482 table_content.push_str("├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────┼─────────────────┤\n");
483
484 for (i, device) in state.devices.iter().enumerate() {
486 let is_selected = i == state.current_device_index;
487 let prefix = if is_selected { "►" } else { " " };
488
489 if let Some(calculator) = stats_calculators.get(&device.name) {
490 let (current_in, current_out) = calculator.current_speed();
491 let (avg_in, avg_out) = calculator.average_speed();
492 let (total_in, total_out) = calculator.total_bytes();
493
494 table_content.push_str(&format!(
495 "│{} {:13} │ {:>11}/s │ {:>11}/s │ {:>11}/s │ {:>11}/s │ {:>7}/{:<7} │\n",
496 prefix,
497 truncate_device_name(&device.name, 13),
498 format_bytes_short(current_in),
499 format_bytes_short(current_out),
500 format_bytes_short(avg_in),
501 format_bytes_short(avg_out),
502 format_bytes_short(total_in),
503 format_bytes_short(total_out)
504 ));
505 } else {
506 table_content.push_str(&format!(
507 "│{} {:13} │ {:>12} │ {:>12} │ {:>12} │ {:>12} │ {:>15} │\n",
508 prefix,
509 truncate_device_name(&device.name, 13),
510 "No data",
511 "No data",
512 "No data",
513 "No data",
514 "No data"
515 ));
516 }
517 }
518
519 table_content.push_str("└─────────────────┴──────────────┴──────────────┴──────────────┴──────────────┴─────────────────┘\n");
520 table_content.push_str(
521 "\nUse arrow keys to select device, Enter to view details, 'r' to reset selected device",
522 );
523
524 let devices_table = Paragraph::new(table_content)
525 .block(
526 Block::default()
527 .borders(Borders::ALL)
528 .title("Network Devices"),
529 )
530 .style(Style::default().fg(Color::White));
531
532 f.render_widget(devices_table, area);
533}
534
535fn draw_multiple_devices_status_line(
536 f: &mut Frame,
537 area: ratatui::layout::Rect,
538 _state: &DisplayState,
539) {
540 let help_text = vec![Line::from(vec![
541 Span::styled("Press ", Style::default().fg(Color::Gray)),
542 Span::styled(
543 "'q'",
544 Style::default()
545 .fg(Color::Yellow)
546 .add_modifier(Modifier::BOLD),
547 ),
548 Span::styled(" to quit, ", Style::default().fg(Color::Gray)),
549 Span::styled(
550 "arrows",
551 Style::default()
552 .fg(Color::Yellow)
553 .add_modifier(Modifier::BOLD),
554 ),
555 Span::styled(" to select device, ", Style::default().fg(Color::Gray)),
556 Span::styled(
557 "Enter",
558 Style::default()
559 .fg(Color::Yellow)
560 .add_modifier(Modifier::BOLD),
561 ),
562 Span::styled(" for details, ", Style::default().fg(Color::Gray)),
563 Span::styled(
564 "'r'",
565 Style::default()
566 .fg(Color::Yellow)
567 .add_modifier(Modifier::BOLD),
568 ),
569 Span::styled(" to reset", Style::default().fg(Color::Gray)),
570 ])];
571
572 let help = Paragraph::new(help_text)
573 .block(Block::default().borders(Borders::ALL))
574 .style(Style::default().fg(Color::Gray));
575
576 f.render_widget(help, area);
577}
578
579fn draw_header(f: &mut Frame, area: ratatui::layout::Rect, device_name: &str, paused: bool) {
580 let status = if paused { " [PAUSED]" } else { "" };
581 let title = format!("netwatch - Network Traffic Monitor [{device_name}]{status}");
582
583 let header = Paragraph::new(title)
584 .style(
585 Style::default()
586 .fg(Color::Cyan)
587 .add_modifier(Modifier::BOLD),
588 )
589 .block(Block::default().borders(Borders::ALL));
590
591 f.render_widget(header, area);
592}
593
594fn draw_placeholder_graphs(
595 f: &mut Frame,
596 area: ratatui::layout::Rect,
597 device: &Device,
598 stats_calculators: &HashMap<String, StatsCalculator>,
599 state: &DisplayState,
600) {
601 if let Some(calculator) = stats_calculators.get(&device.name) {
602 let chunks = Layout::default()
604 .direction(Direction::Vertical)
605 .constraints([
606 Constraint::Length(6), Constraint::Min(10), ])
609 .split(area);
610
611 draw_stats_summary(f, chunks[0], device, calculator);
613
614 draw_traffic_graphs_internal(f, chunks[1], calculator, state);
616 } else {
617 let no_data = Paragraph::new("No statistics available for this device")
618 .block(
619 Block::default()
620 .borders(Borders::ALL)
621 .title("Traffic Monitor"),
622 )
623 .style(Style::default().fg(Color::Red));
624 f.render_widget(no_data, area);
625 }
626}
627
628fn draw_stats_summary(
629 f: &mut Frame,
630 area: ratatui::layout::Rect,
631 device: &Device,
632 calculator: &StatsCalculator,
633) {
634 let (current_in, current_out) = calculator.current_speed();
635 let (avg_in, avg_out) = calculator.average_speed();
636 let (_min_in, _min_out) = calculator.min_speed();
637 let (max_in, max_out) = calculator.max_speed();
638
639 let stats_text = format!(
640 "📶 Device: {} Current Traffic: 📥 {}/s down 📤 {}/s up\nAverages: 📊 {}/s down 📊 {}/s up Peak: 📈 {}/s down 📈 {}/s up",
641 device.name,
642 format_bytes(current_in),
643 format_bytes(current_out),
644 format_bytes(avg_in),
645 format_bytes(avg_out),
646 format_bytes(max_in),
647 format_bytes(max_out)
648 );
649
650 let stats_widget = Paragraph::new(stats_text)
651 .block(Block::default().borders(Borders::ALL).title("Statistics"))
652 .style(Style::default().fg(Color::Cyan));
653
654 f.render_widget(stats_widget, area);
655}
656
657pub fn draw_traffic_graphs(
658 f: &mut Frame,
659 area: ratatui::layout::Rect,
660 device_name: &str,
661 calculator: &StatsCalculator,
662 dashboard_state: &crate::dashboard::DashboardState,
663) {
664 let state = DisplayState {
666 current_device_index: dashboard_state.current_device_index,
667 devices: dashboard_state.devices.clone(),
668 show_multiple: false,
669 show_graphs: true,
670 paused: dashboard_state.paused,
671 traffic_unit: dashboard_state.traffic_unit.clone(),
672 data_unit: dashboard_state.data_unit.clone(),
673 max_incoming: dashboard_state.max_incoming,
674 max_outgoing: dashboard_state.max_outgoing,
675 zoom_level: dashboard_state.zoom_level,
676 show_options: false,
677 settings_message: None,
678 };
679
680 draw_traffic_graphs_with_device_name(f, area, device_name, calculator, &state);
681}
682
683fn draw_traffic_graphs_with_device_name(
684 f: &mut Frame,
685 area: ratatui::layout::Rect,
686 device_name: &str,
687 calculator: &StatsCalculator,
688 state: &DisplayState,
689) {
690 let chunks = Layout::default()
692 .direction(Direction::Horizontal)
693 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
694 .split(area);
695
696 let graph_data_in = calculator.graph_data_in();
698 let graph_data_out = calculator.graph_data_out();
699
700 draw_single_graph_with_device(
702 f,
703 chunks[0],
704 &format!("{device_name} - Incoming"),
705 graph_data_in,
706 Color::Green,
707 calculator.max_speed().0, state,
709 );
710
711 draw_single_graph_with_device(
713 f,
714 chunks[1],
715 &format!("{device_name} - Outgoing"),
716 graph_data_out,
717 Color::Red,
718 calculator.max_speed().1, state,
720 );
721}
722
723fn draw_traffic_graphs_internal(
724 f: &mut Frame,
725 area: ratatui::layout::Rect,
726 calculator: &StatsCalculator,
727 state: &DisplayState,
728) {
729 let chunks = Layout::default()
731 .direction(Direction::Horizontal)
732 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
733 .split(area);
734
735 let graph_data_in = calculator.graph_data_in();
737 let graph_data_out = calculator.graph_data_out();
738
739 draw_single_graph(
741 f,
742 chunks[0],
743 "Incoming Traffic",
744 graph_data_in,
745 Color::Green,
746 calculator.max_speed().0, state,
748 );
749
750 draw_single_graph(
752 f,
753 chunks[1],
754 "Outgoing Traffic",
755 graph_data_out,
756 Color::Red,
757 calculator.max_speed().1, state,
759 );
760}
761
762fn draw_single_graph_with_device(
763 f: &mut Frame,
764 area: ratatui::layout::Rect,
765 title: &str,
766 data: &std::collections::VecDeque<(f64, f64)>,
767 color: Color,
768 max_value: u64,
769 state: &DisplayState,
770) {
771 if data.is_empty() {
772 let no_data = Paragraph::new("Collecting data...")
773 .block(Block::default().borders(Borders::ALL).title(title))
774 .style(Style::default().fg(Color::Yellow));
775 f.render_widget(no_data, area);
776 return;
777 }
778
779 if data.len() < 2 {
780 let waiting = Paragraph::new(format!("Waiting for more data... ({})", data.len()))
781 .block(Block::default().borders(Borders::ALL).title(title))
782 .style(Style::default().fg(Color::Yellow));
783 f.render_widget(waiting, area);
784 return;
785 }
786
787 let min_x = 0.0; let max_x = 60.0; let data_max = data
793 .iter()
794 .map(|(_, y)| *y)
795 .filter(|y| y.is_finite() && *y >= 0.0)
796 .fold(0.0, f64::max);
797 let actual_max = if data_max > 0.0 {
798 data_max as u64
799 } else if max_value > 0 {
800 max_value
801 } else {
802 1024 };
804
805 let base_max_y = get_network_capacity_scale(actual_max) as f64;
807 let max_y = if state.zoom_level > 0.0 && state.zoom_level.is_finite() {
808 base_max_y / state.zoom_level } else {
810 base_max_y };
812
813 let chart_data: Vec<(f64, f64)> = data
815 .iter()
816 .cloned()
817 .filter(|(x, y)| x.is_finite() && y.is_finite() && *x >= 0.0 && *y >= 0.0)
818 .collect();
819 let mut chart_data = chart_data;
820
821 if chart_data.is_empty() {
823 let waiting = Paragraph::new("Waiting for valid data...")
824 .block(Block::default().borders(Borders::ALL).title(title))
825 .style(Style::default().fg(Color::Yellow));
826 f.render_widget(waiting, area);
827 return;
828 }
829
830 chart_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let dataset = Dataset::default()
834 .name(title)
835 .marker(ratatui::symbols::Marker::Braille)
836 .graph_type(GraphType::Line)
837 .style(Style::default().fg(color))
838 .data(&chart_data);
839
840 let chart = Chart::new(vec![dataset])
842 .block(Block::default().borders(Borders::ALL).title(format!(
843 "{} (Max: {}) - Use ↑/↓ to switch devices",
844 title,
845 format_bytes(max_value)
846 )))
847 .x_axis(
848 Axis::default()
849 .title("Time")
850 .style(Style::default().fg(Color::Gray))
851 .bounds([min_x, max_x])
852 .labels(vec!["Now", "30s ago", "1 min ago"]),
853 )
854 .y_axis(
855 Axis::default()
856 .title("Speed")
857 .style(Style::default().fg(Color::Gray))
858 .bounds([0.0, max_y])
859 .labels(create_smart_y_labels(max_y)),
860 );
861
862 if area.width < 20 || area.height < 8 {
864 draw_ascii_graph_with_device(f, area, title, data, color, max_value);
865 } else {
866 f.render_widget(chart, area);
867 }
868}
869
870fn draw_single_graph(
871 f: &mut Frame,
872 area: ratatui::layout::Rect,
873 title: &str,
874 data: &std::collections::VecDeque<(f64, f64)>,
875 color: Color,
876 max_value: u64,
877 state: &DisplayState,
878) {
879 if data.is_empty() {
880 let no_data = Paragraph::new("Collecting data...")
881 .block(Block::default().borders(Borders::ALL).title(title))
882 .style(Style::default().fg(Color::Yellow));
883 f.render_widget(no_data, area);
884 return;
885 }
886
887 if data.len() < 2 {
888 let waiting = Paragraph::new(format!("Waiting for more data... ({})", data.len()))
889 .block(Block::default().borders(Borders::ALL).title(title))
890 .style(Style::default().fg(Color::Yellow));
891 f.render_widget(waiting, area);
892 return;
893 }
894
895 let min_x = 0.0; let max_x = 60.0; let data_max = data
901 .iter()
902 .map(|(_, y)| *y)
903 .filter(|y| y.is_finite() && *y >= 0.0)
904 .fold(0.0, f64::max);
905 let actual_max = if data_max > 0.0 {
906 data_max as u64
907 } else if max_value > 0 {
908 max_value
909 } else {
910 1024 };
912
913 let base_max_y = get_network_capacity_scale(actual_max) as f64;
915 let max_y = if state.zoom_level > 0.0 && state.zoom_level.is_finite() {
916 base_max_y / state.zoom_level } else {
918 base_max_y };
920
921 let chart_data: Vec<(f64, f64)> = data
923 .iter()
924 .cloned()
925 .filter(|(x, y)| x.is_finite() && y.is_finite() && *x >= 0.0 && *y >= 0.0)
926 .collect();
927 let mut chart_data = chart_data;
928
929 if chart_data.is_empty() {
931 let waiting = Paragraph::new("Waiting for valid data...")
932 .block(Block::default().borders(Borders::ALL).title(title))
933 .style(Style::default().fg(Color::Yellow));
934 f.render_widget(waiting, area);
935 return;
936 }
937
938 chart_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let dataset = Dataset::default()
942 .name(title)
943 .marker(ratatui::symbols::Marker::Braille)
944 .graph_type(GraphType::Line)
945 .style(Style::default().fg(color))
946 .data(&chart_data);
947
948 let chart = Chart::new(vec![dataset])
950 .block(Block::default().borders(Borders::ALL).title(format!(
951 "{} (Max: {}) - Use ↑/↓ to switch devices",
952 title,
953 format_bytes(max_value)
954 )))
955 .x_axis(
956 Axis::default()
957 .title("Time")
958 .style(Style::default().fg(Color::Gray))
959 .bounds([min_x, max_x])
960 .labels(vec!["Now", "30s ago", "1 min ago"]),
961 )
962 .y_axis(
963 Axis::default()
964 .title("Speed")
965 .style(Style::default().fg(Color::Gray))
966 .bounds([0.0, max_y])
967 .labels(create_smart_y_labels(max_y)),
968 );
969
970 if area.width < 20 || area.height < 8 {
972 draw_ascii_graph(f, area, title, data, color, max_value);
973 } else {
974 f.render_widget(chart, area);
975 }
976}
977
978fn draw_ascii_graph_with_device(
979 f: &mut Frame,
980 area: ratatui::layout::Rect,
981 title: &str,
982 data: &std::collections::VecDeque<(f64, f64)>,
983 color: Color,
984 max_value: u64,
985) {
986 if data.is_empty() {
987 let no_data = Paragraph::new("No data available")
988 .block(Block::default().borders(Borders::ALL).title(title))
989 .style(Style::default().fg(Color::Yellow));
990 f.render_widget(no_data, area);
991 return;
992 }
993
994 let graph_height = (area.height.saturating_sub(3)) as usize; let graph_width = (area.width.saturating_sub(2)) as usize; if graph_height == 0 || graph_width == 0 {
999 let too_small = Paragraph::new("Area too small for graph")
1000 .block(Block::default().borders(Borders::ALL).title(title))
1001 .style(Style::default().fg(Color::Yellow));
1002 f.render_widget(too_small, area);
1003 return;
1004 }
1005
1006 let data_max = data
1008 .iter()
1009 .map(|(_, y)| *y)
1010 .filter(|y| y.is_finite() && *y >= 0.0)
1011 .fold(0.0, f64::max);
1012 let scale_max = if data_max > 0.0 {
1013 data_max
1014 } else {
1015 max_value as f64
1016 };
1017
1018 let mut graph_lines = vec![String::new(); graph_height];
1020 let step = if data.len() > graph_width {
1021 data.len() / graph_width
1022 } else {
1023 1
1024 };
1025
1026 for (i, chunk) in data.iter().step_by(step).take(graph_width).enumerate() {
1027 let (_, value) = chunk;
1028 let normalized = if scale_max > 0.0 {
1029 (value / scale_max * graph_height as f64) as usize
1030 } else {
1031 0
1032 };
1033 let bar_height = normalized.min(graph_height);
1034
1035 for (row, line) in graph_lines.iter_mut().enumerate().take(graph_height) {
1037 let char_to_use = if (graph_height - row - 1) < bar_height {
1038 match ((graph_height - row - 1) * 8) % 8 {
1039 0..=1 => "█", 2..=3 => "▇", 4..=5 => "▆", 6..=7 => "▅", _ => "█",
1044 }
1045 } else {
1046 " "
1047 };
1048
1049 if i < line.len() {
1050 line.replace_range(i..=i, char_to_use);
1051 } else {
1052 while line.len() < i {
1053 line.push(' ');
1054 }
1055 line.push_str(char_to_use);
1056 }
1057 }
1058 }
1059
1060 let current_val = data.back().map(|(_, v)| *v).unwrap_or(0.0);
1062 let info_line = format!(
1063 "Current: {}/s | Max: {}/s",
1064 format_bytes(current_val as u64),
1065 format_bytes(scale_max as u64)
1066 );
1067
1068 let mut all_lines = graph_lines;
1070 all_lines.push(String::new()); all_lines.push(info_line);
1072
1073 let graph_text: Vec<ratatui::text::Line> = all_lines
1074 .into_iter()
1075 .map(|line| {
1076 ratatui::text::Line::from(ratatui::text::Span::styled(
1077 line,
1078 Style::default().fg(color),
1079 ))
1080 })
1081 .collect();
1082
1083 let ascii_graph = Paragraph::new(graph_text)
1084 .block(
1085 Block::default()
1086 .borders(Borders::ALL)
1087 .title(format!("📊 {title} (ASCII) - Use ↑/↓ to switch devices")),
1088 )
1089 .style(Style::default().fg(color));
1090
1091 f.render_widget(ascii_graph, area);
1092}
1093
1094fn draw_ascii_graph(
1095 f: &mut Frame,
1096 area: ratatui::layout::Rect,
1097 title: &str,
1098 data: &std::collections::VecDeque<(f64, f64)>,
1099 color: Color,
1100 max_value: u64,
1101) {
1102 if data.is_empty() {
1103 let no_data = Paragraph::new("No data available")
1104 .block(Block::default().borders(Borders::ALL).title(title))
1105 .style(Style::default().fg(Color::Yellow));
1106 f.render_widget(no_data, area);
1107 return;
1108 }
1109
1110 let graph_height = (area.height.saturating_sub(3)) as usize; let graph_width = (area.width.saturating_sub(2)) as usize; if graph_height == 0 || graph_width == 0 {
1115 let too_small = Paragraph::new("Area too small for graph")
1116 .block(Block::default().borders(Borders::ALL).title(title))
1117 .style(Style::default().fg(Color::Yellow));
1118 f.render_widget(too_small, area);
1119 return;
1120 }
1121
1122 let data_max = data
1124 .iter()
1125 .map(|(_, y)| *y)
1126 .filter(|y| y.is_finite() && *y >= 0.0)
1127 .fold(0.0, f64::max);
1128 let scale_max = if data_max > 0.0 {
1129 data_max
1130 } else {
1131 max_value as f64
1132 };
1133
1134 let mut graph_lines = vec![String::new(); graph_height];
1136 let step = if data.len() > graph_width {
1137 data.len() / graph_width
1138 } else {
1139 1
1140 };
1141
1142 for (i, chunk) in data.iter().step_by(step).take(graph_width).enumerate() {
1143 let (_, value) = chunk;
1144 let normalized = if scale_max > 0.0 {
1145 (value / scale_max * graph_height as f64) as usize
1146 } else {
1147 0
1148 };
1149 let bar_height = normalized.min(graph_height);
1150
1151 for (row, line) in graph_lines.iter_mut().enumerate().take(graph_height) {
1153 let char_to_use = if (graph_height - row - 1) < bar_height {
1154 match ((graph_height - row - 1) * 8) % 8 {
1155 0..=1 => "█", 2..=3 => "▇", 4..=5 => "▆", 6..=7 => "▅", _ => "█",
1160 }
1161 } else {
1162 " "
1163 };
1164
1165 if i < line.len() {
1166 line.replace_range(i..=i, char_to_use);
1167 } else {
1168 while line.len() < i {
1169 line.push(' ');
1170 }
1171 line.push_str(char_to_use);
1172 }
1173 }
1174 }
1175
1176 let current_val = data.back().map(|(_, v)| *v).unwrap_or(0.0);
1178 let info_line = format!(
1179 "Current: {}/s | Max: {}/s",
1180 format_bytes(current_val as u64),
1181 format_bytes(scale_max as u64)
1182 );
1183
1184 let mut all_lines = graph_lines;
1186 all_lines.push(String::new()); all_lines.push(info_line);
1188
1189 let graph_text: Vec<ratatui::text::Line> = all_lines
1190 .into_iter()
1191 .map(|line| {
1192 ratatui::text::Line::from(ratatui::text::Span::styled(
1193 line,
1194 Style::default().fg(color),
1195 ))
1196 })
1197 .collect();
1198
1199 let ascii_graph = Paragraph::new(graph_text)
1200 .block(
1201 Block::default()
1202 .borders(Borders::ALL)
1203 .title(format!("📊 {title} (ASCII) - Use ↑/↓ to switch devices")),
1204 )
1205 .style(Style::default().fg(color));
1206
1207 f.render_widget(ascii_graph, area);
1208}
1209
1210fn draw_placeholder_stats(
1211 f: &mut Frame,
1212 area: ratatui::layout::Rect,
1213 device: &Device,
1214 stats_calculators: &HashMap<String, StatsCalculator>,
1215 state: &DisplayState,
1216) {
1217 if let Some(calculator) = stats_calculators.get(&device.name) {
1218 draw_detailed_stats_table(
1219 f,
1220 area,
1221 device,
1222 calculator,
1223 &state.traffic_unit,
1224 &state.data_unit,
1225 );
1226 } else {
1227 let no_data = Paragraph::new("No statistics available for this device")
1228 .block(
1229 Block::default()
1230 .borders(Borders::ALL)
1231 .title("Statistics Table"),
1232 )
1233 .style(Style::default().fg(Color::Red));
1234 f.render_widget(no_data, area);
1235 }
1236}
1237
1238fn draw_detailed_stats_table(
1239 f: &mut Frame,
1240 area: ratatui::layout::Rect,
1241 device: &Device,
1242 calculator: &StatsCalculator,
1243 traffic_unit: &TrafficUnit,
1244 data_unit: &DataUnit,
1245) {
1246 let (current_in, current_out) = calculator.current_speed();
1248 let (avg_in, avg_out) = calculator.average_speed();
1249 let (min_in, min_out) = calculator.min_speed();
1250 let (max_in, max_out) = calculator.max_speed();
1251 let (total_bytes_in, total_bytes_out) = calculator.total_bytes();
1252 let (total_packets_in, total_packets_out) = calculator.total_packets();
1253
1254 let table_content = format!(
1256 "Device: {}\n\
1257 \n\
1258 ┌─────────────────────────────┬──────────────────┬──────────────────┐\n\
1259 │ Statistic │ Incoming │ Outgoing │\n\
1260 ├─────────────────────────────┼──────────────────┼──────────────────┤\n\
1261 │ Current Speed │ {:>15}/s │ {:>15}/s │\n\
1262 │ Average Speed │ {:>15}/s │ {:>15}/s │\n\
1263 │ Minimum Speed │ {:>15}/s │ {:>15}/s │\n\
1264 │ Maximum Speed │ {:>15}/s │ {:>15}/s │\n\
1265 ├─────────────────────────────┼──────────────────┼──────────────────┤\n\
1266 │ Total Bytes │ {:>16} │ {:>16} │\n\
1267 │ Total Packets │ {:>16} │ {:>16} │\n\
1268 └─────────────────────────────┴──────────────────┴──────────────────┘\n\
1269 \n\
1270 Network Interface Statistics - Press 'g' to toggle back to graphs",
1271 device.name,
1272 format_bytes_with_unit(current_in, traffic_unit),
1273 format_bytes_with_unit(current_out, traffic_unit),
1274 format_bytes_with_unit(avg_in, traffic_unit),
1275 format_bytes_with_unit(avg_out, traffic_unit),
1276 format_bytes_with_unit(min_in, traffic_unit),
1277 format_bytes_with_unit(min_out, traffic_unit),
1278 format_bytes_with_unit(max_in, traffic_unit),
1279 format_bytes_with_unit(max_out, traffic_unit),
1280 format_bytes_with_unit(total_bytes_in, data_unit),
1281 format_bytes_with_unit(total_bytes_out, data_unit),
1282 format_number(total_packets_in),
1283 format_number(total_packets_out),
1284 );
1285
1286 let stats_table = Paragraph::new(table_content)
1287 .block(
1288 Block::default()
1289 .borders(Borders::ALL)
1290 .title("Detailed Network Statistics"),
1291 )
1292 .style(Style::default().fg(Color::Cyan));
1293
1294 f.render_widget(stats_table, area);
1295}
1296
1297fn draw_status_line(f: &mut Frame, area: ratatui::layout::Rect, _state: &DisplayState) {
1298 let help_text = vec![Line::from(vec![
1299 Span::styled("Press ", Style::default().fg(Color::Gray)),
1300 Span::styled(
1301 "'q'",
1302 Style::default()
1303 .fg(Color::Yellow)
1304 .add_modifier(Modifier::BOLD),
1305 ),
1306 Span::styled(" to quit, ", Style::default().fg(Color::Gray)),
1307 Span::styled(
1308 "arrows",
1309 Style::default()
1310 .fg(Color::Yellow)
1311 .add_modifier(Modifier::BOLD),
1312 ),
1313 Span::styled(" to switch devices, ", Style::default().fg(Color::Gray)),
1314 Span::styled(
1315 "'r'",
1316 Style::default()
1317 .fg(Color::Yellow)
1318 .add_modifier(Modifier::BOLD),
1319 ),
1320 Span::styled(" to reset, ", Style::default().fg(Color::Gray)),
1321 Span::styled(
1322 "space",
1323 Style::default()
1324 .fg(Color::Yellow)
1325 .add_modifier(Modifier::BOLD),
1326 ),
1327 Span::styled(" to pause", Style::default().fg(Color::Gray)),
1328 ])];
1329
1330 let help = Paragraph::new(help_text)
1331 .block(Block::default().borders(Borders::ALL))
1332 .style(Style::default().fg(Color::Gray));
1333
1334 f.render_widget(help, area);
1335}
1336
1337fn format_bytes(bytes: u64) -> String {
1339 format_bytes_with_unit(bytes, &TrafficUnit::HumanByte)
1340}
1341
1342fn format_bytes_with_unit(bytes: u64, unit: &TrafficUnit) -> String {
1344 match unit {
1345 TrafficUnit::HumanBit => {
1346 let bits = bytes * 8;
1347 format_human_readable(bits, &["bit", "Kbit", "Mbit", "Gbit", "Tbit"], 1000.0)
1348 }
1349 TrafficUnit::HumanByte => {
1350 format_human_readable(bytes, &["B", "KB", "MB", "GB", "TB"], 1024.0)
1351 }
1352 TrafficUnit::Bit => format!("{} bit", bytes * 8),
1353 TrafficUnit::Byte => format!("{bytes} B"),
1354 TrafficUnit::KiloBit => format!("{:.2} kbit", (bytes * 8) as f64 / 1000.0),
1355 TrafficUnit::KiloByte => format!("{:.2} KB", bytes as f64 / 1024.0),
1356 TrafficUnit::MegaBit => format!("{:.2} Mbit", (bytes * 8) as f64 / 1_000_000.0),
1357 TrafficUnit::MegaByte => format!("{:.2} MB", bytes as f64 / 1_048_576.0),
1358 TrafficUnit::GigaBit => format!("{:.2} Gbit", (bytes * 8) as f64 / 1_000_000_000.0),
1359 TrafficUnit::GigaByte => format!("{:.2} GB", bytes as f64 / 1_073_741_824.0),
1360 }
1361}
1362
1363fn format_human_readable(value: u64, units: &[&str], divisor: f64) -> String {
1364 let mut size = value as f64;
1365 let mut unit_index = 0;
1366
1367 while size >= divisor && unit_index < units.len() - 1 {
1368 size /= divisor;
1369 unit_index += 1;
1370 }
1371
1372 if size >= 100.0 {
1373 format!("{:.0} {}", size, units[unit_index])
1374 } else if size >= 10.0 {
1375 format!("{:.1} {}", size, units[unit_index])
1376 } else {
1377 format!("{:.2} {}", size, units[unit_index])
1378 }
1379}
1380
1381fn format_number(num: u64) -> String {
1383 let num_str = num.to_string();
1384 let mut result = String::new();
1385 let chars: Vec<char> = num_str.chars().collect();
1386
1387 for (i, &ch) in chars.iter().enumerate() {
1388 if i > 0 && (chars.len() - i) % 3 == 0 {
1389 result.push(',');
1390 }
1391 result.push(ch);
1392 }
1393
1394 result
1395}
1396
1397fn format_bytes_short(bytes: u64) -> String {
1399 const UNITS: &[&str] = &["B", "K", "M", "G", "T"];
1400 let mut size = bytes as f64;
1401 let mut unit_index = 0;
1402
1403 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
1404 size /= 1024.0;
1405 unit_index += 1;
1406 }
1407
1408 if size >= 100.0 {
1409 format!("{:.0}{}", size, UNITS[unit_index])
1410 } else if size >= 10.0 {
1411 format!("{:.1}{}", size, UNITS[unit_index])
1412 } else {
1413 format!("{:.2}{}", size, UNITS[unit_index])
1414 }
1415}
1416
1417fn truncate_device_name(name: &str, max_len: usize) -> String {
1419 if name.len() <= max_len {
1420 name.to_string()
1421 } else {
1422 format!("{}...", &name[..max_len.saturating_sub(3)])
1423 }
1424}
1425
1426fn get_network_capacity_scale(actual_max: u64) -> u64 {
1428 let actual_bits = actual_max * 8;
1430
1431 let tiers = vec![
1433 1_000_000, 10_000_000, 100_000_000, 1_000_000_000, 10_000_000_000, 40_000_000_000, 100_000_000_000, ];
1441
1442 for &tier in &tiers {
1444 if actual_bits <= tier {
1445 return tier / 8; }
1447 }
1448
1449 100_000_000_000 / 8
1451}
1452
1453fn create_smart_y_labels(max_y: f64) -> Vec<ratatui::text::Span<'static>> {
1455 let capacity_scale = max_y as u64; let labels = vec![
1460 "0 B/s".into(), format!("{}/s", format_bytes(capacity_scale / 4)).into(), format!("{}/s", format_bytes(capacity_scale / 2)).into(), format!("{}/s", format_bytes(capacity_scale * 3 / 4)).into(), format!("{}/s", format_bytes(capacity_scale)).into(), ];
1466
1467 labels
1468}
1469
1470fn draw_options_overlay(
1471 f: &mut Frame,
1472 area: ratatui::layout::Rect,
1473 state: &DisplayState,
1474 config: &Config,
1475) {
1476 let popup_area = centered_rect(60, 70, area);
1478
1479 let clear = Block::default().style(Style::default().bg(Color::Black));
1481 f.render_widget(clear, popup_area);
1482
1483 let options_text = format!(
1485 "═══════════════════ OPTIONS ═══════════════════\n\
1486 \n\
1487 Current Settings:\n\
1488 \n\
1489 • Traffic Unit: {:?}\n\
1490 • Data Unit: {:?}\n\
1491 • Show Graphs: {}\n\
1492 • Multiple View: {}\n\
1493 • Paused: {}\n\
1494 • Zoom Level: {:.1}x\n\
1495 • Max Incoming: {} (0 = auto)\n\
1496 • Max Outgoing: {} (0 = auto)\n\
1497 • Average Window: {}s\n\
1498 • Refresh Rate: {}ms\n\
1499 • Devices: {}\n\
1500 \n\
1501 ──────────── Interactive Controls ───────────────\n\
1502 \n\
1503 You can change settings while this window is open:\n\
1504 \n\
1505 • 'u' - Cycle traffic units (speeds - changes above ↑)\n\
1506 • 'U' - Cycle data units (totals - changes above ↑)\n\
1507 • Space - Pause/resume monitoring\n\
1508 • '+/-' - Zoom graph scale\n\
1509 • '</>' - Slower/Faster refresh rate\n\
1510 • '[/]' - Shorter/Longer average window\n\
1511 • F5 - Save current settings to file\n\
1512 • F6 - Reload settings from file\n\
1513 \n\
1514 ────────── View Controls (when closed) ──────────\n\
1515 \n\
1516 • 'g' - Toggle graphs/stats view\n\
1517 • Enter - Toggle single/multiple view\n\
1518 • Arrow keys - Navigate devices\n\
1519 • 'r' - Reset statistics\n\
1520 \n\
1521 Press F2 or ESC to close this options window\n\
1522 \n\
1523 {}",
1524 state.traffic_unit,
1525 state.data_unit,
1526 if state.show_graphs { "Yes" } else { "No" },
1527 if state.show_multiple { "Yes" } else { "No" },
1528 if state.paused { "Yes" } else { "No" },
1529 state.zoom_level,
1530 state.max_incoming,
1531 state.max_outgoing,
1532 config.average_window,
1533 config.refresh_interval,
1534 config.devices,
1535 state.settings_message.as_deref().unwrap_or("")
1536 );
1537
1538 let options_popup = Paragraph::new(options_text)
1539 .block(
1540 Block::default()
1541 .borders(Borders::ALL)
1542 .title("Options & Settings")
1543 .style(Style::default().fg(Color::Yellow)),
1544 )
1545 .style(Style::default().fg(Color::White).bg(Color::Black));
1546
1547 f.render_widget(options_popup, popup_area);
1548}
1549
1550fn centered_rect(
1552 percent_x: u16,
1553 percent_y: u16,
1554 r: ratatui::layout::Rect,
1555) -> ratatui::layout::Rect {
1556 let popup_layout = Layout::default()
1557 .direction(Direction::Vertical)
1558 .constraints([
1559 Constraint::Percentage((100 - percent_y) / 2),
1560 Constraint::Percentage(percent_y),
1561 Constraint::Percentage((100 - percent_y) / 2),
1562 ])
1563 .split(r);
1564
1565 Layout::default()
1566 .direction(Direction::Horizontal)
1567 .constraints([
1568 Constraint::Percentage((100 - percent_x) / 2),
1569 Constraint::Percentage(percent_x),
1570 Constraint::Percentage((100 - percent_x) / 2),
1571 ])
1572 .split(popup_layout[1])[1]
1573}