1use crate::collectors::config::ConfigCollector;
2use crate::collectors::connections::{Connection, ConnectionCollector, ConnectionTimeline};
3use crate::collectors::geo::GeoCache;
4use crate::collectors::insights::{InsightsCollector, NetworkSnapshot};
5use crate::collectors::whois::WhoisCache;
6use crate::collectors::health::HealthProber;
7use crate::collectors::packets::PacketCollector;
8use crate::collectors::traffic::TrafficCollector;
9use crate::event::{AppEvent, EventHandler};
10use crate::platform::{self, InterfaceInfo};
11use crate::ui;
12use anyhow::Result;
13use crossterm::event::{KeyCode, KeyModifiers};
14use std::collections::{HashMap, HashSet};
15use ratatui::prelude::*;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TimelineWindow {
19 Min1,
20 Min5,
21 Min15,
22 Min30,
23 Hour1,
24}
25
26impl TimelineWindow {
27 pub fn seconds(&self) -> u64 {
28 match self {
29 Self::Min1 => 60,
30 Self::Min5 => 300,
31 Self::Min15 => 900,
32 Self::Min30 => 1800,
33 Self::Hour1 => 3600,
34 }
35 }
36
37 pub fn label(&self) -> &'static str {
38 match self {
39 Self::Min1 => "1m",
40 Self::Min5 => "5m",
41 Self::Min15 => "15m",
42 Self::Min30 => "30m",
43 Self::Hour1 => "1h",
44 }
45 }
46
47 fn next(self) -> Self {
48 match self {
49 Self::Min1 => Self::Min5,
50 Self::Min5 => Self::Min15,
51 Self::Min15 => Self::Min30,
52 Self::Min30 => Self::Hour1,
53 Self::Hour1 => Self::Min1,
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum StreamDirectionFilter {
60 Both,
61 AtoB,
62 BtoA,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Tab {
67 Dashboard,
68 Connections,
69 Interfaces,
70 Packets,
71 Stats,
72 Topology,
73 Timeline,
74 Insights,
75}
76
77pub struct App {
78 pub traffic: TrafficCollector,
79 pub interface_info: Vec<InterfaceInfo>,
80 pub connection_collector: ConnectionCollector,
81 pub config_collector: ConfigCollector,
82 pub health_prober: HealthProber,
83 pub packet_collector: PacketCollector,
84 pub selected_interface: Option<usize>,
85 pub paused: bool,
86 pub current_tab: Tab,
87 pub connection_scroll: usize,
88 pub sort_column: usize,
89 pub packet_scroll: usize,
90 pub packet_selected: Option<u64>,
91 pub packet_follow: bool,
92 pub capture_interface: String,
93 pub stream_view_open: bool,
94 pub stream_view_index: Option<u32>,
95 pub stream_scroll: usize,
96 pub stream_direction_filter: StreamDirectionFilter,
97 pub stream_hex_mode: bool,
98 pub packet_filter_input: bool,
99 pub packet_filter_text: String,
100 pub packet_filter_active: Option<String>,
101 pub export_status: Option<String>,
102 export_status_tick: u32,
103 pub bpf_filter_input: bool,
104 pub bpf_filter_text: String,
105 pub bpf_filter_active: Option<String>,
106 pub stats_scroll: usize,
107 pub show_help: bool,
108 pub help_scroll: usize,
109 pub geo_cache: GeoCache,
110 pub show_geo: bool,
111 pub whois_cache: WhoisCache,
112 pub bookmarks: HashSet<u64>,
113 pub topology_scroll: usize,
114 pub connection_timeline: ConnectionTimeline,
115 pub timeline_scroll: usize,
116 pub timeline_window: TimelineWindow,
117 pub insights_collector: InsightsCollector,
118 pub insights_scroll: usize,
119 insights_tick: u32,
120 info_tick: u32,
121 conn_tick: u32,
122 health_tick: u32,
123}
124
125impl App {
126 fn new() -> Self {
127 let interface_info = platform::collect_interface_info().unwrap_or_default();
128 let mut config_collector = ConfigCollector::new();
129 config_collector.update();
130
131 let capture_interface = Self::pick_capture_interface(&interface_info);
134
135 Self {
136 traffic: TrafficCollector::new(),
137 interface_info,
138 connection_collector: ConnectionCollector::new(),
139 config_collector,
140 health_prober: HealthProber::new(),
141 packet_collector: PacketCollector::new(),
142 selected_interface: None,
143 paused: false,
144 current_tab: Tab::Dashboard,
145 connection_scroll: 0,
146 sort_column: 0,
147 packet_scroll: 0,
148 packet_selected: None,
149 packet_follow: true,
150 capture_interface,
151 stream_view_open: false,
152 stream_view_index: None,
153 stream_scroll: 0,
154 stream_direction_filter: StreamDirectionFilter::Both,
155 stream_hex_mode: false,
156 packet_filter_input: false,
157 packet_filter_text: String::new(),
158 packet_filter_active: None,
159 export_status: None,
160 export_status_tick: 0,
161 bpf_filter_input: false,
162 bpf_filter_text: String::new(),
163 bpf_filter_active: None,
164 stats_scroll: 0,
165 show_help: false,
166 help_scroll: 0,
167 geo_cache: GeoCache::new(),
168 show_geo: true,
169 whois_cache: WhoisCache::new(),
170 bookmarks: HashSet::new(),
171 topology_scroll: 0,
172 connection_timeline: ConnectionTimeline::new(),
173 timeline_scroll: 0,
174 timeline_window: TimelineWindow::Min5,
175 insights_collector: InsightsCollector::new("llama3.2"),
176 insights_scroll: 0,
177 insights_tick: 0,
178 info_tick: 0,
179 conn_tick: 0,
180 health_tick: 0,
181 }
182 }
183
184 fn pick_capture_interface(info: &[InterfaceInfo]) -> String {
185 info.iter()
187 .find(|i| i.is_up && i.ipv4.is_some() && i.name != "lo0" && i.name != "lo")
188 .or_else(|| info.iter().find(|i| i.is_up && i.name != "lo0" && i.name != "lo"))
189 .map(|i| i.name.clone())
190 .unwrap_or_else(|| "en0".to_string())
191 }
192
193 fn capturable_interfaces(&self) -> Vec<String> {
194 self.interface_info
195 .iter()
196 .filter(|i| i.is_up)
197 .map(|i| i.name.clone())
198 .collect()
199 }
200
201 fn cycle_capture_interface(&mut self) {
202 let ifaces = self.capturable_interfaces();
203 if ifaces.is_empty() {
204 return;
205 }
206 let current_idx = ifaces.iter().position(|n| *n == self.capture_interface);
207 let next_idx = match current_idx {
208 Some(i) => (i + 1) % ifaces.len(),
209 None => 0,
210 };
211 self.capture_interface = ifaces[next_idx].clone();
212 }
213
214 fn tick(&mut self) {
215 if self.export_status.is_some() {
217 self.export_status_tick += 1;
218 if self.export_status_tick >= 5 {
219 self.export_status = None;
220 self.export_status_tick = 0;
221 }
222 }
223
224 if self.paused {
225 return;
226 }
227 self.traffic.update();
228
229 self.info_tick += 1;
231 if self.info_tick >= 10 {
232 self.info_tick = 0;
233 if let Ok(info) = platform::collect_interface_info() {
234 self.interface_info = info;
235 }
236 self.config_collector.update();
237 }
238
239 self.conn_tick += 1;
241 if self.conn_tick >= 2 {
242 self.conn_tick = 0;
243 self.connection_collector.update();
244 let conns = self.connection_collector.connections.lock().unwrap();
245 self.connection_timeline.update(&conns);
246 }
247
248 self.health_tick += 1;
250 if self.health_tick >= 5 {
251 self.health_tick = 0;
252 let gateway = self.config_collector.config.gateway.clone();
253 let dns = self.config_collector.config.dns_servers.first().cloned();
254 self.health_prober
255 .probe(gateway.as_deref(), dns.as_deref());
256 }
257
258 self.insights_tick += 1;
260 if self.insights_tick >= 15 {
261 self.insights_tick = 0;
262 self.submit_insights_snapshot();
263 }
264 }
265
266 fn submit_insights_snapshot(&self) {
267 let packets = self.packet_collector.get_packets();
268 if packets.is_empty() {
269 return;
270 }
271 let conns = self.connection_collector.connections.lock().unwrap();
272 let health = self.health_prober.status.lock().unwrap();
273 let rx_rate = crate::ui::widgets::format_bytes_rate(
274 self.traffic.interfaces.iter().map(|i| i.rx_rate).sum(),
275 );
276 let tx_rate = crate::ui::widgets::format_bytes_rate(
277 self.traffic.interfaces.iter().map(|i| i.tx_rate).sum(),
278 );
279 let snapshot = NetworkSnapshot::build(&packets, &conns, &health, &rx_rate, &tx_rate);
280 self.insights_collector.submit_snapshot(snapshot);
281 }
282}
283
284fn parse_addr_parts(addr: &str) -> (Option<String>, Option<String>) {
285 if addr == "*:*" || addr.is_empty() {
286 return (None, None);
287 }
288 if let Some(bracket_end) = addr.rfind("]:") {
289 let ip = addr[1..bracket_end].to_string();
290 let port = addr[bracket_end + 2..].to_string();
291 (Some(ip), Some(port))
292 } else if let Some(colon) = addr.rfind(':') {
293 let ip = &addr[..colon];
294 let port = &addr[colon + 1..];
295 let ip = if ip == "*" { None } else { Some(ip.to_string()) };
296 let port = if port == "*" { None } else { Some(port.to_string()) };
297 (ip, port)
298 } else {
299 (Some(addr.to_string()), None)
300 }
301}
302
303fn build_connection_filter(conn: &Connection) -> String {
304 let (remote_ip, remote_port) = parse_addr_parts(&conn.remote_addr);
305
306 let mut parts = Vec::new();
307
308 let proto = conn.protocol.to_lowercase();
309 if proto == "tcp" || proto == "udp" {
310 parts.push(proto);
311 }
312
313 if let Some(ip) = remote_ip {
314 parts.push(ip);
315 }
316
317 if let Some(port) = remote_port {
318 if port.parse::<u16>().is_ok() {
319 parts.push(format!("port {port}"));
320 }
321 }
322
323 parts.join(" and ")
324}
325
326pub async fn run<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
327 let mut app = App::new();
328 let mut events = EventHandler::new(1000);
329
330 app.traffic.update();
332 app.connection_collector.update();
333 {
334 let conns = app.connection_collector.connections.lock().unwrap();
335 app.connection_timeline.update(&conns);
336 }
337 let gateway = app.config_collector.config.gateway.clone();
338 let dns = app.config_collector.config.dns_servers.first().cloned();
339 app.health_prober
340 .probe(gateway.as_deref(), dns.as_deref());
341
342 loop {
343 terminal.draw(|f| {
344 let area = f.size();
345 match app.current_tab {
346 Tab::Dashboard => ui::dashboard::render(f, &app, area),
347 Tab::Connections => ui::connections::render(f, &app, area),
348 Tab::Interfaces => ui::interfaces::render(f, &app, area),
349 Tab::Packets => ui::packets::render(f, &app, area),
350 Tab::Stats => ui::stats::render(f, &app, area),
351 Tab::Topology => ui::topology::render(f, &app, area),
352 Tab::Timeline => ui::timeline::render(f, &app, area),
353 Tab::Insights => ui::insights::render(f, &app, area),
354 }
355 if app.show_help {
356 ui::help::render(f, &app, area);
357 }
358 })?;
359
360 match events.next().await? {
361 AppEvent::Key(key) => {
362 if app.show_help {
364 match key.code {
365 KeyCode::Char('?') | KeyCode::Esc => {
366 app.show_help = false;
367 app.help_scroll = 0;
368 }
369 KeyCode::Up => {
370 app.help_scroll = app.help_scroll.saturating_sub(1);
371 }
372 KeyCode::Down => {
373 app.help_scroll += 1;
374 }
375 KeyCode::Char('q') => {
376 app.packet_collector.stop_capture();
377 return Ok(());
378 }
379 _ => {}
380 }
381 continue;
382 }
383 if app.packet_filter_input && app.current_tab == Tab::Packets {
385 match key.code {
386 KeyCode::Enter => {
387 app.packet_filter_input = false;
388 if app.packet_filter_text.trim().is_empty() {
389 app.packet_filter_active = None;
390 } else {
391 app.packet_filter_active = Some(app.packet_filter_text.clone());
392 }
393 }
394 KeyCode::Esc => {
395 app.packet_filter_input = false;
396 app.packet_filter_text = app.packet_filter_active.clone().unwrap_or_default();
397 }
398 KeyCode::Backspace => { app.packet_filter_text.pop(); }
399 KeyCode::Char(c) => { app.packet_filter_text.push(c); }
400 _ => {}
401 }
402 continue;
403 }
404 if app.bpf_filter_input && app.current_tab == Tab::Packets {
406 match key.code {
407 KeyCode::Enter => {
408 app.bpf_filter_input = false;
409 if app.bpf_filter_text.trim().is_empty() {
410 app.bpf_filter_active = None;
411 } else {
412 app.bpf_filter_active = Some(app.bpf_filter_text.clone());
413 }
414 }
415 KeyCode::Esc => {
416 app.bpf_filter_input = false;
417 app.bpf_filter_text = app.bpf_filter_active.clone().unwrap_or_default();
418 }
419 KeyCode::Backspace => { app.bpf_filter_text.pop(); }
420 KeyCode::Char(c) => { app.bpf_filter_text.push(c); }
421 _ => {}
422 }
423 continue;
424 }
425 match key.code {
426 KeyCode::Char('q') => {
427 app.packet_collector.stop_capture();
428 return Ok(());
429 }
430 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
431 app.packet_collector.stop_capture();
432 return Ok(());
433 }
434 KeyCode::Char('?') => {
435 app.show_help = !app.show_help;
436 app.help_scroll = 0;
437 }
438 KeyCode::Char('a') if !(app.current_tab == Tab::Packets && app.stream_view_open) => {
439 app.submit_insights_snapshot();
440 }
441 KeyCode::Char('g') => app.show_geo = !app.show_geo,
442 KeyCode::Char('p') => app.paused = !app.paused,
443 KeyCode::Char('r') => {
444 app.traffic.update();
445 if let Ok(info) = platform::collect_interface_info() {
446 app.interface_info = info;
447 }
448 app.connection_collector.update();
449 app.config_collector.update();
450 let gateway = app.config_collector.config.gateway.clone();
451 let dns = app.config_collector.config.dns_servers.first().cloned();
452 app.health_prober
453 .probe(gateway.as_deref(), dns.as_deref());
454 }
455 KeyCode::Char('1') => app.current_tab = Tab::Dashboard,
456 KeyCode::Char('2') => app.current_tab = Tab::Connections,
457 KeyCode::Char('3') => app.current_tab = Tab::Interfaces,
458 KeyCode::Char('4') => app.current_tab = Tab::Packets,
459 KeyCode::Char('5') => app.current_tab = Tab::Stats,
460 KeyCode::Char('6') => app.current_tab = Tab::Topology,
461 KeyCode::Char('7') => app.current_tab = Tab::Timeline,
462 KeyCode::Char('8') => app.current_tab = Tab::Insights,
463 KeyCode::Esc if app.current_tab == Tab::Packets && app.stream_view_open => {
465 app.stream_view_open = false;
466 app.stream_view_index = None;
467 app.stream_scroll = 0;
468 }
469 KeyCode::Char('h') if app.current_tab == Tab::Packets && app.stream_view_open => {
470 app.stream_hex_mode = !app.stream_hex_mode;
471 }
472 KeyCode::Char('a') if app.current_tab == Tab::Packets && app.stream_view_open => {
473 app.stream_direction_filter = StreamDirectionFilter::Both;
474 }
475 KeyCode::Right if app.current_tab == Tab::Packets && app.stream_view_open => {
476 app.stream_direction_filter = StreamDirectionFilter::AtoB;
477 }
478 KeyCode::Left if app.current_tab == Tab::Packets && app.stream_view_open => {
479 app.stream_direction_filter = StreamDirectionFilter::BtoA;
480 }
481 KeyCode::Up if app.current_tab == Tab::Packets && app.stream_view_open => {
482 app.stream_scroll = app.stream_scroll.saturating_sub(1);
483 }
484 KeyCode::Down if app.current_tab == Tab::Packets && app.stream_view_open => {
485 app.stream_scroll += 1;
486 }
487 KeyCode::Char('s') if app.current_tab == Tab::Packets && !app.stream_view_open => {
488 if let Some(sel_id) = app.packet_selected {
489 let packets = app.packet_collector.get_packets();
490 if let Some(pkt) = packets.iter().find(|p| p.id == sel_id) {
491 if pkt.stream_index.is_some() {
492 app.stream_view_open = true;
493 app.stream_view_index = pkt.stream_index;
494 app.stream_scroll = 0;
495 app.stream_direction_filter = StreamDirectionFilter::Both;
496 app.stream_hex_mode = false;
497 }
498 }
499 }
500 }
501 KeyCode::Char('c') if app.current_tab == Tab::Packets => {
502 if app.packet_collector.is_capturing() {
503 app.packet_collector.stop_capture();
504 } else {
505 let iface = app.capture_interface.clone();
506 let bpf = app.bpf_filter_active.as_deref();
507 app.packet_collector.start_capture(&iface, bpf);
508 }
509 }
510 KeyCode::Char('b') if app.current_tab == Tab::Packets && !app.packet_collector.is_capturing() && !app.stream_view_open => {
511 app.bpf_filter_input = true;
512 app.bpf_filter_text = app.bpf_filter_active.clone().unwrap_or_default();
513 }
514 KeyCode::Char('i') if app.current_tab == Tab::Packets => {
515 if !app.packet_collector.is_capturing() {
516 app.cycle_capture_interface();
517 }
518 }
519 KeyCode::Char('x') if app.current_tab == Tab::Packets => {
520 app.packet_collector.clear();
521 app.packet_scroll = 0;
522 app.packet_selected = None;
523 app.bookmarks.clear();
524 }
525 KeyCode::Char('m') if app.current_tab == Tab::Packets && !app.stream_view_open => {
526 if let Some(sel_id) = app.packet_selected {
527 if !app.bookmarks.remove(&sel_id) {
528 app.bookmarks.insert(sel_id);
529 }
530 }
531 }
532 KeyCode::Char('n') if app.current_tab == Tab::Packets && !app.stream_view_open => {
533 let packets = app.packet_collector.get_packets();
535 let current_id = app.packet_selected.unwrap_or(0);
536 if let Some((idx, pkt)) = packets.iter().enumerate()
537 .find(|(_, p)| p.id > current_id && app.bookmarks.contains(&p.id))
538 {
539 app.packet_selected = Some(pkt.id);
540 app.packet_scroll = idx;
541 app.packet_follow = false;
542 }
543 }
544 KeyCode::Char('N') if app.current_tab == Tab::Packets && !app.stream_view_open => {
545 let packets = app.packet_collector.get_packets();
547 let current_id = app.packet_selected.unwrap_or(u64::MAX);
548 if let Some((idx, pkt)) = packets.iter().enumerate().rev()
549 .find(|(_, p)| p.id < current_id && app.bookmarks.contains(&p.id))
550 {
551 app.packet_selected = Some(pkt.id);
552 app.packet_scroll = idx;
553 app.packet_follow = false;
554 }
555 }
556 KeyCode::Char('f') if app.current_tab == Tab::Packets => {
557 app.packet_follow = !app.packet_follow;
558 }
559 KeyCode::Char('w') if app.current_tab == Tab::Packets => {
560 use crate::collectors::packets::{export_pcap, parse_filter, matches_packet};
561 let packets = app.packet_collector.get_packets();
562 let filtered: Vec<_>;
563 let to_export: &[_] = if let Some(ref ft) = app.packet_filter_active {
564 if let Some(expr) = parse_filter(ft) {
565 filtered = packets.iter().filter(|p| matches_packet(&expr, p)).cloned().collect();
566 &filtered
567 } else {
568 &*packets
569 }
570 } else {
571 &*packets
572 };
573 let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
574 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
575 let path = format!("{home}/netwatch_capture_{ts}.pcap");
576 match export_pcap(to_export, &path) {
577 Ok(n) => {
578 app.export_status = Some(format!("Saved {n} packets to {path}"));
579 }
580 Err(e) => {
581 app.export_status = Some(format!("Export failed: {e}"));
582 }
583 }
584 app.export_status_tick = 0;
585 }
586 KeyCode::Char('W') if app.current_tab == Tab::Packets && !app.stream_view_open => {
587 if let Some(sel_id) = app.packet_selected {
589 let packets = app.packet_collector.get_packets();
590 if let Some(pkt) = packets.iter().find(|p| p.id == sel_id) {
591 app.whois_cache.request(&pkt.src_ip);
592 app.whois_cache.request(&pkt.dst_ip);
593 }
594 }
595 }
596 KeyCode::Char('W') if app.current_tab == Tab::Connections => {
597 let mut conns = app.connection_collector.connections.lock().unwrap().clone();
599 match app.sort_column {
600 0 => conns.sort_by(|a, b| a.process_name.as_deref().unwrap_or("").cmp(b.process_name.as_deref().unwrap_or(""))),
601 1 => conns.sort_by(|a, b| a.pid.cmp(&b.pid)),
602 2 => conns.sort_by(|a, b| a.protocol.cmp(&b.protocol)),
603 3 => conns.sort_by(|a, b| a.state.cmp(&b.state)),
604 4 => conns.sort_by(|a, b| a.local_addr.cmp(&b.local_addr)),
605 5 => conns.sort_by(|a, b| a.remote_addr.cmp(&b.remote_addr)),
606 _ => {}
607 }
608 if let Some(conn) = conns.get(app.connection_scroll) {
609 let (remote_ip, _) = parse_addr_parts(&conn.remote_addr);
610 if let Some(ip) = remote_ip {
611 app.whois_cache.request(&ip);
612 }
613 }
614 }
615 KeyCode::Char('s') => {
616 if app.current_tab == Tab::Connections {
617 app.sort_column = (app.sort_column + 1) % 6;
618 }
619 }
620 KeyCode::Char('t') if app.current_tab == Tab::Timeline => {
621 app.timeline_window = app.timeline_window.next();
622 }
623 KeyCode::Enter if app.current_tab == Tab::Timeline => {
624 let window_secs = app.timeline_window.seconds();
625 let now = std::time::Instant::now();
626 let window_start = now - std::time::Duration::from_secs(window_secs);
627 let mut sorted: Vec<&crate::collectors::connections::TrackedConnection> =
628 app.connection_timeline.tracked.iter()
629 .filter(|t| t.last_seen >= window_start)
630 .collect();
631 sorted.sort_by(|a, b| {
632 b.is_active.cmp(&a.is_active)
633 .then_with(|| a.first_seen.cmp(&b.first_seen))
634 });
635 if let Some(tracked) = sorted.get(app.timeline_scroll) {
636 let (remote_ip, _) = parse_addr_parts(&tracked.key.remote_addr);
637 if let Some(ip) = remote_ip {
638 app.packet_filter_text = ip.clone();
639 app.packet_filter_active = Some(ip);
640 app.packet_filter_input = false;
641 app.packet_scroll = 0;
642 app.packet_follow = false;
643 app.current_tab = Tab::Connections;
644 }
645 }
646 }
647 KeyCode::Enter if app.current_tab == Tab::Connections => {
648 let mut conns = app.connection_collector.connections.lock().unwrap().clone();
649 match app.sort_column {
650 0 => conns.sort_by(|a, b| a.process_name.as_deref().unwrap_or("").cmp(b.process_name.as_deref().unwrap_or(""))),
651 1 => conns.sort_by(|a, b| a.pid.cmp(&b.pid)),
652 2 => conns.sort_by(|a, b| a.protocol.cmp(&b.protocol)),
653 3 => conns.sort_by(|a, b| a.state.cmp(&b.state)),
654 4 => conns.sort_by(|a, b| a.local_addr.cmp(&b.local_addr)),
655 5 => conns.sort_by(|a, b| a.remote_addr.cmp(&b.remote_addr)),
656 _ => {}
657 }
658 if let Some(conn) = conns.get(app.connection_scroll) {
659 let filter = build_connection_filter(conn);
660 app.packet_filter_text = filter.clone();
661 app.packet_filter_active = Some(filter);
662 app.packet_filter_input = false;
663 app.packet_scroll = 0;
664 app.packet_follow = false;
665 app.current_tab = Tab::Packets;
666 }
667 }
668 KeyCode::Enter if app.current_tab == Tab::Topology => {
669 let mut counts: HashMap<String, usize> = HashMap::new();
670 let conns = app.connection_collector.connections.lock().unwrap();
671 for conn in conns.iter() {
672 let (remote_ip, _) = parse_addr_parts(&conn.remote_addr);
673 if let Some(ip) = remote_ip {
674 *counts.entry(ip).or_insert(0) += 1;
675 }
676 }
677 drop(conns);
678 let mut remote_ips: Vec<(String, usize)> = counts.into_iter().collect();
679 remote_ips.sort_by(|a, b| b.1.cmp(&a.1));
680 if let Some((ip, _)) = remote_ips.get(app.topology_scroll) {
681 app.packet_filter_text = ip.clone();
682 app.packet_filter_active = Some(ip.clone());
683 app.packet_filter_input = false;
684 app.packet_scroll = 0;
685 app.packet_follow = false;
686 app.current_tab = Tab::Connections;
687 }
688 }
689 KeyCode::Enter if app.current_tab == Tab::Packets => {
690 let packets = app.packet_collector.get_packets();
691 if !packets.is_empty() {
692 let visible_height = 20usize; let total = packets.len();
694 let offset = if app.packet_follow && total > visible_height {
695 total - visible_height
696 } else {
697 app.packet_scroll.min(total.saturating_sub(visible_height))
698 };
699 if let Some(pkt) = packets.get(offset) {
701 app.packet_selected = Some(pkt.id);
702 }
703 }
704 }
705 KeyCode::Up => match app.current_tab {
706 Tab::Connections => {
707 app.connection_scroll = app.connection_scroll.saturating_sub(1);
708 }
709 Tab::Packets => {
710 app.packet_follow = false;
711 app.packet_scroll = app.packet_scroll.saturating_sub(1);
712 let packets = app.packet_collector.get_packets();
714 if let Some(pkt) = packets.get(app.packet_scroll) {
715 app.packet_selected = Some(pkt.id);
716 }
717 }
718 Tab::Stats => {
719 app.stats_scroll = app.stats_scroll.saturating_sub(1);
720 }
721 Tab::Topology => {
722 app.topology_scroll = app.topology_scroll.saturating_sub(1);
723 }
724 Tab::Timeline => {
725 app.timeline_scroll = app.timeline_scroll.saturating_sub(1);
726 }
727 Tab::Insights => {
728 app.insights_scroll = app.insights_scroll.saturating_sub(1);
729 }
730 _ => {
731 app.selected_interface = match app.selected_interface {
732 Some(0) | None => None,
733 Some(i) => Some(i - 1),
734 };
735 }
736 },
737 KeyCode::Down => match app.current_tab {
738 Tab::Connections => {
739 let max = app
740 .connection_collector
741 .connections
742 .lock()
743 .unwrap()
744 .len()
745 .saturating_sub(1);
746 if app.connection_scroll < max {
747 app.connection_scroll += 1;
748 }
749 }
750 Tab::Packets => {
751 app.packet_follow = false;
752 let packets = app.packet_collector.get_packets();
753 let max = packets.len().saturating_sub(1);
754 if app.packet_scroll < max {
755 app.packet_scroll += 1;
756 }
757 if let Some(pkt) = packets.get(app.packet_scroll) {
758 app.packet_selected = Some(pkt.id);
759 }
760 }
761 Tab::Stats => {
762 app.stats_scroll += 1;
763 }
764 Tab::Topology => {
765 app.topology_scroll += 1;
766 }
767 Tab::Timeline => {
768 app.timeline_scroll += 1;
769 }
770 Tab::Insights => {
771 app.insights_scroll += 1;
772 }
773 _ => {
774 let max = app.traffic.interfaces.len().saturating_sub(1);
775 app.selected_interface = match app.selected_interface {
776 None => Some(0),
777 Some(i) if i < max => Some(i + 1),
778 other => other,
779 };
780 }
781 },
782 KeyCode::Char('/') if app.current_tab == Tab::Packets && !app.stream_view_open => {
783 app.packet_filter_input = true;
784 app.packet_filter_text = app.packet_filter_active.clone().unwrap_or_default();
785 }
786 KeyCode::Esc if app.current_tab == Tab::Packets && !app.stream_view_open && app.packet_filter_active.is_some() => {
787 app.packet_filter_active = None;
788 app.packet_filter_text.clear();
789 }
790 _ => {}
791 }
792 },
793 AppEvent::Tick => {
794 app.tick();
795 }
796 }
797 }
798}