Files
Zachary D. Rowitsch 38e6dcc34a chore: archive v1.0 phase directories to milestones/v1.0-phases/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 01:33:15 -04:00

28 KiB

Phase 2: Interactive TUI - Research

Researched: 2026-03-21 Domain: Terminal UI with ratatui, async event handling, connection table rendering Confidence: HIGH

Summary

Phase 2 replaces the Phase 1 streaming stdout output with a full interactive TUI using ratatui 0.30 and crossterm 0.29. The existing codebase provides a clean integration surface: the tokio select! event loop in main.rs already separates collector events from periodic ticks, and the aggregator's tick() method returns exactly the data needed for rendering. The primary work is building the TUI layer (terminal init, keyboard handling, table rendering with sort/filter state) and wiring clap CLI args for filtering flags.

Ratatui 0.30 provides ratatui::init() which handles raw mode, alternate screen, and panic hooks in one call. The Table widget with TableState supports row selection and scrolling natively. Crossterm 0.29's EventStream (with the event-stream feature) integrates directly into the existing tokio select! loop for non-blocking keyboard input. Column sorting, filtering, and color-coding are application-level logic built on top of these primitives.

Primary recommendation: Use ratatui 0.30 + crossterm 0.29 with EventStream for async keyboard input. Structure the TUI as a single App struct holding sort/filter/scroll state, rendered each tick by a draw() function that builds the Table widget from sorted/filtered ConnectionRecord data. Replace print_tick() with terminal.draw(|f| app.draw(f)).

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Default column set (8 columns): Proto, Local Addr:Port, Remote Addr:Port, PID, Process, State, Rate In, Rate Out, RTT
  • D-02: Addresses displayed as combined addr:port in a single column (e.g., 192.168.1.5:443). IPv6 abbreviated where possible.
  • D-03: PID and Process Name as separate columns (not combined like nginx (1234))
  • D-04: Additional columns (Bytes In/Out, Packets In/Out) available via toggle key (c), not shown by default
  • D-05: Narrow terminals (< ~120 cols) handled by truncating addresses and process names with ellipsis. Columns stay, content gets trimmed.
  • D-06: Two sorting methods: mnemonic single-key shortcuts (r/R/p/n/s/t/a/A/P) and Tab to navigate column headers + Enter to sort. Press same key again to toggle asc/desc.
  • D-07: / opens filter-as-you-type mode. Table filters live as you type. Matches IP, port, or process name. Esc clears the filter. Like vim's / search.
  • D-08: Standard keyboard shortcuts: q / Ctrl-C quit, ? help overlay, arrows / j/k scroll rows, / filter, c toggle columns.
  • D-09: No detail pane. Table is the sole view.
  • D-10: Bandwidth color coding uses intensity-based single color (dim to bright). Low bandwidth = dim text, high bandwidth = bright/bold.
  • D-11: New connections get subtle green background highlight for one refresh cycle. Closing connections get red background highlight for one cycle before removal.
  • D-12: Pre-existing/partial connections indicated with asterisk on process name (e.g., nginx*). Help overlay explains the asterisk.
  • D-13: Header is 3-4 lines: connection counts with TCP/UDP breakdown, aggregate bandwidth in/out, separator line.
  • D-14: Active sort column and filter shown in a bottom status bar (vim-style), NOT in the header. E.g., Sort: Rate In down Filter: nginx
  • D-15: Header stays pure aggregate stats. Status bar at bottom for UI state.

Claude's Discretion

  • Exact ratatui widget composition and layout structure
  • Terminal initialization/restoration details
  • Internal event loop architecture (how TUI rendering interleaves with data updates)
  • Column width ratios and resize behavior
  • Exact intensity gradient thresholds for bandwidth display
  • Help overlay content and layout
  • How to integrate clap CLI args with the existing main.rs event loop

Deferred Ideas (OUT OF SCOPE)

  • UDP flow idle timeout configurable via CLI flag -- Phase 3 or backlog
  • --batch or --once mode preserving stdout output -- evaluate in Phase 3
  • Reverse DNS resolution for remote IPs -- v2 requirement (DISP-V2-01)
  • Connection duration/age display -- v2 requirement (DISP-V2-02)
  • Freeze/pause display with 'p' key -- v2 requirement (DISP-V2-03) </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
