546 lines
28 KiB
Markdown
546 lines
28 KiB
Markdown
# 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):**
|
|
```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:**
|
|
```toml
|
|
[dependencies]
|
|
ratatui = { workspace = true }
|
|
crossterm = { workspace = true }
|
|
futures = { workspace = true }
|
|
```
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure
|
|
```
|
|
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.
|
|
```rust
|
|
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.
|
|
```rust
|
|
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.
|
|
```rust
|
|
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.
|
|
```rust
|
|
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)
|
|
```rust
|
|
// 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
|
|
```rust
|
|
// 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)
|
|
```rust
|
|
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)
|
|
```rust
|
|
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
|
|
```rust
|
|
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)
|
|
```rust
|
|
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
|
|
```rust
|
|
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)
|
|
- [Ratatui official docs](https://docs.rs/ratatui/latest/ratatui/) - init/restore, Table, TableState, Layout, widgets
|
|
- [Ratatui Table example](https://ratatui.rs/examples/widgets/table/) - Table widget usage pattern with TableState
|
|
- [Ratatui v0.30.0 release notes](https://ratatui.rs/highlights/v030/) - Breaking changes (HorizontalAlignment rename), modular workspace
|
|
- [Crossterm EventStream docs](https://docs.rs/crossterm/latest/crossterm/event/struct.EventStream.html) - Async event stream for tokio
|
|
- [Ratatui async event stream tutorial](https://ratatui.rs/tutorials/counter-async-app/async-event-stream/) - tokio::select! integration pattern
|
|
- [Ratatui init() docs](https://docs.rs/ratatui/latest/ratatui/fn.init.html) - Terminal lifecycle management
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [Ratatui async-template](https://github.com/ratatui/async-template) - Component-based architecture reference
|
|
- [Crossterm event-stream-tokio example](https://github.com/crossterm-rs/crossterm/blob/master/examples/event-stream-tokio.rs) - EventStream usage
|
|
|
|
### 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)
|