Skip to main content

netwatch/
app.rs

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        // Pick the best default capture interface: first UP interface with an IPv4 address
132        // that isn't loopback
133        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        // Prefer UP interfaces with an IPv4 address, skip loopback
186        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        // Clear export status after 5 ticks
216        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        // Refresh interface info every ~10 ticks (10s at 1s tick rate)
230        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        // Refresh connections every ~2 ticks (2s)
240        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        // Refresh health every ~5 ticks (5s)
249        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        // Submit network snapshot for AI analysis every ~15 ticks (15s)
259        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    // Initial data collection
331    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                // Help overlay — intercept keys first
363                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                // Filter input mode — capture all keys
384                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                // BPF filter input mode — capture all keys
405                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                // Stream view controls (intercept before other Packets keys)
464                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                    // Jump to next bookmark after current selection
534                    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                    // Jump to previous bookmark before current selection
546                    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                    // Trigger whois lookup for selected packet's IPs
588                    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                    // Trigger whois lookup for selected connection's remote IP
598                    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; // approximate
693                        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                        // Select the packet at current scroll position
700                        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                        // Update selection to follow cursor
713                        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}