DISP-01 User sees a real-time sortable table with one row per connection showing all stats Ratatui Table + TableState with custom sort logic on ConnectionRecord fields
DISP-02 User sees a summary header with total connection count and aggregate bandwidth in/out Ratatui Paragraph widget in a Layout::vertical chunk above the table
DISP-03 User can sort the table by any column via keyboard App-level sort state (column enum + direction) applied before rendering; dual input model (mnemonic keys + Tab/Enter)
DISP-04 User sees local address, local port, remote address, remote port per connection Format as addr:port string from ConnectionKey fields per D-02
DISP-05 User can configure refresh interval via CLI flag (default 1 second) clap derive struct with --interval flag, passed to tokio::time::interval()
DISP-06 Connections are color-coded by bandwidth usage Ratatui Style with intensity-based color (dim/normal/bold) applied per-row based on rate_in + rate_out
DISP-07 User can search/filter the live view with '/' key App input mode state machine (Normal / Filter); filter string applied to records before sort+render
DISP-08 User can quit with 'q' or Ctrl-C Crossterm key event matching in the event handler
FILT-01 User can filter by port via CLI flag (--port) clap --port arg parsed and applied as pre-filter on ConnectionTable::tick() results
FILT-02 User can filter by process via CLI flag (--pid or --process) clap --pid and --process args
FILT-03 User can select network interface via CLI flag (--interface) clap --interface arg; needs to be passed to collector at construction time
FILT-04 User can filter by protocol via CLI flag (--tcp / --udp) clap boolean flags
</phase_requirements>

Standard Stack

Core

Library Version Purpose Why Standard
ratatui 0.30.0 Terminal UI framework Community standard Rust TUI. Immediate-mode rendering, Table widget with TableState, built-in init()/restore() for terminal lifecycle.
crossterm 0.29.0 Terminal backend + event input Default backend for ratatui 0.30. EventStream (with event-stream feature) provides async Stream for tokio integration.
clap 4.6.x CLI argument parsing Already in workspace deps with derive feature. Needed for --port, --pid, --process, --interface, --tcp, --udp, --interval flags.

Supporting

Library Version Purpose When to Use
futures 0.3.x StreamExt trait for EventStream::next() Required to .next().await on crossterm's EventStream in tokio select.

Alternatives Considered

Instead of Could Use Tradeoff
crossterm EventStream crossterm::event::poll() in a blocking loop Poll requires a dedicated thread or busy-waiting; EventStream integrates cleanly with the existing tokio select!
Manual terminal init ratatui::init() Manual gives more control but init() handles raw mode + alternate screen + panic hook in one call. Use init().

Installation (add to workspace Cargo.toml):

[workspace.dependencies]
ratatui = { version = "0.30.0", features = ["crossterm"] }
crossterm = { version = "0.29.0", features = ["event-stream"] }
futures = "0.3"

Add to tcptop/Cargo.toml:

[dependencies]
ratatui = { workspace = true }
crossterm = { workspace = true }
futures = { workspace = true }

Architecture Patterns

tcptop/src/
├── main.rs          # Entry point, clap parsing, terminal init, event loop
├── lib.rs           # Module declarations
├── tui/
│   ├── mod.rs       # Re-exports
│   ├── app.rs       # App state struct (sort, filter, scroll, input mode)
│   ├── draw.rs      # Rendering: header, table, status bar, help overlay
│   └── event.rs     # Keyboard event -> Action mapping
├── aggregator.rs    # Unchanged from Phase 1
├── model.rs         # Unchanged (may add sort helpers)
├── output.rs        # Kept for non-TUI use; format helpers reused by tui/draw.rs
├── collector/       # Unchanged from Phase 1
└── ...

Pattern 1: App State Struct

What: A single App struct owns all TUI state separate from domain data. When to use: Always -- keeps rendering pure and testable.

pub enum InputMode {
    Normal,
    Filter,
}

