586 lines
28 KiB
Markdown
586 lines
28 KiB
Markdown
---
|
|
phase: 02-interactive-tui
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- Cargo.toml
|
|
- tcptop/Cargo.toml
|
|
- tcptop/src/lib.rs
|
|
- tcptop/src/main.rs
|
|
- tcptop/src/tui/mod.rs
|
|
- tcptop/src/tui/app.rs
|
|
- tcptop/src/tui/draw.rs
|
|
- tcptop/src/tui/event.rs
|
|
autonomous: true
|
|
requirements:
|
|
- DISP-01
|
|
- DISP-02
|
|
- DISP-04
|
|
- DISP-05
|
|
- DISP-06
|
|
- DISP-08
|
|
|
|
must_haves:
|
|
truths:
|
|
- "User sees a live-updating table of connections with columns Proto, Local, Remote, PID, Process, State, Rate In, Rate Out, RTT"
|
|
- "User sees a 3-4 line summary header with TCP/UDP connection counts and aggregate bandwidth"
|
|
- "User sees a bottom status bar showing current sort column and direction"
|
|
- "User can quit with 'q' or Ctrl-C and terminal restores cleanly"
|
|
- "Connections are color-coded by bandwidth intensity (dim to bright)"
|
|
- "Display refreshes at a configurable interval (--interval flag, default 1s)"
|
|
artifacts:
|
|
- path: "tcptop/src/tui/app.rs"
|
|
provides: "App state struct with sort, filter, scroll, input mode"
|
|
exports: ["App", "InputMode", "SortColumn", "CliFilters"]
|
|
- path: "tcptop/src/tui/draw.rs"
|
|
provides: "Rendering functions for header, table, status bar"
|
|
contains: "fn draw"
|
|
- path: "tcptop/src/tui/event.rs"
|
|
provides: "Keyboard event handler"
|
|
contains: "fn handle_event"
|
|
- path: "tcptop/src/main.rs"
|
|
provides: "Terminal init/restore, EventStream in select loop, clap Cli struct"
|
|
contains: "ratatui::init"
|
|
key_links:
|
|
- from: "tcptop/src/main.rs"
|
|
to: "tcptop/src/tui/app.rs"
|
|
via: "App::new() and app.draw()"
|
|
pattern: "app\\.draw"
|
|
- from: "tcptop/src/tui/draw.rs"
|
|
to: "tcptop/src/output.rs"
|
|
via: "format_rate, format_rtt reuse"
|
|
pattern: "format_rate|format_rtt"
|
|
- from: "tcptop/src/main.rs"
|
|
to: "crossterm::event::EventStream"
|
|
via: "event_stream.next() in tokio::select!"
|
|
pattern: "event_stream\\.next\\(\\)"
|
|
---
|
|
|
|
<objective>
|
|
Build the core TUI: ratatui terminal with live connection table, summary header, status bar, bandwidth coloring, and basic quit handling. Replace Phase 1's stdout streaming with a full interactive terminal interface.
|
|
|
|
Purpose: Transform tcptop from a streaming text dump into a usable `top`-like tool with a real terminal UI.
|
|
Output: Working TUI that renders live connection data in a sortable table with header stats and status bar.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/Users/zrowitsch/local_src/tcptop/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/Users/zrowitsch/local_src/tcptop/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/02-interactive-tui/02-CONTEXT.md
|
|
@.planning/phases/02-interactive-tui/02-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Existing types the executor needs -->
|
|
|
|
From tcptop/src/model.rs:
|
|
```rust
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum Protocol { Tcp, Udp }
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct ConnectionKey {
|
|
pub protocol: Protocol,
|
|
pub local_addr: IpAddr,
|
|
pub local_port: u16,
|
|
pub remote_addr: IpAddr,
|
|
pub remote_port: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ConnectionRecord {
|
|
pub key: ConnectionKey,
|
|
pub pid: u32,
|
|
pub process_name: String,
|
|
pub tcp_state: Option<TcpState>,
|
|
pub bytes_in: u64,
|
|
pub bytes_out: u64,
|
|
pub packets_in: u64,
|
|
pub packets_out: u64,
|
|
pub rate_in: f64,
|
|
pub rate_out: f64,
|
|
pub prev_bytes_in: u64,
|
|
pub prev_bytes_out: u64,
|
|
pub rtt_us: Option<u32>,
|
|
pub last_seen: Instant,
|
|
pub is_partial: bool,
|
|
pub is_closed: bool,
|
|
}
|
|
|
|
impl TcpState {
|
|
pub fn as_str(&self) -> &'static str;
|
|
}
|
|
```
|
|
|
|
From tcptop/src/output.rs:
|
|
```rust
|
|
pub fn format_bytes(bytes: u64) -> String;
|
|
pub fn format_rate(rate: f64) -> String;
|
|
pub fn format_rtt(rtt_us: Option<u32>) -> String;
|
|
```
|
|
|
|
From tcptop/src/aggregator.rs:
|
|
```rust
|
|
pub struct ConnectionTable { ... }
|
|
impl ConnectionTable {
|
|
pub fn new() -> Self;
|
|
pub fn seed(&mut self, records: Vec<ConnectionRecord>);
|
|
pub fn update(&mut self, event: CollectorEvent);
|
|
pub fn tick(&mut self) -> (Vec<&ConnectionRecord>, Vec<ConnectionRecord>);
|
|
pub fn connection_count(&self) -> usize;
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add dependencies and create TUI module with App state</name>
|
|
<read_first>
|
|
- Cargo.toml (workspace root - current workspace.dependencies)
|
|
- tcptop/Cargo.toml (userspace crate deps)
|
|
- tcptop/src/lib.rs (module declarations)
|
|
- tcptop/src/model.rs (ConnectionKey, ConnectionRecord, Protocol, TcpState types)
|
|
</read_first>
|
|
<files>Cargo.toml, tcptop/Cargo.toml, tcptop/src/lib.rs, tcptop/src/tui/mod.rs, tcptop/src/tui/app.rs</files>
|
|
<action>
|
|
1. Add workspace dependencies to `Cargo.toml` under `[workspace.dependencies]`:
|
|
```toml
|
|
ratatui = { version = "0.30.0", features = ["crossterm"] }
|
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
|
futures = "0.3"
|
|
```
|
|
|
|
2. Add to `tcptop/Cargo.toml` under `[dependencies]`:
|
|
```toml
|
|
ratatui = { workspace = true }
|
|
crossterm = { workspace = true }
|
|
futures = { workspace = true }
|
|
```
|
|
|
|
3. Add `pub mod tui;` to `tcptop/src/lib.rs` (after `pub mod output;`).
|
|
|
|
4. Create `tcptop/src/tui/mod.rs` with:
|
|
```rust
|
|
pub mod app;
|
|
pub mod draw;
|
|
pub mod event;
|
|
```
|
|
|
|
5. Create `tcptop/src/tui/app.rs` with the App state struct. This is the core TUI state:
|
|
|
|
- `InputMode` enum: `Normal`, `Filter` (per D-07 state machine)
|
|
- `SortColumn` enum: `Proto`, `LocalAddr`, `RemoteAddr`, `Pid`, `Process`, `State`, `RateIn`, `RateOut`, `Rtt`, `BytesIn`, `BytesOut`, `PacketsIn`, `PacketsOut` (per D-01, D-04)
|
|
- `CliFilters` struct: `port: Option<u16>`, `pid: Option<u32>`, `process: Option<String>`, `tcp_only: bool`, `udp_only: bool` (for FILT-01..04, populated by Plan 02)
|
|
- `App` struct with fields:
|
|
- `sort_column: SortColumn` (default `RateIn`)
|
|
- `sort_ascending: bool` (default `false` - highest rate first)
|
|
- `filter_text: String` (empty)
|
|
- `input_mode: InputMode` (Normal)
|
|
- `table_state: ratatui::widgets::TableState` (default)
|
|
- `show_extra_columns: bool` (false, per D-04)
|
|
- `show_help: bool` (false, per D-08)
|
|
- `selected_header_col: usize` (0, for Tab navigation per D-06)
|
|
- `new_connections: HashSet<ConnectionKey>` (for D-11 green highlight)
|
|
- `closing_connections: HashSet<ConnectionKey>` (for D-11 red highlight)
|
|
- `previous_keys: HashSet<ConnectionKey>` (for detecting new connections)
|
|
- `cli_filters: CliFilters`
|
|
- `App::new(cli_filters: CliFilters) -> Self` constructor
|
|
- `App::toggle_sort(&mut self, column: SortColumn)` - if same column, flip ascending; if different column, set column and ascending=false
|
|
- `App::sort_records(&self, records: &mut Vec<&ConnectionRecord>)` - sort in place using `sort_column` and `sort_ascending`. Use `partial_cmp` for f64 fields (rate_in, rate_out), `cmp` for others. Match all SortColumn variants including LocalAddr (format as string then cmp), RemoteAddr similarly.
|
|
- `App::update_highlights(&mut self, active: &[&ConnectionRecord], closed: &[ConnectionRecord])` - compute new_connections as keys in active but not in previous_keys. Set closing_connections from closed records' keys. Then set previous_keys = current active keys.
|
|
- `App::filter_records<'a>(&self, records: &[&'a ConnectionRecord], live_filter: &str) -> Vec<&'a ConnectionRecord>` - apply cli_filters (port, pid, process, tcp_only, udp_only) then live_filter string matching against addr:port and process_name. Case-insensitive contains matching.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/zrowitsch/local_src/tcptop && cargo check 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- Cargo.toml contains `ratatui = { version = "0.30.0", features = ["crossterm"] }`
|
|
- Cargo.toml contains `crossterm = { version = "0.29.0", features = ["event-stream"] }`
|
|
- Cargo.toml contains `futures = "0.3"`
|
|
- tcptop/Cargo.toml contains `ratatui = { workspace = true }`
|
|
- tcptop/Cargo.toml contains `crossterm = { workspace = true }`
|
|
- tcptop/Cargo.toml contains `futures = { workspace = true }`
|
|
- tcptop/src/lib.rs contains `pub mod tui;`
|
|
- tcptop/src/tui/mod.rs contains `pub mod app;` and `pub mod draw;` and `pub mod event;`
|
|
- tcptop/src/tui/app.rs contains `pub enum InputMode` with `Normal` and `Filter` variants
|
|
- tcptop/src/tui/app.rs contains `pub enum SortColumn` with `Proto`, `LocalAddr`, `RemoteAddr`, `Pid`, `Process`, `State`, `RateIn`, `RateOut`, `Rtt` variants
|
|
- tcptop/src/tui/app.rs contains `pub struct App` with `sort_column`, `filter_text`, `input_mode`, `table_state`, `show_extra_columns`, `show_help`, `new_connections`, `closing_connections` fields
|
|
- tcptop/src/tui/app.rs contains `pub struct CliFilters`
|
|
- tcptop/src/tui/app.rs contains `pub fn toggle_sort`
|
|
- tcptop/src/tui/app.rs contains `pub fn sort_records`
|
|
- tcptop/src/tui/app.rs contains `pub fn update_highlights`
|
|
- tcptop/src/tui/app.rs contains `pub fn filter_records`
|
|
- `cargo check` succeeds (exit code 0)
|
|
</acceptance_criteria>
|
|
<done>TUI module scaffolded with App state struct, all enums, filter/sort/highlight methods. Compiles clean.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create draw.rs and event.rs for rendering and keyboard handling</name>
|
|
<read_first>
|
|
- tcptop/src/tui/app.rs (App struct just created)
|
|
- tcptop/src/output.rs (format_rate, format_rtt, format_bytes to reuse)
|
|
- tcptop/src/model.rs (ConnectionRecord, Protocol, TcpState for display)
|
|
- .planning/phases/02-interactive-tui/02-RESEARCH.md (rendering patterns, bandwidth_style, layout composition)
|
|
</read_first>
|
|
<files>tcptop/src/tui/draw.rs, tcptop/src/tui/event.rs</files>
|
|
<action>
|
|
1. Create `tcptop/src/tui/draw.rs` with rendering functions:
|
|
|
|
- `pub fn draw(frame: &mut Frame, app: &mut App, connections: &[&ConnectionRecord])` - main draw function. Uses `Layout::vertical` with 3 chunks:
|
|
- `Constraint::Length(4)` for summary header (per D-13)
|
|
- `Constraint::Min(5)` for connection table (DISP-01)
|
|
- `Constraint::Length(1)` for status bar (per D-14)
|
|
Calls `draw_header`, `draw_table`, `draw_status_bar`. If `app.show_help`, calls `draw_help_overlay` on top.
|
|
|
|
- `fn draw_header(frame: &mut Frame, area: Rect, connections: &[&ConnectionRecord])` - renders a `Paragraph` widget (per D-13). Content:
|
|
- Line 1: `"tcptop — {total} connections ({tcp_count} TCP, {udp_count} UDP)"` - count by protocol
|
|
- Line 2: `"Bandwidth: In: {total_rate_in} Out: {total_rate_out}"` - sum of all rate_in/rate_out formatted via `crate::output::format_rate()`
|
|
- Line 3: horizontal separator using `"─".repeat(area.width)`
|
|
Use `Block::default()` with no borders.
|
|
|
|
- `fn draw_table(frame: &mut Frame, area: Rect, app: &mut App, connections: &[&ConnectionRecord])` - builds a ratatui `Table` widget:
|
|
- Header row (per D-01): `["Proto", "Local Addr:Port", "Remote Addr:Port", "PID", "Process", "State", "Rate In", "Rate Out", "RTT"]`. If `app.show_extra_columns` (D-04), append `["Bytes In", "Bytes Out", "Pkts In", "Pkts Out"]`.
|
|
- Header style: `Style::default().add_modifier(Modifier::BOLD)`. The column matching `app.sort_column` gets underline modifier and a direction indicator arrow appended: " ↑" if ascending, " ↓" if descending.
|
|
- For Tab navigation (D-06): the column at index `app.selected_header_col` gets `bg(Color::DarkGray)` to show selection.
|
|
- Data rows: for each ConnectionRecord, create a `Row` with cells:
|
|
- Proto: `"TCP"` or `"UDP"` from `record.key.protocol`
|
|
- Local: `format!("{}:{}", record.key.local_addr, record.key.local_port)` (per D-02)
|
|
- Remote: `format!("{}:{}", record.key.remote_addr, record.key.remote_port)` (per D-02)
|
|
- PID: `record.pid.to_string()` (per D-03)
|
|
- Process: `record.process_name.clone()` + `"*"` suffix if `record.is_partial` (per D-12). Truncate with ellipsis if needed (D-05, ratatui handles this via Constraint but also add `..` manually if >15 chars for safety).
|
|
- State: `record.tcp_state.map(|s| s.as_str()).unwrap_or("UDP")` (per D-07 in Phase 1)
|
|
- Rate In: `crate::output::format_rate(record.rate_in)`
|
|
- Rate Out: `crate::output::format_rate(record.rate_out)`
|
|
- RTT: `crate::output::format_rtt(record.rtt_us)`
|
|
- If extra columns: `crate::output::format_bytes(record.bytes_in)`, `format_bytes(record.bytes_out)`, `record.packets_in.to_string()`, `record.packets_out.to_string()`
|
|
- Row styling (per D-10 and D-11):
|
|
- If key is in `app.new_connections`: `Style::default().bg(Color::DarkGreen)` (per D-11)
|
|
- Else if key is in `app.closing_connections`: `Style::default().bg(Color::DarkRed)` (per D-11)
|
|
- Else: `bandwidth_style(record.rate_in + record.rate_out)` (per D-10)
|
|
- Column widths: `[Length(5), Min(15), Min(15), Length(7), Length(15), Length(12), Length(10), Length(10), Length(8)]`. If extra columns, append `[Length(12), Length(12), Length(8), Length(8)]`.
|
|
- Render with `frame.render_stateful_widget(table, area, &mut app.table_state)` for scroll support.
|
|
- Row highlight style: `Style::default().add_modifier(Modifier::REVERSED)` for selected row.
|
|
|
|
- `fn draw_status_bar(frame: &mut Frame, area: Rect, app: &App)` - renders a single-line `Paragraph` (per D-14, D-15):
|
|
- Normal mode: `"Sort: {column_name} {arrow} | /:filter ?:help c:columns q:quit"`
|
|
- Filter mode: `"Filter: {app.filter_text}_"` (underscore as cursor indicator)
|
|
- Style: `bg(Color::DarkGray)`, `fg(Color::White)`
|
|
|
|
- `fn draw_help_overlay(frame: &mut Frame, area: Rect)` - centered popup (per D-08):
|
|
- Calculate centered rect: 60 wide, 20 tall (or 60%/60% of terminal, whichever smaller)
|
|
- `Block::bordered()` with title `" Help "`
|
|
- Content: list of keybindings:
|
|
```
|
|
q / Ctrl-C Quit
|
|
/ Filter (type to search, Esc to clear)
|
|
? Toggle this help
|
|
c Toggle extra columns (bytes, packets)
|
|
r / R Sort by Rate In / Rate Out
|
|
p / n Sort by PID / Process Name
|
|
s / t Sort by State / RTT
|
|
a / A Sort by Local Addr / Remote Addr
|
|
P Sort by Protocol
|
|
Tab / Enter Navigate/select column header
|
|
j/k or ↑/↓ Scroll rows
|
|
|
|
* next to process name = pre-existing connection (partial data)
|
|
```
|
|
- Clear the area behind the popup before rendering.
|
|
|
|
- `fn bandwidth_style(total_rate: f64) -> Style` (per D-10):
|
|
- `< 1024.0` (< 1 KB/s): `fg(Color::DarkGray)` (dim)
|
|
- `< 102400.0` (< 100 KB/s): `fg(Color::White)` (normal)
|
|
- `< 1048576.0` (< 1 MB/s): `fg(Color::White).add_modifier(Modifier::BOLD)` (bright)
|
|
- `>= 1048576.0` (>= 1 MB/s): `fg(Color::Yellow).add_modifier(Modifier::BOLD)` (very bright)
|
|
|
|
- `fn centered_rect(width: u16, height: u16, area: Rect) -> Rect` - helper to compute centered rectangle for overlay.
|
|
|
|
2. Create `tcptop/src/tui/event.rs` with keyboard event handling:
|
|
|
|
- `pub enum Action { Continue, Quit }` - return type from event handler
|
|
|
|
- `pub fn handle_event(app: &mut App, event: crossterm::event::Event) -> Action` - process keyboard events:
|
|
- Filter to `Event::Key(key)` where `key.kind == KeyEventKind::Press` only (avoid double events per Pitfall 3 in research)
|
|
- Check for Ctrl-C first (any mode): `key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)` -> return `Action::Quit`
|
|
- Match on `app.input_mode`:
|
|
- `InputMode::Normal`:
|
|
- `KeyCode::Char('q')` -> `Action::Quit`
|
|
- `KeyCode::Char('/')` -> set `app.input_mode = InputMode::Filter`
|
|
- `KeyCode::Char('?')` -> toggle `app.show_help`
|
|
- `KeyCode::Char('c')` -> toggle `app.show_extra_columns` (per D-04)
|
|
- `KeyCode::Char('r')` -> `app.toggle_sort(SortColumn::RateIn)` (per D-06)
|
|
- `KeyCode::Char('R')` -> `app.toggle_sort(SortColumn::RateOut)`
|
|
- `KeyCode::Char('p')` -> `app.toggle_sort(SortColumn::Pid)`
|
|
- `KeyCode::Char('n')` -> `app.toggle_sort(SortColumn::Process)`
|
|
- `KeyCode::Char('s')` -> `app.toggle_sort(SortColumn::State)`
|
|
- `KeyCode::Char('t')` -> `app.toggle_sort(SortColumn::Rtt)`
|
|
- `KeyCode::Char('a')` -> `app.toggle_sort(SortColumn::LocalAddr)`
|
|
- `KeyCode::Char('A')` -> `app.toggle_sort(SortColumn::RemoteAddr)`
|
|
- `KeyCode::Char('P')` -> `app.toggle_sort(SortColumn::Proto)`
|
|
- `KeyCode::Tab` -> advance `app.selected_header_col` (mod number of visible columns)
|
|
- `KeyCode::Enter` -> sort by column at `app.selected_header_col` using a mapping array
|
|
- `KeyCode::Char('j') | KeyCode::Down` -> `app.table_state.select_next()`
|
|
- `KeyCode::Char('k') | KeyCode::Up` -> `app.table_state.select_previous()`
|
|
- _ -> do nothing
|
|
- `InputMode::Filter` (per D-07):
|
|
- `KeyCode::Esc` -> clear `app.filter_text`, set `app.input_mode = InputMode::Normal`, reset table_state selection to 0
|
|
- `KeyCode::Enter` -> set `app.input_mode = InputMode::Normal` (keep filter text)
|
|
- `KeyCode::Char(c)` -> push `c` to `app.filter_text`
|
|
- `KeyCode::Backspace` -> pop from `app.filter_text`
|
|
- _ -> do nothing
|
|
- Return `Action::Continue` for all non-quit cases.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/zrowitsch/local_src/tcptop && cargo check 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- tcptop/src/tui/draw.rs contains `pub fn draw(frame: &mut Frame, app: &mut App, connections: &[&ConnectionRecord])`
|
|
- tcptop/src/tui/draw.rs contains `fn draw_header`
|
|
- tcptop/src/tui/draw.rs contains `fn draw_table`
|
|
- tcptop/src/tui/draw.rs contains `fn draw_status_bar`
|
|
- tcptop/src/tui/draw.rs contains `fn draw_help_overlay`
|
|
- tcptop/src/tui/draw.rs contains `fn bandwidth_style`
|
|
- tcptop/src/tui/draw.rs contains `Layout::vertical` or `Layout::new(Direction::Vertical`
|
|
- tcptop/src/tui/draw.rs contains `format_rate` (reuse from output.rs)
|
|
- tcptop/src/tui/draw.rs contains `format_rtt` (reuse from output.rs)
|
|
- tcptop/src/tui/draw.rs contains `Color::DarkGreen` (D-11 new connection highlight)
|
|
- tcptop/src/tui/draw.rs contains `Color::DarkRed` (D-11 closing connection highlight)
|
|
- tcptop/src/tui/event.rs contains `pub enum Action` with `Continue` and `Quit`
|
|
- tcptop/src/tui/event.rs contains `pub fn handle_event`
|
|
- tcptop/src/tui/event.rs contains `KeyEventKind::Press` (filter for press-only events)
|
|
- tcptop/src/tui/event.rs contains `KeyModifiers::CONTROL` (Ctrl-C handling)
|
|
- tcptop/src/tui/event.rs contains `InputMode::Filter` (filter state machine)
|
|
- `cargo check` succeeds (exit code 0)
|
|
</acceptance_criteria>
|
|
<done>draw.rs renders header (3-4 lines with connection counts and bandwidth per D-13), connection table (9 default columns per D-01, extra 4 via toggle per D-04), status bar (sort/filter info per D-14), and help overlay (per D-08). event.rs handles all keyboard shortcuts including mnemonic sort keys (per D-06), filter-as-you-type (per D-07), quit (per D-08), column toggle, help toggle, and row scrolling. Bandwidth coloring applied per D-10. Connection highlights for new/closing per D-11. Partial marker asterisk per D-12.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Wire TUI into main.rs with terminal lifecycle and clap CLI</name>
|
|
<read_first>
|
|
- tcptop/src/main.rs (current event loop to replace)
|
|
- tcptop/src/tui/app.rs (App::new, CliFilters)
|
|
- tcptop/src/tui/draw.rs (draw function signature)
|
|
- tcptop/src/tui/event.rs (handle_event, Action enum)
|
|
- tcptop/src/aggregator.rs (ConnectionTable::tick signature)
|
|
- .planning/phases/02-interactive-tui/02-RESEARCH.md (Pattern 2: Async Event Loop, terminal lifecycle)
|
|
</read_first>
|
|
<files>tcptop/src/main.rs</files>
|
|
<action>
|
|
1. Add clap `Parser` derive struct at the top of main.rs (or in a separate section):
|
|
```rust
|
|
use clap::Parser;
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "tcptop", about = "Real-time per-connection network monitor")]
|
|
struct Cli {
|
|
/// Filter by port (matches source or destination)
|
|
#[arg(long)]
|
|
port: Option<u16>,
|
|
|
|
/// Filter by process ID
|
|
#[arg(long)]
|
|
pid: Option<u32>,
|
|
|
|
/// Filter by process name (substring match)
|
|
#[arg(long)]
|
|
process: Option<String>,
|
|
|
|
/// Network interface to monitor
|
|
#[arg(long, short = 'i')]
|
|
interface: Option<String>,
|
|
|
|
/// Show only TCP connections
|
|
#[arg(long)]
|
|
tcp: bool,
|
|
|
|
/// Show only UDP connections
|
|
#[arg(long)]
|
|
udp: bool,
|
|
|
|
/// Refresh interval in seconds (default: 1)
|
|
#[arg(long, default_value = "1")]
|
|
interval: u64,
|
|
}
|
|
```
|
|
|
|
2. Update `main()` to parse CLI args and init terminal:
|
|
```rust
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
env_logger::init();
|
|
tcptop::privilege::check_privileges();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// Initialize terminal BEFORE entering async code
|
|
let mut terminal = ratatui::init();
|
|
let result = run_linux(&mut terminal, &cli).await;
|
|
ratatui::restore();
|
|
result?;
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
eprintln!("tcptop: eBPF collector only supported on Linux. macOS backend planned for Phase 4.");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
#[allow(unreachable_code)]
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
3. Rewrite `run_linux()` to accept terminal and CLI args:
|
|
```rust
|
|
#[cfg(target_os = "linux")]
|
|
async fn run_linux(terminal: &mut ratatui::DefaultTerminal, cli: &Cli) -> anyhow::Result<()> {
|
|
// Same collector/table setup as before
|
|
let mut collector = LinuxCollector::new()?;
|
|
let mut table = ConnectionTable::new();
|
|
|
|
// Bootstrap
|
|
match collector.bootstrap_existing() { /* same as before */ }
|
|
|
|
// Channel + spawn collector (same as before)
|
|
let (tx, mut rx) = mpsc::channel(4096);
|
|
let collector_handle = tokio::spawn(async move {
|
|
if let Err(e) = collector.start(tx).await {
|
|
log::error!("Collector error: {}", e);
|
|
}
|
|
});
|
|
|
|
// Create App state with CLI filters
|
|
let cli_filters = tcptop::tui::app::CliFilters {
|
|
port: cli.port,
|
|
pid: cli.pid,
|
|
process: cli.process.clone(),
|
|
tcp_only: cli.tcp,
|
|
udp_only: cli.udp,
|
|
};
|
|
let mut app = tcptop::tui::app::App::new(cli_filters);
|
|
|
|
// Use CLI-specified interval (DISP-05)
|
|
let mut tick = interval(Duration::from_secs(cli.interval));
|
|
|
|
// Async keyboard input stream
|
|
let mut event_stream = crossterm::event::EventStream::new();
|
|
|
|
// Signal handlers (same as before)
|
|
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
|
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
Some(event) = rx.recv() => {
|
|
table.update(event);
|
|
}
|
|
_ = tick.tick() => {
|
|
let (active, closed) = table.tick();
|
|
// Update new/closing connection highlights (D-11)
|
|
app.update_highlights(&active, &closed);
|
|
// Filter (CLI + live filter) then sort
|
|
let mut filtered = app.filter_records(&active, &app.filter_text.clone());
|
|
app.sort_records(&mut filtered);
|
|
// Clamp selection to filtered length (Pitfall 4)
|
|
if let Some(sel) = app.table_state.selected() {
|
|
if sel >= filtered.len() {
|
|
app.table_state.select(Some(filtered.len().saturating_sub(1)));
|
|
}
|
|
}
|
|
// Render
|
|
terminal.draw(|frame| {
|
|
tcptop::tui::draw::draw(frame, &mut app, &filtered);
|
|
})?;
|
|
}
|
|
Some(Ok(evt)) = event_stream.next() => {
|
|
if let tcptop::tui::event::Action::Quit = tcptop::tui::event::handle_event(&mut app, evt) {
|
|
break;
|
|
}
|
|
}
|
|
_ = sigint.recv() => break,
|
|
_ = sigterm.recv() => break,
|
|
}
|
|
}
|
|
|
|
collector_handle.abort();
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
4. Add required imports at the top of main.rs:
|
|
```rust
|
|
use futures::StreamExt; // for event_stream.next()
|
|
use clap::Parser;
|
|
```
|
|
|
|
5. Remove the `tcptop::output::print_header()` and `tcptop::output::print_tick()` calls -- they are replaced by TUI rendering.
|
|
|
|
NOTE: The `--interface` flag is parsed but not yet passed to the collector (FILT-03). The `LinuxCollector::new()` takes no args currently. Plan 02 Task 3 will wire interface filtering. For now the flag is accepted by clap but ignored with a log::warn if provided.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/zrowitsch/local_src/tcptop && cargo check 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- tcptop/src/main.rs contains `#[derive(Parser` (clap derive struct)
|
|
- tcptop/src/main.rs contains `--port` or `port: Option<u16>` (FILT-01 flag)
|
|
- tcptop/src/main.rs contains `--pid` or `pid: Option<u32>` (FILT-02 flag)
|
|
- tcptop/src/main.rs contains `--process` or `process: Option<String>` (FILT-02 flag)
|
|
- tcptop/src/main.rs contains `--interface` or `interface: Option<String>` (FILT-03 flag)
|
|
- tcptop/src/main.rs contains `--tcp` or `tcp: bool` (FILT-04 flag)
|
|
- tcptop/src/main.rs contains `--udp` or `udp: bool` (FILT-04 flag)
|
|
- tcptop/src/main.rs contains `--interval` or `interval: u64` (DISP-05 flag)
|
|
- tcptop/src/main.rs contains `ratatui::init()` (terminal initialization)
|
|
- tcptop/src/main.rs contains `ratatui::restore()` (terminal restoration)
|
|
- tcptop/src/main.rs contains `EventStream::new()` or `event_stream` (async keyboard input)
|
|
- tcptop/src/main.rs contains `event_stream.next()` (keyboard events in select loop)
|
|
- tcptop/src/main.rs contains `handle_event` (keyboard event processing)
|
|
- tcptop/src/main.rs contains `terminal.draw` (TUI rendering)
|
|
- tcptop/src/main.rs does NOT contain `print_header()` (removed)
|
|
- tcptop/src/main.rs does NOT contain `print_tick(` (removed, replaced by TUI draw)
|
|
- `cargo check` succeeds (exit code 0)
|
|
</acceptance_criteria>
|
|
<done>main.rs wired to TUI: terminal init/restore around event loop, clap Cli struct with all flags (--port, --pid, --process, --interface, --tcp, --udp, --interval), EventStream in tokio::select! for non-blocking keyboard input, render on tick with filter+sort+draw pipeline. Phase 1 stdout output replaced with ratatui rendering.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `cargo check` passes with no errors
|
|
- All files in tcptop/src/tui/ exist: mod.rs, app.rs, draw.rs, event.rs
|
|
- main.rs has clap derive struct with all 7 CLI flags
|
|
- main.rs uses ratatui::init() and ratatui::restore() for terminal lifecycle
|
|
- main.rs uses EventStream for async keyboard input in tokio::select!
|
|
- draw.rs renders 3-region layout (header, table, status bar)
|
|
- event.rs handles q, Ctrl-C, /, ?, c, sort keys, j/k/arrows, Tab/Enter
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Running `cargo check` succeeds
|
|
- TUI module exists with App state, draw functions, event handler
|
|
- main.rs replaces stdout output with TUI rendering
|
|
- All 9 default columns rendered in table (Proto, Local, Remote, PID, Process, State, Rate In, Rate Out, RTT)
|
|
- Summary header shows connection counts and aggregate bandwidth
|
|
- Status bar shows sort column and direction
|
|
- Bandwidth color coding applied (dim to bright per D-10)
|
|
- New/closing connection highlights implemented (D-11)
|
|
- Quit works via 'q' or Ctrl-C (DISP-08)
|
|
- --interval flag configures refresh rate (DISP-05)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-interactive-tui/02-01-SUMMARY.md`
|
|
</output>
|