pub enum SortColumn {
    Proto, LocalAddr, RemoteAddr, Pid, Process, State, RateIn, RateOut, Rtt,
    BytesIn, BytesOut, PacketsIn, PacketsOut,
}

pub struct App {
    pub sort_column: SortColumn,
    pub sort_ascending: bool,
    pub filter_text: String,
    pub input_mode: InputMode,
    pub table_state: TableState,       // ratatui scroll/selection state
    pub show_extra_columns: bool,      // 'c' toggle per D-04
    pub show_help: bool,               // '?' overlay per D-08
    pub selected_header_col: usize,    // Tab navigation per D-06
    // Transient display state for D-11 (new/closing highlights)
    pub new_connections: HashSet<ConnectionKey>,    // green highlight this tick
    pub closing_connections: HashSet<ConnectionKey>, // red highlight this tick
}

Pattern 2: Async Event Loop with EventStream

What: Integrate crossterm keyboard events into the existing tokio select! loop. When to use: This is the pattern for the main loop.

use crossterm::event::EventStream;
use futures::StreamExt;

let mut event_stream = EventStream::new();

loop {
    tokio::select! {
        Some(event) = rx.recv() => {
            table.update(event);
        }
        _ = tick.tick() => {
            let (active, closed) = table.tick();
            // Update highlight sets for D-11
            app.update_highlights(&active, &closed);
            // Apply CLI filters (FILT-01..04), then live filter (D-07), then sort (D-06)
            let filtered = app.filter_and_sort(&active);
            terminal.draw(|f| app.draw(f, &filtered))?;
        }
        Some(Ok(crossterm_event)) = event_stream.next() => {
            if app.handle_event(crossterm_event) == Action::Quit {
                break;
            }
        }
        _ = sigint.recv() => break,
        _ = sigterm.recv() => break,
    }
}

Pattern 3: Layout Composition (3 regions)

What: Vertical layout with header, table, and status bar. When to use: The main draw() function.

fn draw(&mut self, frame: &mut Frame, connections: &[&ConnectionRecord]) {
    let chunks = Layout::vertical([
        Constraint::Length(4),    // Summary header (D-13)
        Constraint::Min(5),      // Connection table (DISP-01)
        Constraint::Length(1),   // Status bar (D-14)
    ]).split(frame.area());

    self.draw_header(frame, chunks[0], connections);
    self.draw_table(frame, chunks[1], connections);
    self.draw_status_bar(frame, chunks[2]);

    if self.show_help {
        self.draw_help_overlay(frame, frame.area());  // Centered popup
    }
}

Pattern 4: Filter-as-you-type State Machine

What: Input mode switching for the / filter. When to use: Keyboard event handler.

fn handle_event(&mut self, event: CrosstermEvent) -> Action {
    if let CrosstermEvent::Key(key) = event {
        if key.kind != KeyEventKind::Press { return Action::Continue; }
        match self.input_mode {
            InputMode::Normal => match key.code {
                KeyCode::Char('q') => return Action::Quit,
                KeyCode::Char('/') => self.input_mode = InputMode::Filter,
                KeyCode::Char('r') => self.toggle_sort(SortColumn::RateIn),
                KeyCode::Char('R') => self.toggle_sort(SortColumn::RateOut),
                // ... other mnemonic keys per D-06
                KeyCode::Tab => self.select_next_header_col(),
                KeyCode::Enter => self.sort_by_selected_header(),
                KeyCode::Char('j') | KeyCode::Down => self.table_state.select_next(),
                KeyCode::Char('k') | KeyCode::Up => self.table_state.select_previous(),
                KeyCode::Char('c') => self.show_extra_columns = !self.show_extra_columns,
                KeyCode::Char('?') => self.show_help = !self.show_help,
                _ => {}
            },
            InputMode::Filter => match key.code {
                KeyCode::Esc => {
                    self.filter_text.clear();
                    self.input_mode = InputMode::Normal;
                }
                KeyCode::Char(c) => self.filter_text.push(c),
                KeyCode::Backspace => { self.filter_text.pop(); }
                KeyCode::Enter => self.input_mode = InputMode::Normal,
                _ => {}
            },
        }
    }
    Action::Continue
}

Anti-Patterns to Avoid

  • Blocking event reads in async context: Never use crossterm::event::read() in a tokio task. Use EventStream with the event-stream feature instead. Blocking reads will freeze the entire async runtime.
  • Rendering on every keypress: Only render on tick intervals. Keyboard events update state; the next tick renders the new state. This keeps rendering at a consistent cadence and avoids wasted frames.
  • Forgetting terminal restore on all exit paths: Use ratatui::init() which installs a panic hook. But also ensure ratatui::restore() is called on normal exit AND on signal handlers. Wrap the main loop in a function and call restore in a defer-like pattern.
  • Processing Key Release/Repeat events: Crossterm sends Press, Release, and Repeat events. Filter to KeyEventKind::Press only, or you get double-handling on some terminals.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Terminal raw mode / alternate screen Manual crossterm commands ratatui::init() / ratatui::restore() Handles raw mode, alternate screen, and panic hooks. One call.
Async keyboard input Polling thread or busy loop crossterm EventStream + futures StreamExt Integrates into tokio select! cleanly
Table scrolling / selection Manual offset tracking TableState (ratatui) Handles scroll position, selection, wrapping
Column layout constraints Manual character counting Constraint::Percentage, Constraint::Min, Constraint::Length Ratatui's layout engine handles terminal resize
Help overlay popup Manual screen save/restore Render a Clear + Block + Paragraph on top Ratatui's immediate-mode rendering makes overlays trivial

Common Pitfalls

Pitfall 1: Terminal not restored after panic or early exit

What goes wrong: If the program panics or exits without restoring the terminal, the user's shell is left in raw mode with the alternate screen active. They see garbled input. Why it happens: Manual terminal setup without panic hook. Signal handlers that exit without restoring. How to avoid: Use ratatui::init() which installs a panic hook. Call ratatui::restore() before every exit path. In signal handlers, restore before breaking. Warning signs: Testing crashes leave terminal in bad state.

Pitfall 2: EventStream requires the event-stream feature

What goes wrong: crossterm::event::EventStream does not exist at compile time. Why it happens: The event-stream feature on crossterm is not enabled by default. How to avoid: Add features = ["event-stream"] to crossterm dependency. Warning signs: Compilation error about missing EventStream type.

Pitfall 3: Double key events (Press + Release)

What goes wrong: Each keypress triggers the handler twice, causing sort toggles to flip back, filter chars to double, etc. Why it happens: Some terminals send both Press and Release events. Crossterm 0.28+ enables KeyEventKind reporting. How to avoid: Filter events: if key.kind != KeyEventKind::Press { return; } Warning signs: Sort direction flipping back immediately, double characters in filter.

Pitfall 4: TableState selection out of bounds after filter

What goes wrong: Selected row index points past the end of the filtered list, causing panic or rendering glitch. Why it happens: User has row 15 selected, then types a filter that reduces the list to 3 items. How to avoid: After filtering, clamp table_state.selected() to filtered.len().saturating_sub(1). Or call table_state.select(Some(0)) when filter text changes. Warning signs: Panic on draw, or table showing no highlight.

Pitfall 5: Forgetting to handle Ctrl-C separately from 'q'

What goes wrong: Ctrl-C does not quit the app because raw mode intercepts it. Why it happens: In raw mode, Ctrl-C is delivered as KeyCode::Char('c') with KeyModifiers::CONTROL, not as SIGINT. How to avoid: Match on KeyCode::Char('c') when key.modifiers.contains(KeyModifiers::CONTROL) in the event handler. The existing SIGINT handler in tokio::select! is a backup but the crossterm event arrives first. Warning signs: User presses Ctrl-C and nothing happens.

Pitfall 6: Rendering performance with many connections

What goes wrong: Drawing hundreds of rows causes noticeable lag. Why it happens: Building Row/Cell widgets for connections not visible on screen. How to avoid: Only build Row widgets for visible rows (use TableState::offset() + terminal height to calculate visible range). For < 500 connections this is unlikely to matter, but good to keep in mind. Warning signs: Noticeable frame drops with > 200 connections.

Pitfall 7: ratatui 0.30 breaking change -- HorizontalAlignment

What goes wrong: Code using Alignment::Center does not compile. Why it happens: Ratatui 0.30 renamed Alignment to HorizontalAlignment. How to avoid: Use HorizontalAlignment::Center or import the renamed type. Warning signs: Compilation error about Alignment not found.

Code Examples

Terminal Lifecycle (init + restore)

// Source: https://docs.rs/ratatui/latest/ratatui/fn.init.html
use ratatui::DefaultTerminal;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // init() enables raw mode, alternate screen, installs panic hook
    let mut terminal = ratatui::init();

    let result = run(&mut terminal).await;

    // restore() disables raw mode, leaves alternate screen
    ratatui::restore();
    result
}

Table Widget with Styled Rows

// Source: https://ratatui.rs/examples/widgets/table/
use ratatui::widgets::{Row, Table, TableState, Block, Borders};
use ratatui::style::{Style, Color, Modifier};
use ratatui::layout::Constraint;

fn build_table<'a>(records: &'a [&ConnectionRecord], app: &App) -> Table<'a> {
    let header = Row::new(vec!["Proto", "Local", "Remote", "PID", "Process", "State", "Rate In", "Rate Out", "RTT"])
        .style(Style::default().add_modifier(Modifier::BOLD))
        .bottom_margin(1);

    let rows: Vec<Row> = records.iter().map(|r| {
        let style = bandwidth_style(r.rate_in + r.rate_out);
        Row::new(vec![
            proto_str(r),
            format!("{}:{}", r.key.local_addr, r.key.local_port),
            format!("{}:{}", r.key.remote_addr, r.key.remote_port),
            r.pid.to_string(),
            process_display(r),  // adds * for is_partial per D-12
            state_str(r),
            format_rate(r.rate_in),
            format_rate(r.rate_out),
            format_rtt(r.rtt_us),
        ]).style(style)
    }).collect();

    let widths = [
        Constraint::Length(5),   // Proto
        Constraint::Min(15),     // Local
        Constraint::Min(15),     // Remote
        Constraint::Length(7),   // PID
        Constraint::Length(12),  // Process
        Constraint::Length(12),  // State
        Constraint::Length(10),  // Rate In
        Constraint::Length(10),  // Rate Out
        Constraint::Length(8),   // RTT
    ];

    Table::new(rows, widths)
        .header(header)
        .block(Block::default().borders(Borders::NONE))
        .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
}

Bandwidth Intensity Styling (D-10)

fn bandwidth_style(total_rate: f64) -> Style {
    if total_rate < 1024.0 {
        // < 1 KB/s: dim
        Style::default().fg(Color::DarkGray)
    } else if total_rate < 100.0 * 1024.0 {
        // 1-100 KB/s: normal
        Style::default().fg(Color::White)
    } else if total_rate < 1024.0 * 1024.0 {
        // 100KB-1MB/s: bright
        Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
    } else {
        // > 1 MB/s: bright + underline for emphasis
        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
    }
}

Connection Highlight for New/Closing (D-11)

fn row_style(record: &ConnectionRecord, app: &App) -> Style {
    if app.new_connections.contains(&record.key) {
        Style::default().bg(Color::DarkGreen)
    } else if app.closing_connections.contains(&record.key) {
        Style::default().bg(Color::DarkRed)
    } else {
        bandwidth_style(record.rate_in + record.rate_out)
    }
}

Sort Implementation

fn sort_records(records: &mut Vec<&ConnectionRecord>, column: &SortColumn, ascending: bool) {
    records.sort_by(|a, b| {
        let ord = match column {
            SortColumn::Proto => a.key.protocol.cmp(&b.key.protocol),
            SortColumn::RateIn => a.rate_in.partial_cmp(&b.rate_in).unwrap_or(Ordering::Equal),
            SortColumn::RateOut => a.rate_out.partial_cmp(&b.rate_out).unwrap_or(Ordering::Equal),
            SortColumn::Pid => a.pid.cmp(&b.pid),
            SortColumn::Process => a.process_name.cmp(&b.process_name),
            SortColumn::Rtt => a.rtt_us.cmp(&b.rtt_us),
            // ... other columns
            _ => Ordering::Equal,
        };
        if ascending { ord } else { ord.reverse() }
    });
}

Filter Implementation (D-07)

fn filter_records<'a>(
    records: &[&'a ConnectionRecord],
    filter: &str,
    cli_filters: &CliFilters,
) -> Vec<&'a ConnectionRecord> {
    records.iter()
        .filter(|r| {
            // CLI filters (FILT-01..04)
            if let Some(port) = cli_filters.port {
                if r.key.local_port != port && r.key.remote_port != port { return false; }
            }
            if let Some(pid) = cli_filters.pid {
                if r.pid != pid { return false; }
            }
            if let Some(ref proc) = cli_filters.process {
                if !r.process_name.contains(proc.as_str()) { return false; }
            }
            if cli_filters.tcp_only && r.key.protocol != Protocol::Tcp { return false; }
            if cli_filters.udp_only && r.key.protocol != Protocol::Udp { return false; }
            // Live filter (DISP-07)
            if !filter.is_empty() {
                let haystack = format!(
                    "{} {} {} {} {}",
                    r.key.local_addr, r.key.local_port,
                    r.key.remote_addr, r.key.remote_port,
                    r.process_name
                );
                if !haystack.to_lowercase().contains(&filter.to_lowercase()) { return false; }
            }
            true
        })
        .copied()
        .collect()
}

Clap Derive Struct

use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "tcptop", about = "Real-time per-connection network monitor")]
pub struct Cli {
    /// Filter by port (matches source or destination)
    #[arg(long)]
    pub port: Option<u16>,

    /// Filter by process ID
    #[arg(long)]
    pub pid: Option<u32>,

    /// Filter by process name (substring match)
    #[arg(long)]
    pub process: Option<String>,

    /// Network interface to monitor
    #[arg(long, short = 'i')]
    pub interface: Option<String>,

    /// Show only TCP connections
    #[arg(long)]
    pub tcp: bool,

    /// Show only UDP connections
    #[arg(long)]
    pub udp: bool,

    /// Refresh interval in seconds (default: 1)
    #[arg(long, default_value = "1")]
    pub interval: u64,
}

State of the Art

Old Approach Current Approach When Changed Impact
tui-rs crate ratatui (community fork) 2023 tui-rs is deprecated; ratatui is the only maintained option
Manual terminal init (enable_raw_mode + EnterAlternateScreen) ratatui::init() ratatui 0.28+ One function call handles everything including panic hook
crossterm::event::read() blocking EventStream async stream crossterm 0.28+ with event-stream feature Enables native tokio integration without dedicated thread
Alignment enum HorizontalAlignment enum ratatui 0.30.0 Renamed for clarity; old name no longer compiles

Open Questions

  1. Interface filter (FILT-03) implementation depth

    • What we know: --interface needs to be passed to the collector, not just the TUI filter layer. The LinuxCollector::new() currently takes no arguments.
    • What's unclear: Whether eBPF programs can be attached per-interface, or if interface filtering must happen in userspace. For Phase 2, userspace filtering of interface is likely sufficient if records include interface info.
    • Recommendation: Add --interface to clap args and pass to collector constructor. If eBPF per-interface attachment is complex, filter in userspace as a Phase 2 pragmatic choice and note for future optimization.
  2. New connection detection for D-11 highlight

    • What we know: ConnectionTable::tick() returns active and closed lists. Closed connections have is_closed = true.
    • What's unclear: There's no explicit "new this tick" flag on ConnectionRecord.
    • Recommendation: Track connection keys seen in previous tick. Diff with current tick to identify new connections. Store previous tick's key set in App state.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • None

Metadata

Confidence breakdown:

  • Standard stack: HIGH - ratatui 0.30 and crossterm 0.29 are verified current releases with well-documented APIs
  • Architecture: HIGH - async event loop pattern is documented in official ratatui tutorials and matches existing codebase structure
  • Pitfalls: HIGH - double key events, terminal restore, and EventStream feature flag are well-documented common issues

Research date: 2026-03-21 Valid until: 2026-04-21 (ratatui ecosystem is mature and stable)