diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..775e8f6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Build & Test Commands + +```bash +cargo build --workspace # build everything +cargo test --workspace # run all tests (188 tests) +cargo test -p prt-core core::scanner # tests for specific module +cargo test -- --nocapture # show println output +cargo clippy --workspace --all-targets # lint +cargo fmt --all -- --check # format check +cargo bench -p prt-core # criterion benchmarks +``` + +Note: `cargo` may require `export PATH="$HOME/.cargo/bin:$PATH"` on this machine. + +## Architecture + +Network port monitor with TUI interface (ratatui) for macOS and Linux. Workspace with 2 crates: + +- **prt-core** — library: model, scanner, killer, platform abstraction, i18n, session, config, known ports, alerts, suspicious detection, bandwidth, containers, namespaces, process detail, firewall +- **prt** — TUI binary (ratatui + crossterm + clap) with stream/watch/tracer/forward modules + +**Data flow:** `platform::scan_ports()` → `Session::refresh()` → `scanner::diff_entries()` (tracks New/Unchanged/Gone with first_seen carry-forward) → enrich (service names, suspicious, containers) → retain (drop Gone after 5s) → `bandwidth.sample()` → `scanner::sort_entries()` → (in App::refresh) `alerts::evaluate()` → cache invalidation → `scanner::filter_indices()` → UI renders (ViewMode-based routing) + +**Key design decisions:** +- Platform abstraction via `platform/mod.rs` with `#[cfg(target_os)]` — macOS uses `lsof` output parsing, Linux uses `/proc` via `procfs` crate +- `PortEntry` is the core data type; `TrackedEntry` wraps it with status (New/Unchanged/Gone), timestamp, and enrichment fields (first_seen, suspicious, container_name, service_name) +- Entry identity key is `(port, pid)` tuple — used in `diff_entries()` and focus stability (selection tracks by identity, not index) +- `Session` struct encapsulates the refresh/diff/retain/sort cycle — shared logic that UI delegates to +- `ViewMode` enum controls fullscreen views (Table/Chart/Topology/ProcessDetail/Namespaces); `DetailTab` enum controls bottom panel tabs (Tree/Interface/Connection) +- `ExportFormat` in core has no clap dependency; binary crate wraps it with `clap::ValueEnum` +- Gone entries are retained for 5 seconds before removal; auto-refresh every 2 seconds +- Config from `~/.config/prt/config.toml` — optional, missing file = defaults, parse error = stderr warning + defaults +- Error handling: `anyhow::Result` throughout, UI shows errors as status messages +- Caching: process detail and namespace data cached per-refresh (not per-frame) in App + +**i18n system:** `prt-core/src/i18n/` — static `Strings` structs per language (en, ru, zh), `AtomicU8` for global state. Language set via `--lang` flag, `PRT_LANG` env, or auto-detected from system locale. Compile-time completeness check: adding a field to `Strings` forces all language files to be updated. + +**macOS performance:** `platform/macos.rs` uses batch `ps` calls (`batch_ps_info`, `batch_parent_names`) — 2 total ps invocations per scan cycle instead of 4*N. This is critical for responsiveness with many connections. + +**Shared constants:** `TICK_RATE` and `GONE_RETENTION` are defined in `model.rs`. + +## Testing Patterns + +Tests are inline `#[cfg(test)] mod tests` in each module (172 in prt-core + 1 doc-test, 15 in prt = 188 total). Helper functions `make_entry()` / `make_tracked()` create test data with minimal required fields. Platform-specific parsing tests (macos.rs) run only on macOS via `#[cfg(target_os = "macos")]`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed65221..a466630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed (breaking) + +- **Top-level navigation simplified.** `ViewMode` shrinks from + `{ Table, Chart, Topology, ProcessDetail, Namespaces, SshHosts, Tunnels }` + to three sections: `Connections`, `Processes`, `Ssh`. + `Tab` / `Shift+Tab` cycles between sections. +- **Sub-tabs replace fullscreen modes.** Topology and ProcessDetail are + sub-tabs of *Processes*; SSH Hosts and Tunnels are sub-tabs of *SSH*. + Switch sub-tabs with `[` / `]`. +- **Sort moves off Tab.** `o` now picks the next sort column, `O` reverses + direction. `Tab` is reserved for section navigation. +- **Per-action shortcuts collapse into a Space-key menu.** `b` (Block IP), + `t` (Trace), `F` (SSH Forward), `p` (Copy PID), and the old fullscreen + toggles `4`/`5`/`6`/`7`/`8`/`9` are removed. Use `Space` → choose + action. Direct shortcuts remain only for `K` (Kill) and `c` (Copy). +- **Bottom Details panel is a single unified view** (no more 1/2/3 + Tree/Network/Connection tabs). Combines bind type, interface, remote, + state, cmdline, related ports, and process tree in one scroll view. +- **Esc cascade is armed for filter clear.** First press shows + "Esc again to clear filter"; a second press inside 1.5s clears. + Same guard for the tunnel form when it has unsaved input. + +### Added + +- **Action menu** opened with `Space` — contextual list (Kill / Copy / + Copy PID / Block IP / Trace / SSH forward) with j/k navigation, Enter + to execute, 1..9 to jump. +- **Tunnel real status** — `TunnelStatus { Starting, Alive, Failed }` + replaces the hard-coded "alive". Failed tunnels stay visible in the + list (red) until the user restarts or removes them. +- **Tunnel edit mode** — `e` on the selected tunnel re-opens the form + with all fields pre-filled; Enter replaces the tunnel in place. +- **Inline form validation** — bad fields turn red as you type, no need + to wait for Enter. + +### Removed + +- **Chart fullscreen view** and its `4` shortcut. +- **Namespaces fullscreen view**, `7` shortcut, App `namespace_cache`, + and the `prt_core::core::namespace` module. + ## [0.3.0] - 2026-04-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4b6439b..bba6d41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Note: `cargo` may require `export PATH="$HOME/.cargo/bin:$PATH"` on this machine Network port monitor with TUI interface (ratatui) for macOS and Linux. Workspace with 2 crates: -- **prt-core** — library: model, scanner, killer, platform abstraction, i18n, session, config, known ports, alerts, suspicious detection, bandwidth, containers, namespaces, process detail, firewall +- **prt-core** — library: model, scanner, killer, platform abstraction, i18n, session, config, known ports, alerts, suspicious detection, bandwidth, containers, process detail, firewall - **prt** — TUI binary (ratatui + crossterm + clap) with stream/watch/tracer/forward modules **Data flow:** `platform::scan_ports()` → `Session::refresh()` → `scanner::diff_entries()` (tracks New/Unchanged/Gone with first_seen carry-forward) → enrich (service names, suspicious, containers) → retain (drop Gone after 5s) → `bandwidth.sample()` → `scanner::sort_entries()` → (in App::refresh) `alerts::evaluate()` → cache invalidation → `scanner::filter_indices()` → UI renders (ViewMode-based routing) @@ -30,12 +30,12 @@ Network port monitor with TUI interface (ratatui) for macOS and Linux. Workspace - `PortEntry` is the core data type; `TrackedEntry` wraps it with status (New/Unchanged/Gone), timestamp, and enrichment fields (first_seen, suspicious, container_name, service_name) - Entry identity key is `(port, pid)` tuple — used in `diff_entries()` and focus stability (selection tracks by identity, not index) - `Session` struct encapsulates the refresh/diff/retain/sort cycle — shared logic that UI delegates to -- `ViewMode` enum controls fullscreen views (Table/Chart/Topology/ProcessDetail/Namespaces); `DetailTab` enum controls bottom panel tabs (Tree/Interface/Connection) +- `ViewMode` enum is the top-level section: `Connections` / `Processes` / `Ssh` (Tab/Shift+Tab cycles). `ProcessesTab` and `SshTab` enums drive sub-tabs (`[` / `]`). The bottom Details panel under Connections is a single unified view (no tabs). Discrete actions live in the `Space`-key `ActionItem` menu. - `ExportFormat` in core has no clap dependency; binary crate wraps it with `clap::ValueEnum` - Gone entries are retained for 5 seconds before removal; auto-refresh every 2 seconds - Config from `~/.config/prt/config.toml` — optional, missing file = defaults, parse error = stderr warning + defaults - Error handling: `anyhow::Result` throughout, UI shows errors as status messages -- Caching: process detail and namespace data cached per-refresh (not per-frame) in App +- Caching: process detail data cached per-refresh (not per-frame) in App **i18n system:** `prt-core/src/i18n/` — static `Strings` structs per language (en, ru, zh), `AtomicU8` for global state. Language set via `--lang` flag, `PRT_LANG` env, or auto-detected from system locale. Compile-time completeness check: adding a field to `Strings` forces all language files to be updated. diff --git a/Cargo.lock b/Cargo.lock index f185cd6..2f31dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,7 +1079,7 @@ dependencies = [ [[package]] name = "prt" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "arboard", @@ -1093,7 +1093,7 @@ dependencies = [ [[package]] name = "prt-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "criterion", diff --git a/Cargo.toml b/Cargo.toml index c28e6f5..3d99c3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "MIT" repository = "https://github.com/rekurt/prt" @@ -24,4 +24,4 @@ uzers = "0.11" clap = { version = "4", features = ["derive"] } toml = "0.8" dirs = "6" -prt-core = { path = "crates/prt-core", version = "0.4.0" } +prt-core = { path = "crates/prt-core", version = "0.5.0" } diff --git a/README.md b/README.md index 8ac4fa8..31e33c0 100644 --- a/README.md +++ b/README.md @@ -106,57 +106,59 @@ The header bar shows system-wide network throughput: `▼ 1.2 MB/s ▲ 340 KB/s` Press `Enter` or `d` to open the detail panel, then `1` to see the full parent chain for the selected process (e.g., `launchd → nginx → worker`). Built by traversing PPID relationships. -### Detail Panel Tabs +### Sections -The bottom panel (toggle with `Enter`/`d`) has three tabs: +`Tab` / `Shift+Tab` cycles between three top-level sections. The active +section is highlighted in the header. -| Tab | Key | Content | -|-----|-----|---------| -| **Tree** | `1` | Process parent chain | -| **Network** | `2` | Interface details, IP addresses, MTU | -| **Connection** | `3` | All connections for the selected PID | +| Section | Default content | Sub-tabs (`[` / `]`) | +|---------|-----------------|----------------------| +| **Connections** | Port table + bottom Details panel (toggle with `Enter` / `d`) | — | +| **Processes** | Selected entry's process detail (CWD, CPU %, RSS, open files, env, all connections, process tree) | Detail ⇄ Topology | +| **SSH** | Saved hosts and active tunnels in one place | Hosts ⇄ Tunnels | -### Fullscreen Views +The **Details** panel under the Connections table is a single unified view +combining bind type, interface, remote address, state, cmdline, related +ports, and the process tree — no tab switching needed. -Four dedicated views accessible with keys `4`-`7`: +The **Topology** sub-tab in Processes draws an ASCII tree +`process → :local_port → remote` for the whole working set. -| View | Key | Description | -|------|-----|-------------| -| **Chart** | `4` | Horizontal bar chart showing connection count per process | -| **Topology** | `5` | ASCII network graph: process → local port → remote host | -| **Process Detail** | `6` | Comprehensive info page: CWD, CPU %, RSS, open files, environment variables, all connections, network interfaces, process tree | -| **Namespaces** | `7` | Network namespace grouping (Linux only). Shows named namespaces from `/run/netns/` or raw inode numbers | +All scrollable views support `j`/`k` and `g`/`G`. -All fullscreen views support scrolling with `j`/`k` and `g`/`G`. Press `Esc` to return to the table. +### Action menu (`Space`) -### Firewall Quick-Block +Almost every action on the selected entry is reached through one +contextual popup, opened with `Space`: -Press `b` on a connection with a remote address to block that IP. A confirmation dialog shows the exact command that will be executed: +- **Kill process** (also bound to `K` directly) +- **Copy line** (also bound to `c` directly) / **Copy PID** +- **Block remote IP** — `iptables -A INPUT -s -j DROP` (Linux) / + `pfctl -t prt_blocked -T add ` (macOS). Status bar shows the undo + command. Requires sudo. +- **Trace syscalls** — `strace -p -e trace=network -f` (Linux) or + `dtruss -p ` (macOS, needs SIP disabled or root). Re-run to detach. +- **SSH forward** — opens the tunnel form so you can pick local port, + remote target, and host alias. -- **Linux:** `iptables -A INPUT -s -j DROP` -- **macOS:** `pfctl -t prt_blocked -T add ` +The menu only shows actions that are valid for the current entry — Block +and Forward are hidden when there's no remote address. -The status bar shows the undo command after blocking. Requires sudo privileges. +### SSH section -### Strace / Dtruss Attach +`SSH` aggregates two sub-tabs: -Press `t` to attach a system call tracer to the selected process. The detail panel splits to show a live stream of network-related syscalls: +- **Hosts** — read-only list parsed from `~/.ssh/config` plus + `[[ssh_hosts]]` entries in `~/.config/prt/config.toml`. Press `Enter` + to open the tunnel form pre-filled with the host alias. +- **Tunnels** — running tunnels with live status: 🟢 alive, 🟡 starting, + 🔴 failed (failures stay visible until you act on them). + Keys: `n` new · `e` edit · `K` kill · `r` restart · `s` save to config. -- **Linux:** `strace -p -e trace=network -f` -- **macOS:** `dtruss -p ` (requires SIP disabled or root) - -Press `t` again to detach. The tracer process is automatically killed on exit. - -### SSH Port Forwarding - -Press `F` (Shift+F) to create an SSH tunnel for the selected port. A dialog prompts for the remote host: - -``` -localhost:5432 → -host:port → user@server.io:5432█ -``` - -The tunnel is created via `ssh -N -L :localhost: `. Active tunnels are shown in the header bar (`⇄ localhost:5432 → server:22`). Tunnels are health-checked each tick and automatically killed on exit via `Drop`. +The tunnel form does **inline validation** (bad fields turn red as you +type), supports **edit-mode** (Enter replaces the existing tunnel), and +**guards Esc** — discarding a non-empty form requires a second Esc +within 1.5 seconds. ### Alert Rules @@ -254,47 +256,53 @@ sudo prt # run as root (see all processes) **Navigation:** +**Global:** + | Key | Action | |-----|--------| -| `j`/`k` `↑`/`↓` | Move selection / scroll | -| `g` / `G` | Jump to top / bottom | -| `/` | Search & filter (`!` = suspicious only) | -| `Esc` | Back to table / clear filter | +| `?` | Help (cheat sheet) | | `q` | Quit | +| `Tab` / `Shift+Tab` | Next / previous section (Connections \| Processes \| SSH) | +| `Space` | Action menu (Kill / Copy / Block / Trace / Forward) | +| `/` | Search & filter (`!` = suspicious only) | +| `Esc` | Close modal · twice to clear an active filter | +| `r` | Refresh | +| `s` | Sudo prompt | +| `L` | Cycle language | +| `j`/`k` `↑`/`↓` `g`/`G` | Move / scroll · jump to top / bottom | -**Bottom panel (Table mode):** +**Direct shortcuts (any section):** | Key | Action | |-----|--------| -| `Enter` / `d` | Toggle detail panel | -| `1` `2` `3` | Tree / Network / Connection tab | -| `←`/`→` `h`/`l` | Switch detail tab | +| `K` / `Del` | Kill selected process | +| `c` | Copy line to clipboard | -**Fullscreen views:** +**Connections section:** | Key | Action | |-----|--------| -| `4` | Chart — connections per process | -| `5` | Topology — process → port → remote | -| `6` | Process detail — info, files, env | -| `7` | Namespaces (Linux only) | +| `Enter` / `d` | Toggle bottom Details panel | +| `o` / `O` | Next sort column / reverse direction | -**Actions:** +**Processes section:** | Key | Action | |-----|--------| -| `K` / `Del` | Kill process | -| `c` | Copy line to clipboard | -| `p` | Copy PID to clipboard | -| `b` | Block remote IP (firewall) | -| `t` | Attach/detach strace | -| `F` | SSH port forward (tunnel) | -| `r` | Refresh | -| `s` | Sudo prompt | -| `Tab` | Next sort column | -| `Shift+Tab` | Reverse sort direction | -| `L` | Cycle language | -| `?` | Help | +| `[` / `]` | Switch sub-tab (Detail \| Topology) | + +**SSH section:** + +| Key | Action | +|-----|--------| +| `[` / `]` | Switch sub-tab (Hosts \| Tunnels) | +| Hosts: `Enter` | New tunnel from selected host | +| Hosts: `r` | Reload `~/.ssh/config` and prt config | +| Tunnels: `n` | Open new-tunnel form | +| Tunnels: `e` | Edit selected tunnel (kill + respawn on submit) | +| Tunnels: `K` | Kill selected tunnel | +| Tunnels: `r` | Restart selected tunnel | +| Tunnels: `s` | Save active tunnels to config | ## Configuration @@ -326,7 +334,7 @@ action = "bell" ``` crates/ ├── prt-core/ # Core library (platform-independent) -│ ├── model.rs # PortEntry, TrackedEntry, ViewMode, DetailTab, enums +│ ├── model.rs # PortEntry, TrackedEntry, ViewMode, ProcessesTab, SshTab, ActionItem │ ├── config.rs # TOML config loading (~/.config/prt/) │ ├── known_ports.rs # Well-known port → service name database │ ├── core/ @@ -397,7 +405,7 @@ This provides a fast “observe → contain → inspect” workflow. ### 3) Container port exposure audit -In container-heavy hosts, use **Topology** (`5`) and **Namespaces** (`7`) to spot +In container-heavy hosts, switch to **Processes → Topology** (`Tab` to Processes, `]` to Topology) to spot unexpected exposure (e.g., debug ports, admin APIs, accidental public binds). ### 4) Runtime feature-flag verification diff --git a/README.ru.md b/README.ru.md index 2d7d1e5..b90dcb6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -106,57 +106,63 @@ Нажмите `Enter` или `d` для открытия панели деталей, затем `1` — отобразится цепочка родительских процессов (например, `launchd → nginx → worker`). -### Вкладки панели деталей +### Разделы -Нижняя панель (переключение — `Enter`/`d`) содержит три вкладки: +`Tab` / `Shift+Tab` переключает между тремя разделами верхнего уровня. +Активный раздел подсвечен в шапке. -| Вкладка | Клавиша | Содержание | -|---------|---------|------------| -| **Дерево** | `1` | Цепочка родительских процессов | -| **Сеть** | `2` | Сетевые интерфейсы, IP-адреса, MTU | -| **Соединения** | `3` | Все соединения выбранного PID | +| Раздел | По умолчанию | Подвкладки (`[` / `]`) | +|--------|--------------|------------------------| +| **Соединения** | Таблица портов + нижняя панель Деталей (`Enter` / `d`) | — | +| **Процессы** | Детали выбранного процесса (CWD, CPU %, RSS, файлы, env, соединения, дерево процессов) | Детали ⇄ Топология | +| **SSH** | Сохранённые хосты и активные туннели в одном месте | Хосты ⇄ Туннели | -### Полноэкранные режимы +Панель **Деталей** под таблицей соединений — единый сводный вид: bind-тип, +интерфейс, удалённый адрес, состояние, cmdline, связанные порты процесса +и дерево родителей. Без вкладок. -Четыре специализированных вида по клавишам `4`-`7`: +Подвкладка **Топология** в Процессах рисует ASCII-дерево +`процесс → :локальный_порт → удалённый` для всего рабочего набора. -| Режим | Клавиша | Описание | -|-------|---------|----------| -| **График** | `4` | Горизонтальная гистограмма — количество соединений по процессам | -| **Топология** | `5` | ASCII-граф сети: процесс → локальный порт → удалённый хост | -| **Детали процесса** | `6` | Полная информация: CWD, CPU %, RSS, открытые файлы, переменные окружения, все соединения, сетевые интерфейсы, дерево процессов | -| **Namespaces** | `7` | Группировка по сетевым пространствам имён (только Linux). Показывает имена из `/run/netns/` или инод-номера | +Везде, где есть скролл, работают `j`/`k` и `g`/`G`. -Во всех режимах работает скролл (`j`/`k`, `g`/`G`). `Esc` — возврат к таблице. +### Меню действий (`Space`) -### Блокировка IP через файрвол +Почти все операции над выбранной строкой собраны в один контекстный +popup, который открывается по `Space`: -Нажмите `b` на соединении с удалённым адресом для блокировки IP. Диалог подтверждения показывает точную команду: +- **Убить процесс** (есть и прямой шорткат `K`) +- **Копировать строку** (прямой шорткат `c`) / **Копировать PID** +- **Блокировать IP** — `iptables -A INPUT -s -j DROP` (Linux) / + `pfctl -t prt_blocked -T add ` (macOS). В статусной строке — + команда отмены. Требует sudo. +- **Трассировать syscalls** — `strace -p -e trace=network -f` + (Linux) или `dtruss -p ` (macOS, нужен отключённый SIP или root). + Повторный вызов отключает трассировку. +- **SSH-туннель** — открывает форму создания туннеля с пред-заполненными + полями. -- **Linux:** `iptables -A INPUT -s -j DROP` -- **macOS:** `pfctl -t prt_blocked -T add ` +Меню показывает только применимые к строке действия — Блок и Туннель +скрыты, если у соединения нет удалённого адреса. -После блокировки в статусной строке отображается команда отмены. Требует sudo. +### Раздел SSH -### Strace / Dtruss +`SSH` объединяет две подвкладки: -Нажмите `t` для подключения трассировщика системных вызовов к выбранному процессу. Панель деталей разделяется для показа потока сетевых syscall: +- **Хосты** — read-only список из `~/.ssh/config` плюс `[[ssh_hosts]]` + из `~/.config/prt/config.toml`. `Enter` открывает форму туннеля с + предзаполненным alias. +- **Туннели** — активные туннели с реальным статусом: 🟢 активен, + 🟡 запускается, 🔴 сбой (упавшие остаются в списке, пока вы их не + обработаете). + Клавиши: `n` новый · `e` правка · `K` убить · `r` рестарт · + `s` сохранить в конфиг. -- **Linux:** `strace -p -e trace=network -f` -- **macOS:** `dtruss -p ` (требует отключённый SIP или root) - -Повторное нажатие `t` отключает трассировку. Процесс трассировщика автоматически завершается при выходе. - -### SSH-проброс портов - -Нажмите `F` (Shift+F) для создания SSH-туннеля для выбранного порта. Диалог запрашивает удалённый хост: - -``` -localhost:5432 → -хост:порт → user@server.io:5432█ -``` - -Туннель создаётся через `ssh -N -L :localhost: `. Активные туннели отображаются в заголовке (`⇄ localhost:5432 → server:22`). Туннели проверяются каждый тик и автоматически завершаются при выходе через `Drop`. +Форма туннеля делает **inline-валидацию** (некорректные поля +подсвечиваются красным сразу при вводе), поддерживает **режим правки** +(Enter заменяет существующий туннель) и **защищает от случайного +закрытия** — `Esc` на непустой форме требует второго нажатия в течение +1.5 секунд. ### Правила алертов @@ -252,49 +258,53 @@ sudo prt # запуск от root (все процессы) ## Горячие клавиши -**Навигация:** +**Глобально:** | Клавиша | Действие | |---------|----------| -| `j`/`k` `↑`/`↓` | Перемещение / скролл | -| `g` / `G` | В начало / конец | -| `/` | Поиск и фильтр (`!` = подозрительные) | -| `Esc` | Назад к таблице / сбросить фильтр | +| `?` | Справка (cheat sheet) | | `q` | Выход | +| `Tab` / `Shift+Tab` | Следующий / предыдущий раздел (Соединения \| Процессы \| SSH) | +| `Space` | Меню действий (Убить / Копировать / Блок / Трасс. / Туннель) | +| `/` | Поиск и фильтр (`!` = подозрительные) | +| `Esc` | Закрыть модал · дважды — стереть фильтр | +| `r` | Обновить | +| `s` | Sudo-пароль | +| `L` | Сменить язык | +| `j`/`k` `↑`/`↓` `g`/`G` | Перемещение / в начало / в конец | -**Нижняя панель (режим таблицы):** +**Прямые шорткаты (везде):** | Клавиша | Действие | |---------|----------| -| `Enter` / `d` | Показать/скрыть панель деталей | -| `1` `2` `3` | Дерево / Сеть / Соединение | -| `←`/`→` `h`/`l` | Переключение вкладок | +| `K` / `Del` | Убить выделенный процесс | +| `c` | Копировать строку в буфер | -**Полноэкранные режимы:** +**Раздел «Соединения»:** | Клавиша | Действие | |---------|----------| -| `4` | График — соединения по процессам | -| `5` | Топология — процесс → порт → удалённый | -| `6` | Детали процесса — инфо, файлы, env | -| `7` | Namespaces (только Linux) | +| `Enter` / `d` | Показать/скрыть нижнюю панель Деталей | +| `o` / `O` | Следующая колонка сортировки / реверс | -**Действия:** +**Раздел «Процессы»:** | Клавиша | Действие | |---------|----------| -| `K` / `Del` | Завершить процесс | -| `c` | Копировать строку | -| `p` | Копировать PID | -| `b` | Блокировать IP (firewall) | -| `t` | Подключить/отключить strace | -| `F` | SSH-проброс порта (туннель) | -| `r` | Обновить | -| `s` | Sudo пароль | -| `Tab` | След. колонка сортировки | -| `Shift+Tab` | Направление сортировки | -| `L` | Переключить язык | -| `?` | Справка | +| `[` / `]` | Переключить вкладку (Детали \| Топология) | + +**Раздел «SSH»:** + +| Клавиша | Действие | +|---------|----------| +| `[` / `]` | Переключить вкладку (Хосты \| Туннели) | +| Хосты: `Enter` | Создать туннель из выбранного хоста | +| Хосты: `r` | Перечитать `~/.ssh/config` и конфиг prt | +| Туннели: `n` | Открыть форму нового туннеля | +| Туннели: `e` | Редактировать туннель (kill + respawn при сохранении) | +| Туннели: `K` | Убить туннель | +| Туннели: `r` | Перезапустить туннель | +| Туннели: `s` | Сохранить активные туннели в конфиг | ## Конфигурация diff --git a/README.zh.md b/README.zh.md index 57d79da..337f796 100644 --- a/README.zh.md +++ b/README.zh.md @@ -106,57 +106,46 @@ 按 `Enter` 或 `d` 打开详情面板,然后按 `1` 查看选中进程的完整父进程链(如 `launchd → nginx → worker`)。 -### 详情面板标签 +### 分区 -底部面板(`Enter`/`d` 切换)有三个标签: +`Tab` / `Shift+Tab` 在三个顶级分区之间循环切换。当前分区在顶部高亮显示。 -| 标签 | 按键 | 内容 | -|------|------|------| -| **进程树** | `1` | 父进程链 | -| **网络** | `2` | 网络接口详情、IP 地址、MTU | -| **连接** | `3` | 选中 PID 的所有连接 | +| 分区 | 默认内容 | 子标签 (`[` / `]`) | +|------|----------|--------------------| +| **连接** | 端口表 + 底部详情面板 (`Enter` / `d` 切换) | — | +| **进程** | 选中条目的进程详情 (CWD、CPU %、RSS、打开的文件、env、所有连接、进程树) | 详情 ⇄ 拓扑 | +| **SSH** | 已保存的主机和活跃隧道集中在同一处 | 主机 ⇄ 隧道 | -### 全屏视图 +连接表下方的 **详情** 面板是单一统一视图,包含 bind 类型、网络接口、远程地址、状态、cmdline、相关端口和进程树 — 不需要切换标签。 -四个专用视图,通过 `4`-`7` 键访问: +进程分区的 **拓扑** 子标签为整个工作集绘制 ASCII 树 +`进程 → :本地端口 → 远程`。 -| 视图 | 按键 | 说明 | -|------|------|------| -| **图表** | `4` | 水平柱状图 — 每个进程的连接数 | -| **拓扑** | `5` | ASCII 网络拓扑:进程 → 本地端口 → 远程主机 | -| **进程详情** | `6` | 完整信息:CWD、CPU %、RSS、打开的文件、环境变量、所有连接、网络接口、进程树 | -| **命名空间** | `7` | 按网络命名空间分组(仅 Linux)。显示 `/run/netns/` 中的命名空间名称或原始 inode 编号 | +可滚动的视图都支持 `j`/`k` 和 `g`/`G`。 -所有全屏视图支持 `j`/`k` 和 `g`/`G` 滚动。按 `Esc` 返回表格。 +### 操作菜单 (`Space`) -### 防火墙快速封锁 +对选中条目的几乎所有操作都通过一个上下文相关的弹出菜单(按 `Space` 打开)完成: -在有远程地址的连接上按 `b` 封锁该 IP。确认对话框显示将执行的确切命令: +- **终止进程**(也可直接按 `K`) +- **复制行**(也可直接按 `c`) / **复制 PID** +- **封锁远程 IP** — `iptables -A INPUT -s -j DROP` (Linux) / + `pfctl -t prt_blocked -T add ` (macOS)。状态栏显示撤销命令,需要 sudo。 +- **跟踪系统调用** — `strace -p -e trace=network -f` (Linux) 或 + `dtruss -p ` (macOS,需要禁用 SIP 或 root)。再次执行可分离。 +- **SSH 转发** — 打开隧道表单,可选择本地端口、远程目标和主机别名。 -- **Linux:** `iptables -A INPUT -s -j DROP` -- **macOS:** `pfctl -t prt_blocked -T add ` +菜单只显示对当前条目有效的操作 — 没有远程地址时不会显示「封锁」和「转发」。 -封锁后状态栏显示撤销命令。需要 sudo 权限。 +### SSH 分区 -### Strace / Dtruss 附加 +`SSH` 集中了两个子标签: -按 `t` 将系统调用跟踪器附加到选中进程。详情面板分割显示网络相关系统调用的实时流: +- **主机** — 来自 `~/.ssh/config` 和 `~/.config/prt/config.toml` 的 `[[ssh_hosts]]` 的只读列表。按 `Enter` 打开预填了别名的隧道表单。 +- **隧道** — 带实时状态的活跃隧道:🟢 活跃,🟡 启动中,🔴 失败(失败的隧道会保留在列表中直到处理)。 + 按键: `n` 新建 · `e` 编辑 · `K` 终止 · `r` 重启 · `s` 保存到配置。 -- **Linux:** `strace -p -e trace=network -f` -- **macOS:** `dtruss -p `(需要禁用 SIP 或 root) - -再次按 `t` 分离。跟踪器进程在退出时自动终止。 - -### SSH 端口转发 - -按 `F`(Shift+F)为选中端口创建 SSH 隧道。对话框提示输入远程主机: - -``` -localhost:5432 → -主机:端口 → user@server.io:5432█ -``` - -隧道通过 `ssh -N -L :localhost: ` 创建。活跃隧道显示在标题栏(`⇄ localhost:5432 → server:22`)。隧道每次刷新检查健康状态,退出时通过 `Drop` 自动终止。 +隧道表单支持**实时验证**(输入时不正确的字段变红)、**编辑模式**(`Enter` 替换现有隧道),并**防止意外关闭** — 在非空表单上按 `Esc` 需要在 1.5 秒内再次按 `Esc` 才会丢弃。 ### 告警规则 @@ -252,49 +241,53 @@ sudo prt # 以 root 运行(查看所有进程) ## 快捷键 -**导航:** +**全局:** | 按键 | 操作 | |------|------| -| `j`/`k` `↑`/`↓` | 移动选择 / 滚动 | -| `g` / `G` | 跳到顶部 / 底部 | -| `/` | 搜索过滤(`!` = 仅可疑) | -| `Esc` | 返回表格 / 清除过滤 | +| `?` | 帮助 (cheat sheet) | | `q` | 退出 | +| `Tab` / `Shift+Tab` | 下一个 / 上一个分区 (连接 \| 进程 \| SSH) | +| `Space` | 操作菜单(终止 / 复制 / 封锁 / 跟踪 / 转发) | +| `/` | 搜索 / 过滤(`!` = 仅可疑) | +| `Esc` | 关闭模态框 · 再按一次清除过滤 | +| `r` | 刷新 | +| `s` | Sudo 密码 | +| `L` | 切换语言 | +| `j`/`k` `↑`/`↓` `g`/`G` | 移动 / 滚动 / 跳到顶部 / 底部 | -**底部面板(表格模式):** +**直接快捷键(任意分区):** | 按键 | 操作 | |------|------| -| `Enter` / `d` | 显示/隐藏详情面板 | -| `1` `2` `3` | 进程树 / 网络 / 连接 | -| `←`/`→` `h`/`l` | 切换标签 | +| `K` / `Del` | 终止选中进程 | +| `c` | 复制行到剪贴板 | -**全屏视图:** +**连接分区:** | 按键 | 操作 | |------|------| -| `4` | 图表 — 每进程连接数 | -| `5` | 拓扑 — 进程 → 端口 → 远程 | -| `6` | 进程详情 — 信息、文件、环境变量 | -| `7` | 命名空间(仅 Linux) | +| `Enter` / `d` | 切换底部详情面板 | +| `o` / `O` | 下一排序列 / 反转方向 | -**操作:** +**进程分区:** | 按键 | 操作 | |------|------| -| `K` / `Del` | 终止进程 | -| `c` | 复制行到剪贴板 | -| `p` | 复制 PID 到剪贴板 | -| `b` | 封锁远程 IP(防火墙) | -| `t` | 附加/分离 strace | -| `F` | SSH端口转发 (隧道) | -| `r` | 刷新 | -| `s` | Sudo 密码 | -| `Tab` | 下一排序列 | -| `Shift+Tab` | 反转排序方向 | -| `L` | 切换语言 | -| `?` | 帮助 | +| `[` / `]` | 切换子标签 (详情 \| 拓扑) | + +**SSH 分区:** + +| 按键 | 操作 | +|------|------| +| `[` / `]` | 切换子标签 (主机 \| 隧道) | +| 主机: `Enter` | 从所选主机创建新隧道 | +| 主机: `r` | 重新加载 `~/.ssh/config` 与 prt 配置 | +| 隧道: `n` | 打开新建隧道表单 | +| 隧道: `e` | 编辑选中隧道(保存时 kill + 重启) | +| 隧道: `K` | 终止选中隧道 | +| 隧道: `r` | 重启选中隧道 | +| 隧道: `s` | 保存活跃隧道到配置 | ## 配置 diff --git a/crates/prt-core/README.md b/crates/prt-core/README.md index 6cbed63..ca3484e 100644 --- a/crates/prt-core/README.md +++ b/crates/prt-core/README.md @@ -21,7 +21,6 @@ Core library for [**prt**](https://crates.io/crates/prt) — a real-time network - **Firewall** — generate iptables/pfctl block/unblock commands - **Bandwidth** — system-wide RX/TX rate tracking - **Containers** — Docker/Podman container name resolution -- **Namespaces** — Linux network namespace grouping - **Process detail** — CWD, environment, open files, CPU, RSS - **i18n** — runtime-switchable localization (English, Russian, Chinese) backed by `AtomicU8` - **Config** — TOML-based configuration from `~/.config/prt/` @@ -88,7 +87,6 @@ for entry in &session.entries { | `core::suspicious` | Suspicious connection heuristics (3 rules) | | `core::bandwidth` | System-wide RX/TX rate (Linux: /proc/net/dev, macOS: netstat -ib) | | `core::container` | Docker/Podman resolution via batched CLI calls | -| `core::namespace` | Linux network namespace grouping | | `core::process_detail` | CWD, env, open files, CPU %, RSS | | `core::firewall` | iptables/pfctl block/unblock command generation | | `core::killer` | SIGTERM / SIGKILL | diff --git a/crates/prt-core/src/core/mod.rs b/crates/prt-core/src/core/mod.rs index 0352856..79cf8b6 100644 --- a/crates/prt-core/src/core/mod.rs +++ b/crates/prt-core/src/core/mod.rs @@ -5,7 +5,6 @@ pub mod bandwidth; pub mod container; pub mod firewall; pub mod killer; -pub mod namespace; pub mod process_detail; pub mod scanner; pub mod session; diff --git a/crates/prt-core/src/core/namespace.rs b/crates/prt-core/src/core/namespace.rs deleted file mode 100644 index e5f2dea..0000000 --- a/crates/prt-core/src/core/namespace.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Network namespace awareness (Linux only). -//! -//! Groups processes by their network namespace inode. Named namespaces -//! from `/run/netns/` get human-readable labels; unnamed ones show the -//! raw inode number. -//! -//! On non-Linux platforms, all functions return empty/default results. - -use std::collections::HashMap; - -/// A network namespace with its inode and optional human-readable name. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NetNamespace { - /// The namespace inode number (unique identifier). - pub inode: u64, - /// Human-readable name from /run/netns/, if available. - pub name: Option, -} - -impl NetNamespace { - /// Display label: name if available, otherwise "ns:". - pub fn label(&self) -> String { - match &self.name { - Some(n) => n.clone(), - None => format!("ns:{}", self.inode), - } - } -} - -/// Resolve the network namespace for a given PID. -/// Returns None on non-Linux or if the namespace can't be read. -pub fn resolve_namespace(pid: u32) -> Option { - if !cfg!(target_os = "linux") { - return None; - } - let inode = read_ns_inode(pid)?; - Some(NetNamespace { inode, name: None }) -} - -/// Batch-resolve namespaces for multiple PIDs. -/// Returns a map from PID to namespace. -pub fn resolve_namespaces(pids: &[u32]) -> HashMap { - let mut result = HashMap::new(); - if !cfg!(target_os = "linux") { - return result; - } - - // First, build a map of named namespaces from /run/netns/ - let named = load_named_namespaces(); - - for &pid in pids { - if let Some(inode) = read_ns_inode(pid) { - let name = named.get(&inode).cloned(); - result.insert(pid, NetNamespace { inode, name }); - } - } - - result -} - -/// Group PIDs by their namespace inode. -/// Returns Vec<(namespace, Vec)> sorted by namespace label. -pub fn group_by_namespace(pid_ns: &HashMap) -> Vec<(NetNamespace, Vec)> { - let mut by_inode: HashMap)> = HashMap::new(); - - for (&pid, ns) in pid_ns { - by_inode - .entry(ns.inode) - .or_insert_with(|| (ns.clone(), Vec::new())) - .1 - .push(pid); - } - - let mut groups: Vec<_> = by_inode.into_values().collect(); - groups.sort_by_key(|a| a.0.label()); - for (_, pids) in &mut groups { - pids.sort(); - } - groups -} - -/// Read the network namespace inode for a PID from /proc/{pid}/ns/net. -#[allow(dead_code)] -fn read_ns_inode(pid: u32) -> Option { - let link = std::fs::read_link(format!("/proc/{pid}/ns/net")).ok()?; - let s = link.to_string_lossy(); - // Format: "net:[]" - parse_ns_inode(&s) -} - -/// Parse inode from a readlink result like "net:[4026531992]". -fn parse_ns_inode(s: &str) -> Option { - let start = s.find('[')?; - let end = s.find(']')?; - s[start + 1..end].parse().ok() -} - -/// Load named namespaces from /run/netns/. -/// Returns a map from inode → name. -/// Files in /run/netns/ are bind-mounts (not symlinks), so we use -/// metadata().ino() to get the namespace inode. -#[allow(dead_code)] -fn load_named_namespaces() -> HashMap { - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::MetadataExt; - let mut result = HashMap::new(); - if let Ok(dir) = std::fs::read_dir("/run/netns") { - for entry in dir.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if let Ok(meta) = std::fs::metadata(format!("/run/netns/{name}")) { - result.insert(meta.ino(), name); - } - } - } - result - } - - #[cfg(not(target_os = "linux"))] - HashMap::new() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_ns_inode_valid() { - assert_eq!(parse_ns_inode("net:[4026531992]"), Some(4026531992)); - } - - #[test] - fn parse_ns_inode_invalid() { - assert_eq!(parse_ns_inode("garbage"), None); - assert_eq!(parse_ns_inode("net:[]"), None); - assert_eq!(parse_ns_inode("net:[abc]"), None); - } - - #[test] - fn namespace_label_with_name() { - let ns = NetNamespace { - inode: 123, - name: Some("myns".into()), - }; - assert_eq!(ns.label(), "myns"); - } - - #[test] - fn namespace_label_without_name() { - let ns = NetNamespace { - inode: 4026531992, - name: None, - }; - assert_eq!(ns.label(), "ns:4026531992"); - } - - #[test] - fn group_by_namespace_groups_correctly() { - let mut pid_ns = HashMap::new(); - let ns1 = NetNamespace { - inode: 100, - name: Some("default".into()), - }; - let ns2 = NetNamespace { - inode: 200, - name: Some("container".into()), - }; - pid_ns.insert(1, ns1.clone()); - pid_ns.insert(2, ns1.clone()); - pid_ns.insert(3, ns2.clone()); - - let groups = group_by_namespace(&pid_ns); - assert_eq!(groups.len(), 2); - - // Sorted by label: "container" < "default" - assert_eq!(groups[0].0.name, Some("container".into())); - assert_eq!(groups[0].1, vec![3]); - assert_eq!(groups[1].0.name, Some("default".into())); - assert_eq!(groups[1].1, vec![1, 2]); - } - - #[test] - fn group_by_namespace_empty() { - let groups = group_by_namespace(&HashMap::new()); - assert!(groups.is_empty()); - } - - #[test] - fn resolve_namespaces_non_linux_returns_empty() { - if !cfg!(target_os = "linux") { - let result = resolve_namespaces(&[1, 2, 3]); - assert!(result.is_empty()); - } - } -} diff --git a/crates/prt-core/src/core/scanner.rs b/crates/prt-core/src/core/scanner.rs index d0dcf32..4fa5440 100644 --- a/crates/prt-core/src/core/scanner.rs +++ b/crates/prt-core/src/core/scanner.rs @@ -169,16 +169,61 @@ pub fn sort_entries(entries: &mut [TrackedEntry], state: &SortState) { }); } +fn matches_term(e: &TrackedEntry, term: &str) -> bool { + if term == "!" { + return !e.suspicious.is_empty(); + } + + if let Some((field, value)) = term.split_once(':') { + return matches_field_query(e, field, value); + } + + matches_plain_query(e, term) +} + +fn matches_field_query(e: &TrackedEntry, field: &str, value: &str) -> bool { + match field { + "port" => e.entry.local_port().to_string().contains(value), + "pid" => e.entry.process.pid.to_string().contains(value), + "proc" | "process" | "name" => e.entry.process.name.to_lowercase().contains(value), + "state" => e.entry.state.to_string().to_lowercase().contains(value), + "proto" | "protocol" => e.entry.protocol.to_string().to_lowercase().contains(value), + "user" => e + .entry + .process + .user + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(value), + "service" => e + .service_name + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(value), + "remote" => e + .entry + .remote_addr + .map(|a| a.to_string()) + .unwrap_or_default() + .to_lowercase() + .contains(value), + "container" => e + .container_name + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(value), + _ => matches_plain_query(e, value), + } +} + /// Returns `true` if the entry matches the query string. /// /// Matches against: port number, process name, PID, protocol, state, user. /// All comparisons are case-insensitive. -fn matches_query(e: &TrackedEntry, q: &str) -> bool { - // Special filter: "!" shows only suspicious entries - if q == "!" { - return !e.suspicious.is_empty(); - } - +fn matches_plain_query(e: &TrackedEntry, q: &str) -> bool { e.entry.local_port().to_string().contains(q) || e.entry.process.name.to_lowercase().contains(q) || e.entry.process.pid.to_string().contains(q) @@ -196,6 +241,17 @@ fn matches_query(e: &TrackedEntry, q: &str) -> bool { .unwrap_or("") .to_lowercase() .contains(q) + || e.entry + .remote_addr + .map(|a| a.to_string()) + .unwrap_or_default() + .to_lowercase() + .contains(q) + || e.container_name + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(q) } /// Filter entries by query string, returning matching entries. @@ -206,7 +262,11 @@ pub fn filter_entries<'a>(entries: &'a [TrackedEntry], query: &str) -> Vec<&'a T return entries.iter().collect(); } let q = query.to_lowercase(); - entries.iter().filter(|e| matches_query(e, &q)).collect() + let terms: Vec<&str> = q.split_whitespace().collect(); + entries + .iter() + .filter(|e| terms.iter().all(|term| matches_term(e, term))) + .collect() } /// Filter entries by query, returning indices into the original slice. @@ -216,10 +276,11 @@ pub fn filter_indices(entries: &[TrackedEntry], query: &str) -> Vec { return (0..entries.len()).collect(); } let q = query.to_lowercase(); + let terms: Vec<&str> = q.split_whitespace().collect(); entries .iter() .enumerate() - .filter(|(_, e)| matches_query(e, &q)) + .filter(|(_, e)| terms.iter().all(|term| matches_term(e, term))) .map(|(i, _)| i) .collect() } @@ -859,6 +920,44 @@ mod tests { assert_eq!(filter_indices(&entries, ""), vec![0, 1]); } + #[test] + fn filter_field_queries_match_specific_columns() { + let mut entries = vec![ + make_tracked(5432, 10, "postgres", EntryStatus::Unchanged), + make_tracked(8080, 20, "node", EntryStatus::Unchanged), + ]; + entries[0].service_name = Some("postgres".into()); + entries[0].entry.process.user = Some("db".into()); + entries[0].entry.remote_addr = Some("10.0.0.5:55000".parse().unwrap()); + entries[0].container_name = Some("db-primary".into()); + entries[1].service_name = Some("http-alt".into()); + entries[1].entry.process.user = Some("app".into()); + + assert_eq!(filter_indices(&entries, "port:5432"), vec![0]); + assert_eq!(filter_indices(&entries, "pid:20"), vec![1]); + assert_eq!(filter_indices(&entries, "proc:post"), vec![0]); + assert_eq!(filter_indices(&entries, "user:app"), vec![1]); + assert_eq!(filter_indices(&entries, "service:postgres"), vec![0]); + assert_eq!(filter_indices(&entries, "remote:10.0.0.5"), vec![0]); + assert_eq!(filter_indices(&entries, "container:primary"), vec![0]); + } + + #[test] + fn filter_multiple_terms_are_and_combined() { + let mut entries = vec![ + make_tracked(5432, 10, "postgres", EntryStatus::Unchanged), + make_tracked(5432, 20, "node", EntryStatus::Unchanged), + ]; + entries[0].entry.process.user = Some("db".into()); + entries[1].entry.process.user = Some("app".into()); + + assert_eq!(filter_indices(&entries, "port:5432 user:db"), vec![0]); + assert_eq!( + filter_indices(&entries, "port:5432 user:missing"), + Vec::::new() + ); + } + // ── export: table-driven ────────────────────────────────────── #[test] diff --git a/crates/prt-core/src/i18n/en.rs b/crates/prt-core/src/i18n/en.rs index 2de2f06..760b674 100644 --- a/crates/prt-core/src/i18n/en.rs +++ b/crates/prt-core/src/i18n/en.rs @@ -9,15 +9,16 @@ pub static STRINGS: Strings = Strings { filter_label: "filter:", search_mode: "[SEARCH]", - tab_tree: "Tree", - tab_network: "Network", - tab_connection: "Connection", + detail_panel_title: "Details", + detail_panel_tree_header: "Process tree:", no_selected_process: " no process selected", - view_chart: "Chart", + section_connections: "Connections", + section_processes: "Processes", + section_ssh: "SSH", + view_topology: "Topology", view_process: "Process", - view_namespaces: "Namespaces", process_not_found: "process not found", @@ -38,42 +39,33 @@ pub static STRINGS: Strings = Strings { conn_cmdline: " Cmdline: ", help_text: r#" - Keys: - q quit + Global: ? this help - / search / filter (! = suspicious only) - Esc back to table / clear filter + q quit + Tab / Sh+Tab next / previous section (Connections | Processes | SSH) + Space action menu (Kill / Copy / Block / Trace / Forward) + : command palette + / search / filter (Esc twice to clear) + p pause / resume auto-refresh r refresh s enter sudo password - - Navigation: - j/k Up/Down move selection - g/G jump to start / end - - Bottom panel (Table mode): - Enter/d show/hide detail panel - 1/2/3 Tree / Network / Connection tab - Left/Right switch detail tab - h/l switch detail tab - - Fullscreen views: - 4 Chart (connections per process) - 5 Topology (process -> port -> remote) - 6 Process detail (info, files, env) - 7 Namespaces (Linux only) - - Actions: - K/Del kill process - c copy line to clipboard - p copy PID to clipboard - b block remote IP (firewall) - t attach/detach strace - F SSH port forward (tunnel) - - Table: - Tab next sort column - Shift+Tab reverse sort direction L switch language + K / Del kill selected process + c copy selected line to clipboard + j/k g/G navigate / jump to start | end + + Connections (default section): + Enter open Process detail + d show / hide bottom Details panel + o / O next sort column / reverse direction + + Processes: + [ / ] switch sub-tab (Detail | Topology) + + SSH: + [ / ] switch sub-tab (Hosts | Tunnels) + Hosts Enter = new tunnel from this host, r = reload + Tunnels n = new, e = edit, K = kill, r = restart, s = save "#, kill_cancel: "[y] SIGTERM [f] SIGKILL [n/Esc] cancel", @@ -82,6 +74,13 @@ pub static STRINGS: Strings = Strings { clipboard_unavailable: "clipboard unavailable", scan_error: "scan error", cancelled: "cancelled", + paused: "auto-refresh paused", + resumed: "auto-refresh resumed", + no_connections: " no connections visible", + no_filter_matches: " no matches for filter", + more: "more", + col_age: "Age", + col_remote: "Remote", sudo_prompt_title: " Enter sudo password ", sudo_password_label: " Password: ", @@ -99,18 +98,33 @@ pub static STRINGS: Strings = Strings { hint_back: "back", hint_details: "details", - hint_views: "views", hint_sort: "sort", hint_copy: "copy", - hint_block: "block IP", - hint_trace: "trace", hint_navigate: "navigate", - hint_tabs: "tabs", + hint_section_next: "section", + hint_subtab: "tab", + hint_action_menu: "actions", + hint_edit_tunnel: "edit", + hint_pause: "pause", + hint_resume: "resume", + + action_menu_title: "Actions", + action_kill: "Kill process", + action_copy: "Copy line", + action_copy_pid: "Copy PID", + action_block: "Block remote IP", + action_trace: "Trace syscalls", + action_forward: "SSH forward", + action_unavailable_no_remote: "no remote address", + command_palette_title: "Command", + command_palette_empty: "no commands", + + esc_again_to_clear_filter: "Esc again to clear filter", + esc_again_to_discard_form: "Esc again to discard changes", forward_prompt_title: " SSH Forward ", forward_host_label: " host:port → ", forward_confirm_hint: " [Enter] create [Esc] cancel", - hint_forward: "forward", view_ssh_hosts: "SSH Hosts", view_tunnels: "Tunnels", @@ -130,6 +144,10 @@ pub static STRINGS: Strings = Strings { tunnel_col_status: "Status", tunnel_status_alive: "alive", tunnel_status_dead: "dead", + tunnel_status_starting: "starting", + tunnel_status_failed: "failed", + tunnel_form_edit_title: " Edit SSH Tunnel ", + tunnel_form_field_required: "required", tunnels_empty: " No active tunnels. Press [n] to create one.", tunnels_saved: "tunnels saved to config", tunnel_killed: "tunnel killed", @@ -147,8 +165,6 @@ pub static STRINGS: Strings = Strings { tunnel_form_hint: " [Tab] next [\u{2190}\u{2192}] kind [Enter] create [Esc] cancel", tunnel_form_invalid: "invalid tunnel form", - hint_ssh_hosts: "ssh hosts", - hint_tunnels: "tunnels", hint_new_tunnel: "new", hint_kill_tunnel: "kill", hint_restart_tunnel: "restart", diff --git a/crates/prt-core/src/i18n/mod.rs b/crates/prt-core/src/i18n/mod.rs index 746f231..2c19859 100644 --- a/crates/prt-core/src/i18n/mod.rs +++ b/crates/prt-core/src/i18n/mod.rs @@ -128,17 +128,19 @@ pub struct Strings { pub filter_label: &'static str, pub search_mode: &'static str, - // Detail tabs - pub tab_tree: &'static str, - pub tab_network: &'static str, - pub tab_connection: &'static str, + // Bottom Details panel + pub detail_panel_title: &'static str, + pub detail_panel_tree_header: &'static str, pub no_selected_process: &'static str, - // View mode labels (fullscreen views) - pub view_chart: &'static str, + // Top-level section labels + pub section_connections: &'static str, + pub section_processes: &'static str, + pub section_ssh: &'static str, + + // Sub-view labels pub view_topology: &'static str, pub view_process: &'static str, - pub view_namespaces: &'static str, // Tree view pub process_not_found: &'static str, @@ -170,6 +172,13 @@ pub struct Strings { pub scan_error: &'static str, pub cancelled: &'static str, pub lang_switched: &'static str, + pub paused: &'static str, + pub resumed: &'static str, + pub no_connections: &'static str, + pub no_filter_matches: &'static str, + pub more: &'static str, + pub col_age: &'static str, + pub col_remote: &'static str, // Sudo pub sudo_prompt_title: &'static str, @@ -190,19 +199,36 @@ pub struct Strings { // Footer hints — context-specific pub hint_back: &'static str, pub hint_details: &'static str, - pub hint_views: &'static str, pub hint_sort: &'static str, pub hint_copy: &'static str, - pub hint_block: &'static str, - pub hint_trace: &'static str, pub hint_navigate: &'static str, - pub hint_tabs: &'static str, + pub hint_section_next: &'static str, + pub hint_subtab: &'static str, + pub hint_action_menu: &'static str, + pub hint_edit_tunnel: &'static str, + pub hint_pause: &'static str, + pub hint_resume: &'static str, + + // Action menu + pub action_menu_title: &'static str, + pub action_kill: &'static str, + pub action_copy: &'static str, + pub action_copy_pid: &'static str, + pub action_block: &'static str, + pub action_trace: &'static str, + pub action_forward: &'static str, + pub action_unavailable_no_remote: &'static str, + pub command_palette_title: &'static str, + pub command_palette_empty: &'static str, + + // Esc cascade hints + pub esc_again_to_clear_filter: &'static str, + pub esc_again_to_discard_form: &'static str, // Forward dialog pub forward_prompt_title: &'static str, pub forward_host_label: &'static str, pub forward_confirm_hint: &'static str, - pub hint_forward: &'static str, // SSH hosts / tunnels views pub view_ssh_hosts: &'static str, @@ -224,6 +250,10 @@ pub struct Strings { pub tunnel_col_status: &'static str, pub tunnel_status_alive: &'static str, pub tunnel_status_dead: &'static str, + pub tunnel_status_starting: &'static str, + pub tunnel_status_failed: &'static str, + pub tunnel_form_edit_title: &'static str, + pub tunnel_form_field_required: &'static str, pub tunnels_empty: &'static str, pub tunnels_saved: &'static str, pub tunnel_killed: &'static str, @@ -243,8 +273,6 @@ pub struct Strings { pub tunnel_form_invalid: &'static str, // Footer hints — ssh views - pub hint_ssh_hosts: &'static str, - pub hint_tunnels: &'static str, pub hint_new_tunnel: &'static str, pub hint_kill_tunnel: &'static str, pub hint_restart_tunnel: &'static str, diff --git a/crates/prt-core/src/i18n/ru.rs b/crates/prt-core/src/i18n/ru.rs index 3ccd4de..285417b 100644 --- a/crates/prt-core/src/i18n/ru.rs +++ b/crates/prt-core/src/i18n/ru.rs @@ -9,15 +9,16 @@ pub static STRINGS: Strings = Strings { filter_label: "фильтр:", search_mode: "[ПОИСК]", - tab_tree: "Дерево", - tab_network: "Сеть", - tab_connection: "Соединение", + detail_panel_title: "Детали", + detail_panel_tree_header: "Дерево процессов:", no_selected_process: " нет выбранного процесса", - view_chart: "График", + section_connections: "Соединения", + section_processes: "Процессы", + section_ssh: "SSH", + view_topology: "Топология", view_process: "Процесс", - view_namespaces: "Namespaces", process_not_found: "процесс не найден", @@ -38,42 +39,33 @@ pub static STRINGS: Strings = Strings { conn_cmdline: " Cmdline: ", help_text: r#" - Клавиши: - q выход + Общие: ? эта справка - / поиск / фильтр (! = подозрительные) - Esc назад к таблице / сбросить фильтр + q выход + Tab / Sh+Tab след. / предыд. раздел (Соединения | Процессы | SSH) + Space меню действий (Убить / Копир. / Блок / Трасс. / Туннель) + : палитра команд + / поиск / фильтр (Esc дважды — стереть) + p пауза / продолжить автообновление r обновить - s ввести sudo пароль - - Навигация: - j/k ↑/↓ перемещение выбора - g/G в начало / в конец - - Нижняя панель (режим таблицы): - Enter/d показать/скрыть панель деталей - 1/2/3 Дерево / Сеть / Соединение - ←/→ переключение вкладок - h/l переключение вкладок - - Полноэкранные режимы: - 4 График (соединения по процессам) - 5 Топология (процесс → порт → удалённый) - 6 Детали процесса (инфо, файлы, env) - 7 Namespaces (только Linux) - - Действия: - K/Del завершить процесс + s ввести sudo-пароль + L сменить язык + K / Del убить выделенный процесс c копировать строку в буфер - p копировать PID в буфер - b блокировать IP (firewall) - t подключить/отключить strace - F SSH проброс порта (туннель) - - Таблица: - Tab следующая колонка сортировки - Shift+Tab изменить направление сортировки - L переключить язык + j/k g/G навигация / в начало | в конец + + Соединения (раздел по умолчанию): + Enter открыть детали процесса + d показать/скрыть панель Деталей + o / O след. колонка сорт. / реверс направления + + Процессы: + [ / ] переключить вкладку (Детали | Топология) + + SSH: + [ / ] переключить вкладку (Хосты | Туннели) + Хосты Enter = новый туннель от хоста, r = перезагрузить + Туннели n = новый, e = правка, K = убить, r = рестарт, s = сохранить "#, kill_cancel: "[y] SIGTERM [f] SIGKILL [n/Esc] отмена", @@ -82,6 +74,13 @@ pub static STRINGS: Strings = Strings { clipboard_unavailable: "буфер недоступен", scan_error: "ошибка сканирования", cancelled: "отменено", + paused: "автообновление на паузе", + resumed: "автообновление включено", + no_connections: " нет видимых соединений", + no_filter_matches: " нет совпадений по фильтру", + more: "ещё", + col_age: "Возраст", + col_remote: "Удалённый", sudo_prompt_title: " Введите пароль sudo ", sudo_password_label: " Пароль: ", @@ -99,18 +98,33 @@ pub static STRINGS: Strings = Strings { hint_back: "назад", hint_details: "детали", - hint_views: "режимы", hint_sort: "сорт.", hint_copy: "копир.", - hint_block: "блок. IP", - hint_trace: "трасс.", hint_navigate: "навиг.", - hint_tabs: "вкладки", + hint_section_next: "раздел", + hint_subtab: "вкладка", + hint_action_menu: "действия", + hint_edit_tunnel: "правка", + hint_pause: "пауза", + hint_resume: "продолж.", + + action_menu_title: "Действия", + action_kill: "Убить процесс", + action_copy: "Копировать строку", + action_copy_pid: "Копировать PID", + action_block: "Блокировать IP", + action_trace: "Трассировать syscalls", + action_forward: "SSH-туннель", + action_unavailable_no_remote: "нет удалённого адреса", + command_palette_title: "Команда", + command_palette_empty: "команд нет", + + esc_again_to_clear_filter: "Esc ещё раз — стереть фильтр", + esc_again_to_discard_form: "Esc ещё раз — отменить изменения", forward_prompt_title: " SSH-туннель ", forward_host_label: " хост:порт → ", forward_confirm_hint: " [Enter] создать [Esc] отмена", - hint_forward: "туннель", view_ssh_hosts: "SSH хосты", view_tunnels: "Туннели", @@ -130,6 +144,10 @@ pub static STRINGS: Strings = Strings { tunnel_col_status: "Статус", tunnel_status_alive: "активен", tunnel_status_dead: "мёртв", + tunnel_status_starting: "запускается", + tunnel_status_failed: "сбой", + tunnel_form_edit_title: " Правка SSH-туннеля ", + tunnel_form_field_required: "обязательно", tunnels_empty: " Активных туннелей нет. Нажмите [n] чтобы создать.", tunnels_saved: "туннели сохранены в конфиг", tunnel_killed: "туннель убит", @@ -147,8 +165,6 @@ pub static STRINGS: Strings = Strings { tunnel_form_hint: " [Tab] след. [\u{2190}\u{2192}] тип [Enter] создать [Esc] отмена", tunnel_form_invalid: "неверные поля туннеля", - hint_ssh_hosts: "SSH хосты", - hint_tunnels: "туннели", hint_new_tunnel: "новый", hint_kill_tunnel: "убить", hint_restart_tunnel: "рестарт", diff --git a/crates/prt-core/src/i18n/zh.rs b/crates/prt-core/src/i18n/zh.rs index 1426bdd..f15f563 100644 --- a/crates/prt-core/src/i18n/zh.rs +++ b/crates/prt-core/src/i18n/zh.rs @@ -9,15 +9,16 @@ pub static STRINGS: Strings = Strings { filter_label: "过滤:", search_mode: "[搜索]", - tab_tree: "进程树", - tab_network: "网络", - tab_connection: "连接", + detail_panel_title: "详情", + detail_panel_tree_header: "进程树:", no_selected_process: " 未选择进程", - view_chart: "图表", + section_connections: "连接", + section_processes: "进程", + section_ssh: "SSH", + view_topology: "拓扑", view_process: "进程详情", - view_namespaces: "命名空间", process_not_found: "未找到进程", @@ -38,42 +39,33 @@ pub static STRINGS: Strings = Strings { conn_cmdline: " 命令行: ", help_text: r#" - 快捷键: - q 退出 + 全局: ? 帮助 - / 搜索 / 过滤 (! = 可疑连接) - Esc 返回表格 / 清除过滤 + q 退出 + Tab / Sh+Tab 下 / 上 一个分区 (连接 | 进程 | SSH) + Space 操作菜单 (终止 / 复制 / 封锁 / 跟踪 / 转发) + : 命令面板 + / 搜索 / 过滤 (Esc 两次清除) + p 暂停 / 恢复自动刷新 r 刷新 - s 输入sudo密码 - - 导航: - j/k 上/下 移动选择 - g/G 跳到开头 / 结尾 - - 底部面板 (表格模式): - Enter/d 显示/隐藏详情面板 - 1/2/3 进程树 / 网络 / 连接 - 左/右 切换标签页 - h/l 切换标签页 - - 全屏模式: - 4 图表 (每进程连接数) - 5 拓扑 (进程 → 端口 → 远程) - 6 进程详情 (信息、文件、环境变量) - 7 命名空间 (仅Linux) - - 操作: - K/Del 终止进程 - c 复制行到剪贴板 - p 复制PID到剪贴板 - b 封锁远程IP (防火墙) - t 附加/分离 strace - F SSH端口转发 (隧道) - - 表格: - Tab 下一排序列 - Shift+Tab 反转排序方向 + s 输入 sudo 密码 L 切换语言 + K / Del 终止选中的进程 + c 复制行到剪贴板 + j/k g/G 导航 / 跳到开头 | 结尾 + + 连接 (默认分区): + Enter 打开进程详情 + d 显示/隐藏底部详情面板 + o / O 下一排序列 / 反转方向 + + 进程: + [ / ] 切换子标签 (详情 | 拓扑) + + SSH: + [ / ] 切换子标签 (主机 | 隧道) + 主机 Enter = 从该主机新建隧道, r = 重新加载 + 隧道 n = 新建, e = 编辑, K = 终止, r = 重启, s = 保存 "#, kill_cancel: "[y] SIGTERM [f] SIGKILL [n/Esc] 取消", @@ -82,6 +74,13 @@ pub static STRINGS: Strings = Strings { clipboard_unavailable: "剪贴板不可用", scan_error: "扫描错误", cancelled: "已取消", + paused: "自动刷新已暂停", + resumed: "自动刷新已恢复", + no_connections: " 无可见连接", + no_filter_matches: " 过滤无匹配", + more: "更多", + col_age: "时长", + col_remote: "远程", sudo_prompt_title: " 输入sudo密码 ", sudo_password_label: " 密码: ", @@ -99,18 +98,33 @@ pub static STRINGS: Strings = Strings { hint_back: "返回", hint_details: "详情", - hint_views: "视图", hint_sort: "排序", hint_copy: "复制", - hint_block: "封锁IP", - hint_trace: "跟踪", hint_navigate: "导航", - hint_tabs: "标签", + hint_section_next: "分区", + hint_subtab: "标签", + hint_action_menu: "操作", + hint_edit_tunnel: "编辑", + hint_pause: "暂停", + hint_resume: "继续", + + action_menu_title: "操作", + action_kill: "终止进程", + action_copy: "复制行", + action_copy_pid: "复制 PID", + action_block: "封锁远程 IP", + action_trace: "跟踪系统调用", + action_forward: "SSH 转发", + action_unavailable_no_remote: "无远程地址", + command_palette_title: "命令", + command_palette_empty: "无命令", + + esc_again_to_clear_filter: "再按 Esc 清除过滤", + esc_again_to_discard_form: "再按 Esc 放弃更改", forward_prompt_title: " SSH隧道 ", forward_host_label: " 主机:端口 → ", forward_confirm_hint: " [Enter] 创建 [Esc] 取消", - hint_forward: "转发", view_ssh_hosts: "SSH 主机", view_tunnels: "隧道", @@ -129,6 +143,10 @@ pub static STRINGS: Strings = Strings { tunnel_col_status: "状态", tunnel_status_alive: "活跃", tunnel_status_dead: "已断", + tunnel_status_starting: "启动中", + tunnel_status_failed: "失败", + tunnel_form_edit_title: " 编辑 SSH 隧道 ", + tunnel_form_field_required: "必填", tunnels_empty: " 无活跃隧道。按 [n] 创建。", tunnels_saved: "隧道已保存到配置", tunnel_killed: "隧道已终止", @@ -146,8 +164,6 @@ pub static STRINGS: Strings = Strings { tunnel_form_hint: " [Tab] 下一项 [\u{2190}\u{2192}] 类型 [Enter] 创建 [Esc] 取消", tunnel_form_invalid: "隧道字段无效", - hint_ssh_hosts: "SSH 主机", - hint_tunnels: "隧道", hint_new_tunnel: "新建", hint_kill_tunnel: "终止", hint_restart_tunnel: "重启", diff --git a/crates/prt-core/src/lib.rs b/crates/prt-core/src/lib.rs index d251073..4d04a6f 100644 --- a/crates/prt-core/src/lib.rs +++ b/crates/prt-core/src/lib.rs @@ -29,7 +29,7 @@ //! [`DetailTab`](model::DetailTab), [`SortState`](model::SortState). //! - [`core`] — Business logic: scanning, diffing, filtering, sorting, killing, //! session management, alerts, suspicious detection, bandwidth tracking, -//! container resolution, namespaces, process detail, firewall. +//! container resolution, process detail, firewall. //! - [`config`] — TOML configuration from `~/.config/prt/` (known port overrides, alert rules). //! - [`known_ports`] — Well-known port → service name database (~170 entries + user overrides). //! - [`i18n`] — Internationalization: runtime-switchable language support diff --git a/crates/prt-core/src/model.rs b/crates/prt-core/src/model.rs index 8db3183..63d4201 100644 --- a/crates/prt-core/src/model.rs +++ b/crates/prt-core/src/model.rs @@ -228,72 +228,101 @@ impl SortState { } } -/// Tab in the detail panel below the port table (selected process info). +/// Top-level section. Tab / Shift+Tab cycles between sections. /// -/// These tabs only appear in the bottom split panel when `ViewMode::Table` -/// is active and `show_details` is true. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DetailTab { - /// Process tree view. - Tree, - /// Network interface info. - Interface, - /// Connection details. - Connection, +/// `Connections` is the default: port table + bottom Details panel. +/// `Processes` shows the selected entry's process detail / topology. +/// `Ssh` aggregates SSH hosts and active tunnels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + /// Connections: port table + Details panel. + #[default] + Connections, + /// Processes: Detail / Topology sub-tabs. + Processes, + /// SSH: Hosts / Tunnels sub-tabs. + Ssh, } -impl DetailTab { - /// All tabs in display order. - pub const ALL: &[DetailTab] = &[DetailTab::Tree, DetailTab::Interface, DetailTab::Connection]; +impl ViewMode { + pub const ALL: &[ViewMode] = &[ViewMode::Connections, ViewMode::Processes, ViewMode::Ssh]; - /// Position of this tab in [`Self::ALL`] (0-based). - pub fn index(self) -> usize { + fn index(self) -> usize { Self::ALL .iter() - .position(|&t| t == self) - .expect("all DetailTab variants must be listed in ALL") + .position(|&m| m == self) + .expect("all ViewMode variants must be listed in ALL") } - /// Cycle to the next tab (wraps around). pub fn next(self) -> Self { Self::ALL[(self.index() + 1) % Self::ALL.len()] } - /// Cycle to the previous tab (wraps around). pub fn prev(self) -> Self { Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] } +} - /// One-based label used for the tab bar and key dispatch, e.g. `"1"`. - pub fn key_label(self) -> String { - (self.index() + 1).to_string() +/// Sub-tab inside the Processes section. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ProcessesTab { + #[default] + Detail, + Topology, +} + +impl ProcessesTab { + pub const ALL: &[ProcessesTab] = &[ProcessesTab::Detail, ProcessesTab::Topology]; + + pub fn next(self) -> Self { + match self { + ProcessesTab::Detail => ProcessesTab::Topology, + ProcessesTab::Topology => ProcessesTab::Detail, + } + } + + pub fn prev(self) -> Self { + self.next() } } -/// Main view mode — what occupies the primary screen area. +/// Action that can be invoked on the currently selected entry. /// -/// `Table` is the default: shows the port table (+ optional bottom detail panel). -/// Other modes are fullscreen and replace the table entirely. -/// Press `Esc` to return to `Table` from any other mode. +/// Triggered via the Space-key contextual menu (and a small set of direct +/// shortcuts: `K` for Kill, `c` for Copy). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionItem { + Kill, + Copy, + CopyPid, + BlockIp, + Trace, + Forward, +} + +/// Sub-tab inside the SSH section. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ViewMode { - /// Normal port table (default view). +pub enum SshTab { #[default] - Table, - /// Fullscreen bar chart: connections per process. - Chart, - /// Fullscreen network topology: process → port → remote. - Topology, - /// Fullscreen process detail: cwd, env, files, CPU/RAM, connections. - ProcessDetail, - /// Fullscreen network namespace grouping (Linux only). - Namespaces, - /// Fullscreen list of saved SSH hosts (from `~/.ssh/config` + prt config). - SshHosts, - /// Fullscreen SSH tunnels manager. + Hosts, Tunnels, } +impl SshTab { + pub const ALL: &[SshTab] = &[SshTab::Hosts, SshTab::Tunnels]; + + pub fn next(self) -> Self { + match self { + SshTab::Hosts => SshTab::Tunnels, + SshTab::Tunnels => SshTab::Hosts, + } + } + + pub fn prev(self) -> Self { + self.next() + } +} + /// Output format for CLI export mode (`--export`). /// /// Note: this enum intentionally does not derive `clap::ValueEnum` to keep @@ -386,78 +415,36 @@ mod tests { } } - // ── DetailTab cycling ───────────────────────────────────────── - #[test] - fn detail_tab_next_cycles_forward() { - let cases = [ - (DetailTab::Tree, DetailTab::Interface), - (DetailTab::Interface, DetailTab::Connection), - (DetailTab::Connection, DetailTab::Tree), - ]; - for (from, expected) in cases { - assert_eq!(from.next(), expected, "next of {:?}", from); - } + fn view_mode_default_is_connections() { + assert_eq!(ViewMode::default(), ViewMode::Connections); } #[test] - fn detail_tab_prev_cycles_backward() { + fn view_mode_next_prev_cycle() { let cases = [ - (DetailTab::Tree, DetailTab::Connection), - (DetailTab::Interface, DetailTab::Tree), - (DetailTab::Connection, DetailTab::Interface), + (ViewMode::Connections, ViewMode::Processes), + (ViewMode::Processes, ViewMode::Ssh), + (ViewMode::Ssh, ViewMode::Connections), ]; for (from, expected) in cases { - assert_eq!(from.prev(), expected, "prev of {:?}", from); - } - } - - #[test] - fn detail_tab_next_prev_roundtrip() { - for tab in DetailTab::ALL { - let tab = *tab; - assert_eq!(tab.next().prev(), tab, "roundtrip {:?}", tab); - assert_eq!(tab.prev().next(), tab, "reverse roundtrip {:?}", tab); - } - } - - #[test] - fn detail_tab_all_contains_every_variant() { - let variant_count = { - let mut n = 0u8; - for tab in DetailTab::ALL { - match tab { - DetailTab::Tree => n += 1, - DetailTab::Interface => n += 1, - DetailTab::Connection => n += 1, - } - } - n as usize - }; - assert_eq!( - DetailTab::ALL.len(), - variant_count, - "ALL must list every DetailTab variant exactly once" - ); - } - - #[test] - fn detail_tab_index_matches_position() { - for (i, &tab) in DetailTab::ALL.iter().enumerate() { - assert_eq!(tab.index(), i, "index of {:?}", tab); + assert_eq!(from.next(), expected); + assert_eq!(expected.prev(), from); } } #[test] - fn detail_tab_key_label() { - assert_eq!(DetailTab::Tree.key_label(), "1"); - assert_eq!(DetailTab::Interface.key_label(), "2"); - assert_eq!(DetailTab::Connection.key_label(), "3"); + fn processes_tab_cycle() { + assert_eq!(ProcessesTab::Detail.next(), ProcessesTab::Topology); + assert_eq!(ProcessesTab::Topology.next(), ProcessesTab::Detail); + assert_eq!(ProcessesTab::default(), ProcessesTab::Detail); } #[test] - fn view_mode_default_is_table() { - assert_eq!(ViewMode::default(), ViewMode::Table); + fn ssh_tab_cycle() { + assert_eq!(SshTab::Hosts.next(), SshTab::Tunnels); + assert_eq!(SshTab::Tunnels.next(), SshTab::Hosts); + assert_eq!(SshTab::default(), SshTab::Hosts); } // ── SortState toggle table ──────────────────────────────────── diff --git a/crates/prt/README.md b/crates/prt/README.md index f90a74e..ac0e313 100644 --- a/crates/prt/README.md +++ b/crates/prt/README.md @@ -26,13 +26,14 @@ cargo install prt | **Connection aging** | Color-coded by age (>1h yellow, >24h red, CLOSE_WAIT always red) | | **Suspicious detector** | `[!]` flags for non-root on privileged ports, scripts on sensitive ports | | **Process tree** | Full parent chain (e.g. `launchd → nginx → worker`) | -| **Detail panel** | Tree / Network / Connection tabs (`1` `2` `3`) | -| **Fullscreen views** | Chart (`4`), Topology (`5`), Process detail (`6`), Namespaces (`7`) | -| **Search & filter** | By port, service, process, PID, protocol, state, user. `!` = suspicious | -| **Kill** | Select → `K` → `y` (SIGTERM) or `f` (SIGKILL) | -| **Firewall block** | `b` → block remote IP with undo command | -| **Strace** | `t` → live syscall tracing in split panel | -| **SSH Forward** | `F` → SSH -L tunnel from selected port | +| **Sections** | `Tab` cycles Connections / Processes / SSH; sub-tabs with `[` / `]` | +| **Details panel** | Single unified panel under the table — bind, iface, remote, state, cmdline, related ports, process tree | +| **Action menu** | `Space` → contextual list (Kill / Copy / Block / Trace / Forward) | +| **Search & filter** | By port, service, process, PID, protocol, state, user. `!` = suspicious. `Esc` twice to clear | +| **Kill** | `K` → `y` (SIGTERM) or `f` (SIGKILL) | +| **Firewall block** | `Space → Block IP` — adds rule + status-bar undo command | +| **Strace** | `Space → Trace` — live syscall stream in split panel | +| **SSH Forward** | `Space → SSH forward` — opens tunnel form with inline validation | | **Containers** | Docker/Podman container name column (auto-hides) | | **Bandwidth** | System-wide RX/TX in header | | **Export** | `--export json/csv`, `--json` (NDJSON stream) | @@ -54,13 +55,13 @@ sudo prt # run as root ## Keyboard shortcuts -**Navigation:** `j`/`k` move, `g`/`G` top/bottom, `/` search, `Esc` back/clear, `q` quit +**Global:** `?` help, `q` quit, `Tab`/`Shift+Tab` next/prev section, `Space` action menu, `/` filter, `r` refresh, `s` sudo, `L` language -**Panel:** `Enter`/`d` toggle details, `1`-`3` tabs, `←`/`→` switch tabs +**Direct:** `K`/`Del` kill, `c` copy line -**Views:** `4` chart, `5` topology, `6` process detail, `7` namespaces +**Connections:** `Enter`/`d` toggle Details panel, `o`/`O` sort column / reverse -**Actions:** `K` kill, `c` copy, `p` copy PID, `b` block IP, `t` strace, `F` forward, `Tab` sort, `L` language +**Processes / SSH:** `[`/`]` switch sub-tab. SSH/Tunnels: `n` new · `e` edit · `K` kill · `r` restart · `s` save ## Architecture diff --git a/crates/prt/src/app.rs b/crates/prt/src/app.rs index 816ebb2..5d8f46a 100644 --- a/crates/prt/src/app.rs +++ b/crates/prt/src/app.rs @@ -7,16 +7,17 @@ use crossterm::ExecutableCommand; use prt_core::config; use prt_core::core::alerts::{self, AlertAction, FiredAlert}; use prt_core::core::firewall; -use prt_core::core::namespace::NetNamespace; use prt_core::core::process_detail::ProcessDetail; use prt_core::core::ssh_config::{self, SshHost}; use prt_core::core::ssh_tunnel::SshTunnelSpec; -use prt_core::core::{killer, namespace, process_detail, session::Session}; +use prt_core::core::{killer, process_detail, session::Session}; use prt_core::i18n; -use prt_core::model::{DetailTab, EntryStatus, TrackedEntry, ViewMode, TICK_RATE}; +use prt_core::model::{ProcessesTab, SshTab, TrackedEntry, ViewMode, TICK_RATE}; use crate::forward::ForwardManager; use crate::tracer::StraceSession; +use crate::views::action_menu::ActionMenu; +use crate::views::command_palette::CommandPalette; use crate::views::tunnel_form::TunnelFormState; use ratatui::prelude::*; use std::io::stdout; @@ -39,9 +40,13 @@ pub struct App { pub filter_mode: bool, pub show_help: bool, pub show_details: bool, - pub detail_tab: DetailTab, - /// Main view mode: Table, Chart, Topology, ProcessDetail, Namespaces. + pub auto_refresh_paused: bool, + /// Top-level section. pub view_mode: ViewMode, + /// Sub-tab inside the Processes section. + pub processes_tab: ProcessesTab, + /// Sub-tab inside the SSH section. + pub ssh_tab: SshTab, pub confirm_kill: Option<(u32, String)>, pub sudo_prompt: bool, pub sudo_password: String, @@ -62,9 +67,7 @@ pub struct App { pub tracer: Option, /// Cached process detail (PID → detail). Refreshed on PID change or refresh. pub detail_cache: Option<(u32, ProcessDetail)>, - /// Cached namespace data. Refreshed each scan cycle. - pub namespace_cache: Vec<(NetNamespace, Vec)>, - /// Scroll offset for fullscreen views (Chart, Topology, Namespaces). + /// Scroll offset for fullscreen views (Topology). pub scroll_offset: u16, /// Saved SSH hosts (parsed from `~/.ssh/config` + prt config). pub ssh_hosts: Vec, @@ -74,6 +77,12 @@ pub struct App { pub tunnels_selected: usize, /// Active "new tunnel" form, if any. pub tunnel_form: Option, + /// Active action menu overlay (Space-key popup), if any. + pub action_menu: Option, + pub command_palette: Option, + /// Timestamp of the last Esc press; used to arm the cascade + /// (e.g. press Esc once to be warned, twice in <1.5s to clear filter). + pub last_esc: Option, } impl App { @@ -88,8 +97,10 @@ impl App { filter_mode: false, show_help: false, show_details: true, - detail_tab: DetailTab::Tree, + auto_refresh_paused: false, view_mode: ViewMode::default(), + processes_tab: ProcessesTab::default(), + ssh_tab: SshTab::default(), confirm_kill: None, sudo_prompt: false, sudo_password: String::new(), @@ -103,12 +114,14 @@ impl App { forward_input: String::new(), tracer: None, detail_cache: None, - namespace_cache: Vec::new(), scroll_offset: 0, ssh_hosts, ssh_hosts_selected: 0, tunnels_selected: 0, tunnel_form: None, + action_menu: None, + command_palette: None, + last_esc: None, }; app.autostart_tunnels(); app @@ -162,7 +175,29 @@ impl App { /// Open the new-tunnel form, optionally pre-filling the SSH host alias. pub fn open_tunnel_form(&mut self, prefill_alias: Option) { - self.tunnel_form = Some(TunnelFormState::new(prefill_alias)); + self.tunnel_form = Some(match prefill_alias { + Some(alias) => TunnelFormState::new_from_host(alias), + None => TunnelFormState::new(None), + }); + } + + /// Open the tunnel form pre-populated from an existing tunnel for editing. + /// On submit, the form will replace the tunnel at `idx` (kill + spawn). + pub fn open_tunnel_form_edit(&mut self, idx: usize) { + if let Some(tunnel) = self.forwards.tunnels.get(idx) { + self.tunnel_form = Some(TunnelFormState::edit(&tunnel.spec, idx)); + } + } + + /// Replace the tunnel at `idx` with a new spec (kill old, spawn new). + pub fn replace_tunnel(&mut self, idx: usize, spec: SshTunnelSpec) { + let summary = spec.summary(); + let host = self.host_for_alias(&spec.host_alias).cloned(); + let s = i18n::strings(); + match self.forwards.replace_at(idx, spec, host.as_ref()) { + Ok(()) => self.set_status(format!("tunnel: {summary}")), + Err(e) => self.set_status(format!("{}: {e}", s.tunnel_create_failed)), + } } /// Spawn a tunnel from a fully-validated spec. @@ -224,6 +259,7 @@ impl App { } /// Persist the current set of active tunnels to the user's config file. + /// Failed tunnels are pruned first so the on-disk config stays clean. pub fn save_tunnels(&mut self) { let path = match config::config_path() { Some(p) => p, @@ -232,6 +268,7 @@ impl App { return; } }; + self.forwards.drop_failed(); let specs = self.forwards.specs(); let s = i18n::strings(); match config::write_tunnels(&path, &specs) { @@ -246,8 +283,6 @@ impl App { } // Evaluate alert rules self.active_alerts = alerts::evaluate(&self.session.config.alerts, &self.session.entries); - // Refresh caches - self.refresh_namespace_cache(); // Invalidate detail cache to pick up fresh data self.detail_cache = None; self.update_filtered(); @@ -437,20 +472,6 @@ impl App { self.detail_cache.as_ref().map(|(_, d)| d) } - /// Refresh namespace cache (called once per refresh cycle, not per frame). - fn refresh_namespace_cache(&mut self) { - let pids: Vec = self - .session - .entries - .iter() - .filter(|e| e.status != EntryStatus::Gone) - .map(|e| e.entry.process.pid) - .collect(); - - let ns_map = namespace::resolve_namespaces(&pids); - self.namespace_cache = namespace::group_by_namespace(&ns_map); - } - pub fn open_sudo_prompt(&mut self, purpose: SudoPurpose) { self.sudo_purpose = purpose; self.sudo_prompt = true; @@ -499,8 +520,10 @@ pub fn run() -> Result<()> { app.refresh(); loop { - // Populate detail cache if ProcessDetail view is visible (avoids per-frame fetch) - if app.view_mode == ViewMode::ProcessDetail { + // Populate detail cache when Processes/Detail view is visible (avoids per-frame fetch) + if app.view_mode == ViewMode::Processes + && app.processes_tab == prt_core::model::ProcessesTab::Detail + { app.get_process_detail(); } @@ -529,10 +552,12 @@ pub fn run() -> Result<()> { app.forwards.cleanup(); if last_tick.elapsed() >= TICK_RATE { - app.refresh(); - // Bell on alert (BEL char to terminal) - if app.should_bell() { - print!("\x07"); + if !app.auto_refresh_paused { + app.refresh(); + // Bell on alert (BEL char to terminal) + if app.should_bell() { + print!("\x07"); + } } last_tick = Instant::now(); } diff --git a/crates/prt/src/forward.rs b/crates/prt/src/forward.rs index bf53628..f55eef7 100644 --- a/crates/prt/src/forward.rs +++ b/crates/prt/src/forward.rs @@ -9,12 +9,22 @@ use std::process::{Child, Command, Stdio}; use std::thread; use std::time::Duration; +/// Lifecycle status of a tunnel, refreshed on each `cleanup()` tick. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TunnelStatus { + #[default] + Starting, + Alive, + Failed, +} + /// A single SSH tunnel: a running `ssh` child process plus the spec and /// resolved argument list (kept so `restart()` reuses the same resolution). pub struct SshTunnel { pub spec: SshTunnelSpec, args: Vec, child: Child, + pub last_status: TunnelStatus, } impl SshTunnel { @@ -24,7 +34,12 @@ impl SshTunnel { spec.validate()?; let args = spec.ssh_args(); let child = spawn_ssh_args(&args)?; - Ok(Self { spec, args, child }) + Ok(Self { + spec, + args, + child, + last_status: TunnelStatus::Starting, + }) } /// Spawn an `ssh` process for `spec`. For aliases defined only in prt's @@ -42,7 +57,12 @@ impl SshTunnel { _ => spec.ssh_args(), }; let child = spawn_ssh_args(&args)?; - Ok(Self { spec, args, child }) + Ok(Self { + spec, + args, + child, + last_status: TunnelStatus::Starting, + }) } /// Backwards-compat shortcut: spawn a Local tunnel matching the legacy @@ -64,9 +84,22 @@ impl SshTunnel { self.spec.summary() } - /// Check if the underlying ssh child is still alive. - pub fn is_alive(&mut self) -> bool { - matches!(self.child.try_wait(), Ok(None)) + /// Refresh `last_status` based on the child process state. + pub fn refresh_status(&mut self) -> TunnelStatus { + let new = match self.child.try_wait() { + Ok(None) => match self.last_status { + TunnelStatus::Starting => { + // After surviving the 150ms spawn check + at least one tick, + // promote to Alive. + TunnelStatus::Alive + } + other => other, + }, + Ok(Some(_)) => TunnelStatus::Failed, + Err(_) => TunnelStatus::Failed, + }; + self.last_status = new; + new } /// Kill the tunnel (signal + reap). @@ -79,6 +112,7 @@ impl SshTunnel { pub fn restart(&mut self) -> Result<(), String> { self.kill(); self.child = spawn_ssh_args(&self.args)?; + self.last_status = TunnelStatus::Starting; Ok(()) } } @@ -168,9 +202,20 @@ impl ForwardManager { Ok(self.tunnels.len() - 1) } - /// Remove dead tunnels. + /// Refresh each tunnel's `last_status`. Dead tunnels remain in the list + /// (with `last_status = Failed`) so the user can see what happened and + /// either restart or remove them; previously they were silently dropped. pub fn cleanup(&mut self) { - self.tunnels.retain_mut(|t| t.is_alive()); + for tunnel in &mut self.tunnels { + tunnel.refresh_status(); + } + } + + /// Drop tunnels that have already failed. Called when the user asks to + /// prune the list (e.g. via "save" which only persists running tunnels). + pub fn drop_failed(&mut self) { + self.tunnels + .retain(|t| t.last_status != TunnelStatus::Failed); } /// Kill the tunnel at `idx`. No-op if out of bounds. @@ -181,6 +226,23 @@ impl ForwardManager { } } + /// Replace the tunnel at `idx` with a fresh spec (kills + respawns). + /// Used by the form's edit-mode. + pub fn replace_at( + &mut self, + idx: usize, + spec: SshTunnelSpec, + host: Option<&SshHost>, + ) -> Result<(), String> { + if idx >= self.tunnels.len() { + return Err("no such tunnel".into()); + } + let new_tunnel = SshTunnel::spawn_with_host(spec, host)?; + // Old tunnel is killed via Drop when replaced. + self.tunnels[idx] = new_tunnel; + Ok(()) + } + /// Restart the tunnel at `idx`. pub fn restart_at(&mut self, idx: usize) -> Result<(), String> { self.tunnels diff --git a/crates/prt/src/input.rs b/crates/prt/src/input.rs index aba90f8..70f9051 100644 --- a/crates/prt/src/input.rs +++ b/crates/prt/src/input.rs @@ -1,9 +1,18 @@ use crate::app::App; use crossterm::event::{KeyCode, KeyEvent}; use prt_core::i18n; -use prt_core::model::{DetailTab, SortColumn, ViewMode}; +use prt_core::model::{ProcessesTab, SortColumn, SshTab, ViewMode}; +use std::time::{Duration, Instant}; + +/// How long the "press Esc again" prompt stays armed. +const ESC_ARM_WINDOW: Duration = Duration::from_millis(1500); pub fn handle_key(app: &mut App, key: KeyEvent) { + // Any non-Esc press disarms the cascade. + if key.code != KeyCode::Esc { + app.last_esc = None; + } + if app.show_help { app.show_help = false; return; @@ -104,27 +113,92 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } - // Let SshHosts/Tunnels views consume their own navigation/action keys - // before generic handlers (so e.g. `s` in Tunnels means "save", not "sudo"). - if app.view_mode == ViewMode::SshHosts && crate::views::ssh_hosts::handle_key(app, key) { + if crate::views::command_palette::handle_key(app, key) { + return; + } + + // Action menu overlay (highest priority after explicit forms/modals). + if crate::views::action_menu::handle_key(app, key) { return; } - if app.view_mode == ViewMode::Tunnels && crate::views::tunnels::handle_key(app, key) { + + if let KeyCode::Char(':') = key.code { + crate::views::command_palette::open(app); + return; + } + + // Space opens the action menu when nothing else is active. + if let KeyCode::Char(' ') = key.code { + crate::views::action_menu::open(app); return; } + // Section navigation: Tab / Shift+Tab cycles top-level sections. + match key.code { + KeyCode::Tab => { + app.view_mode = app.view_mode.next(); + app.scroll_offset = 0; + return; + } + KeyCode::BackTab => { + app.view_mode = app.view_mode.prev(); + app.scroll_offset = 0; + return; + } + _ => {} + } + + // Sub-tab cycling: [ / ] inside Processes and SSH. + match (app.view_mode, key.code) { + (ViewMode::Processes, KeyCode::Char('[')) => { + app.processes_tab = app.processes_tab.prev(); + app.scroll_offset = 0; + return; + } + (ViewMode::Processes, KeyCode::Char(']')) => { + app.processes_tab = app.processes_tab.next(); + app.scroll_offset = 0; + return; + } + (ViewMode::Ssh, KeyCode::Char('[')) => { + app.ssh_tab = app.ssh_tab.prev(); + return; + } + (ViewMode::Ssh, KeyCode::Char(']')) => { + app.ssh_tab = app.ssh_tab.next(); + return; + } + _ => {} + } + + // Inside SSH section, delegate to the active sub-view's handler so it can + // claim its own action keys (e.g. `s` = save in Tunnels, not sudo). + if app.view_mode == ViewMode::Ssh { + let consumed = match app.ssh_tab { + SshTab::Hosts => crate::views::ssh_hosts::handle_key(app, key), + SshTab::Tunnels => crate::views::tunnels::handle_key(app, key), + }; + if consumed { + return; + } + } + match key.code { KeyCode::Char('q') => app.should_quit = true, KeyCode::Char('?') => app.show_help = true, KeyCode::Char('/') => app.filter_mode = true, - KeyCode::Esc => { - // Esc: return to Table from fullscreen views, or clear filter - if app.view_mode != ViewMode::Table { - app.view_mode = ViewMode::Table; - app.scroll_offset = 0; - } else if !app.filter.is_empty() { + // Cascade: only meaningful Esc action at top-level is clearing a + // non-empty filter. To prevent accidental loss, require two presses. + KeyCode::Esc if !app.filter.is_empty() => { + let armed = app.last_esc.is_some_and(|t| t.elapsed() < ESC_ARM_WINDOW); + if armed { app.filter.clear(); app.update_filtered(); + app.last_esc = None; + } else { + app.last_esc = Some(Instant::now()); + let s = i18n::strings(); + app.set_status(s.esc_again_to_clear_filter.into()); } } KeyCode::Char('r') => { @@ -136,15 +210,15 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.open_sudo_prompt(crate::app::SudoPurpose::Refresh); } - // Navigation (works in all view modes) + // Navigation KeyCode::Up | KeyCode::Char('k') => { - if app.view_mode == ViewMode::Table || app.view_mode == ViewMode::ProcessDetail { + if app.view_mode == ViewMode::Connections || app.view_mode == ViewMode::Processes { app.selected = app.selected.saturating_sub(1); } app.scroll_offset = app.scroll_offset.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { - if app.view_mode == ViewMode::Table || app.view_mode == ViewMode::ProcessDetail { + if app.view_mode == ViewMode::Connections || app.view_mode == ViewMode::Processes { app.selected = (app.selected + 1).min(app.filtered_indices.len().saturating_sub(1)); } app.scroll_offset = app.scroll_offset.saturating_add(1); @@ -155,91 +229,20 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { } KeyCode::End | KeyCode::Char('G') => { app.selected = app.filtered_indices.len().saturating_sub(1); - app.scroll_offset = u16::MAX; // will be clamped by rendering - } - - // Toggle bottom detail panel (Table mode only) - KeyCode::Enter | KeyCode::Char('d') if app.view_mode == ViewMode::Table => { - app.show_details = !app.show_details; + app.scroll_offset = u16::MAX; } - // Keys 1-3: bottom panel tabs (in Table mode) - KeyCode::Char('1') if app.view_mode == ViewMode::Table => { - app.detail_tab = DetailTab::Tree; - app.show_details = true; - } - KeyCode::Char('2') if app.view_mode == ViewMode::Table => { - app.detail_tab = DetailTab::Interface; - app.show_details = true; - } - KeyCode::Char('3') if app.view_mode == ViewMode::Table => { - app.detail_tab = DetailTab::Connection; - app.show_details = true; - } - - // Keys 4-7: toggle fullscreen views (press again = back to Table) - KeyCode::Char('4') => { - app.scroll_offset = 0; - app.view_mode = if app.view_mode == ViewMode::Chart { - ViewMode::Table - } else { - ViewMode::Chart - }; - } - KeyCode::Char('5') => { - app.scroll_offset = 0; - app.view_mode = if app.view_mode == ViewMode::Topology { - ViewMode::Table - } else { - ViewMode::Topology - }; - } - KeyCode::Char('6') => { - app.scroll_offset = 0; - app.view_mode = if app.view_mode == ViewMode::ProcessDetail { - ViewMode::Table - } else { - ViewMode::ProcessDetail - }; - } - KeyCode::Char('7') => { - app.scroll_offset = 0; - app.view_mode = if app.view_mode == ViewMode::Namespaces { - ViewMode::Table - } else { - ViewMode::Namespaces - }; - } - KeyCode::Char('8') => { - app.scroll_offset = 0; - app.view_mode = if app.view_mode == ViewMode::SshHosts { - ViewMode::Table - } else { - ViewMode::SshHosts - }; - } - KeyCode::Char('9') => { + KeyCode::Enter if app.view_mode == ViewMode::Connections => { + app.view_mode = ViewMode::Processes; + app.processes_tab = ProcessesTab::Detail; app.scroll_offset = 0; - app.view_mode = if app.view_mode == ViewMode::Tunnels { - ViewMode::Table - } else { - ViewMode::Tunnels - }; } - // Left/Right: switch bottom panel tabs in Table mode - KeyCode::Right | KeyCode::Char('l') - if app.view_mode == ViewMode::Table && app.show_details => - { - app.detail_tab = app.detail_tab.next(); - } - KeyCode::Left | KeyCode::Char('h') - if app.view_mode == ViewMode::Table && app.show_details => - { - app.detail_tab = app.detail_tab.prev(); + KeyCode::Char('d') if app.view_mode == ViewMode::Connections => { + app.show_details = !app.show_details; } - // Kill + // Kill (always available, top-level shortcut) KeyCode::Char('K') | KeyCode::Delete => { if let Some(entry) = app.selected_entry() { let pid = entry.entry.process.pid; @@ -248,7 +251,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { } } - // Copy + // Copy (always available, top-level shortcut) KeyCode::Char('c') => { if let Some(entry) = app.selected_entry() { let text = format!( @@ -261,31 +264,9 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.copy_to_clipboard(&text); } } - KeyCode::Char('p') => { - if let Some(entry) = app.selected_entry() { - let text = entry.entry.process.pid.to_string(); - app.copy_to_clipboard(&text); - } - } - - // Firewall block - KeyCode::Char('b') => { - app.initiate_block(); - } - // Strace attach/detach - KeyCode::Char('t') => { - app.toggle_tracer(); - } - - // SSH port forwarding - KeyCode::Char('F') if app.selected_entry().is_some() => { - app.forward_prompt = true; - app.forward_input.clear(); - } - - // Sort (Table mode) - KeyCode::Tab if app.view_mode == ViewMode::Table => { + // Sort (Connections only): o = next column, O = reverse direction. + KeyCode::Char('o') if app.view_mode == ViewMode::Connections => { let cols = [ SortColumn::Port, SortColumn::Service, @@ -303,11 +284,28 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.session.sort.toggle(cols[next]); app.refresh(); } - KeyCode::BackTab if app.view_mode == ViewMode::Table => { + KeyCode::Char('O') if app.view_mode == ViewMode::Connections => { app.session.sort.ascending = !app.session.sort.ascending; app.refresh(); } + // Switch to Processes from Connections: ProcessesTab::Detail by default. + KeyCode::Char('P') => { + app.view_mode = ViewMode::Processes; + app.processes_tab = ProcessesTab::Detail; + app.scroll_offset = 0; + } + + KeyCode::Char('p') => { + app.auto_refresh_paused = !app.auto_refresh_paused; + let s = i18n::strings(); + app.set_status(if app.auto_refresh_paused { + s.paused.into() + } else { + s.resumed.into() + }); + } + // Language KeyCode::Char('L') => { let current = i18n::lang(); @@ -319,3 +317,50 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { _ => {} } } + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyEvent, KeyModifiers}; + use prt_core::model::ViewMode; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn enter_in_connections_opens_process_detail() { + let mut app = App::new(); + app.view_mode = ViewMode::Connections; + app.show_details = true; + + handle_key(&mut app, key(KeyCode::Enter)); + + assert_eq!(app.view_mode, ViewMode::Processes); + assert!(app.show_details); + } + + #[test] + fn d_in_connections_toggles_bottom_details_only() { + let mut app = App::new(); + app.view_mode = ViewMode::Connections; + app.show_details = true; + + handle_key(&mut app, key(KeyCode::Char('d'))); + + assert_eq!(app.view_mode, ViewMode::Connections); + assert!(!app.show_details); + } + + #[test] + fn p_toggles_auto_refresh_pause() { + let mut app = App::new(); + assert!(!app.auto_refresh_paused); + + handle_key(&mut app, key(KeyCode::Char('p'))); + assert!(app.auto_refresh_paused); + + handle_key(&mut app, key(KeyCode::Char('p'))); + assert!(!app.auto_refresh_paused); + } +} diff --git a/crates/prt/src/ui.rs b/crates/prt/src/ui.rs index 5bcb540..ea147f7 100644 --- a/crates/prt/src/ui.rs +++ b/crates/prt/src/ui.rs @@ -2,7 +2,7 @@ use crate::app::App; use prt_core::core::{bandwidth, process_detail, scanner}; use prt_core::i18n; use prt_core::model::{ - ConnectionState, DetailTab, EntryStatus, SortColumn, TrackedEntry, ViewMode, + ConnectionState, EntryStatus, ProcessesTab, SortColumn, SshTab, TrackedEntry, ViewMode, }; use ratatui::prelude::*; use ratatui::widgets::*; @@ -24,13 +24,9 @@ pub fn draw(f: &mut Frame, app: &App) { draw_help(f, chunks[1]); } else { match app.view_mode { - ViewMode::Table => draw_table_view(f, app, chunks[1]), - ViewMode::Chart => draw_chart_fullscreen(f, app, chunks[1]), - ViewMode::Topology => draw_topology_fullscreen(f, app, chunks[1]), - ViewMode::ProcessDetail => draw_process_detail_fullscreen(f, app, chunks[1]), - ViewMode::Namespaces => draw_namespaces_fullscreen(f, app, chunks[1]), - ViewMode::SshHosts => crate::views::ssh_hosts::draw(f, app, chunks[1]), - ViewMode::Tunnels => crate::views::tunnels::draw(f, app, chunks[1]), + ViewMode::Connections => draw_table_view(f, app, chunks[1]), + ViewMode::Processes => draw_processes_view(f, app, chunks[1]), + ViewMode::Ssh => draw_ssh_view(f, app, chunks[1]), } } @@ -47,6 +43,12 @@ pub fn draw(f: &mut Frame, app: &App) { if app.tunnel_form.is_some() { crate::views::tunnel_form::draw(f, app); } + if app.action_menu.is_some() { + crate::views::action_menu::draw(f, app); + } + if app.command_palette.is_some() { + crate::views::command_palette::draw(f, app); + } draw_footer(f, app, chunks[2]); } @@ -82,6 +84,20 @@ fn draw_table_view(f: &mut Frame, app: &App, area: Rect) { } } +fn draw_processes_view(f: &mut Frame, app: &App, area: Rect) { + match app.processes_tab { + ProcessesTab::Detail => draw_process_detail_fullscreen(f, app, area), + ProcessesTab::Topology => draw_topology_fullscreen(f, app, area), + } +} + +fn draw_ssh_view(f: &mut Frame, app: &App, area: Rect) { + match app.ssh_tab { + SshTab::Hosts => crate::views::ssh_hosts::draw(f, app, area), + SshTab::Tunnels => crate::views::tunnels::draw(f, app, area), + } +} + // ── Header ─────────────────────────────────────────────────────── fn draw_header(f: &mut Frame, app: &App, area: Rect) { @@ -92,8 +108,10 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::Black).bg(Color::Cyan), ), Span::raw(format!( - " {} ", - s.fmt_connections(app.filtered_indices.len()) + " {}/{} {} ", + app.filtered_indices.len(), + app.session.entries.len(), + s.connections )), ]; @@ -123,6 +141,20 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { )); } + if app.auto_refresh_paused { + parts.push(Span::styled( + " PAUSED ", + Style::default().fg(Color::Black).bg(Color::Yellow), + )); + } + + if app.tracer.is_some() { + parts.push(Span::styled( + " TRACE ", + Style::default().fg(Color::Black).bg(Color::Magenta), + )); + } + // Bandwidth indicator if let Some(rate) = &app.session.bandwidth.current_rate { parts.push(Span::styled( @@ -146,28 +178,46 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { parts.push(Span::styled(label, Style::default().fg(Color::Cyan))); } - // View mode indicator (when not in Table) - if app.view_mode != ViewMode::Table { - let label = view_mode_label(app.view_mode); + // Section breadcrumb: [Connections | Processes | Ssh] with active highlighted. + parts.push(Span::raw(" ")); + for &mode in ViewMode::ALL { + let label = section_label(mode); + let style = if mode == app.view_mode { + Style::default().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + parts.push(Span::styled(format!(" {label} "), style)); + parts.push(Span::raw(" ")); + } + // Sub-tab indicator + let sub = match app.view_mode { + ViewMode::Connections => None, + ViewMode::Processes => Some(match app.processes_tab { + ProcessesTab::Detail => i18n::strings().view_process, + ProcessesTab::Topology => i18n::strings().view_topology, + }), + ViewMode::Ssh => Some(match app.ssh_tab { + SshTab::Hosts => i18n::strings().view_ssh_hosts, + SshTab::Tunnels => i18n::strings().view_tunnels, + }), + }; + if let Some(sub) = sub { parts.push(Span::styled( - format!(" [{label}] "), - Style::default().fg(Color::Black).bg(Color::Yellow), + format!("[{sub}]"), + Style::default().fg(Color::Cyan), )); } f.render_widget(Line::from(parts), area); } -fn view_mode_label(mode: ViewMode) -> &'static str { +fn section_label(mode: ViewMode) -> &'static str { let s = i18n::strings(); match mode { - ViewMode::Table => "", - ViewMode::Chart => s.view_chart, - ViewMode::Topology => s.view_topology, - ViewMode::ProcessDetail => s.view_process, - ViewMode::Namespaces => s.view_namespaces, - ViewMode::SshHosts => s.view_ssh_hosts, - ViewMode::Tunnels => s.view_tunnels, + ViewMode::Connections => s.section_connections, + ViewMode::Processes => s.section_processes, + ViewMode::Ssh => s.section_ssh, } } @@ -236,10 +286,37 @@ fn show_container_column(width: u16, app: &App) -> bool { .any(|e| e.container_name.is_some()) } +fn show_age_column(width: u16) -> bool { + width > 100 +} + +fn show_remote_column(width: u16) -> bool { + width > 120 +} + +fn format_age(first_seen: Option, now: Instant) -> String { + let Some(first_seen) = first_seen else { + return "-".into(); + }; + let secs = now.duration_since(first_seen).as_secs(); + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86400 { + format!("{}h", secs / 3600) + } else { + format!("{}d", secs / 86400) + } +} + // ── Port table ─────────────────────────────────────────────────── fn draw_table(f: &mut Frame, app: &App, area: Rect) { + let s = i18n::strings(); let wide = show_service_column(area.width); + let show_age = show_age_column(area.width); + let show_remote = show_remote_column(area.width); let show_container = show_container_column(area.width, app); let mut header_cells = vec![Cell::from(format!( @@ -268,6 +345,12 @@ fn draw_table(f: &mut Frame, app: &App, area: Rect) { if show_container { header_cells.push(Cell::from("Container")); } + if show_age { + header_cells.push(Cell::from(s.col_age)); + } + if show_remote { + header_cells.push(Cell::from(s.col_remote)); + } let header = Row::new(header_cells).style( Style::default() @@ -276,6 +359,19 @@ fn draw_table(f: &mut Frame, app: &App, area: Rect) { ); let now = Instant::now(); + if app.filtered_indices.is_empty() { + let message = if app.filter.is_empty() { + s.no_connections.to_string() + } else { + format!("{}: {}", s.no_filter_matches, app.filter) + }; + f.render_widget( + Paragraph::new(message).style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + let rows: Vec = app .filtered_indices .iter() @@ -309,6 +405,17 @@ fn draw_table(f: &mut Frame, app: &App, area: Rect) { e.container_name.as_deref().unwrap_or("-").to_string(), )); } + if show_age { + cells.push(Cell::from(format_age(e.first_seen, now))); + } + if show_remote { + cells.push(Cell::from( + e.entry + .remote_addr + .map(|a| a.to_string()) + .unwrap_or_else(|| "-".into()), + )); + } Row::new(cells).style(style) }) @@ -328,6 +435,12 @@ fn draw_table(f: &mut Frame, app: &App, area: Rect) { if show_container { widths.push(Constraint::Length(15)); } + if show_age { + widths.push(Constraint::Length(7)); + } + if show_remote { + widths.push(Constraint::Length(24)); + } let table = Table::new(rows, widths) .header(header) @@ -342,98 +455,26 @@ fn draw_table(f: &mut Frame, app: &App, area: Rect) { // ── Bottom detail panel (Table mode only) ──────────────────────── fn draw_detail_panel(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) - .split(area); - - draw_tab_bar(f, app, chunks[0]); - - let block = Block::default().borders(Borders::ALL & !Borders::TOP); - let inner = block.inner(chunks[1]); - f.render_widget(block, chunks[1]); - - match app.detail_tab { - DetailTab::Tree => draw_tab_tree(f, app, inner), - DetailTab::Interface => draw_tab_interface(f, app, inner), - DetailTab::Connection => draw_tab_connection(f, app, inner), - } -} - -fn tab_label(tab: DetailTab) -> &'static str { - let s = i18n::strings(); - match tab { - DetailTab::Tree => s.tab_tree, - DetailTab::Interface => s.tab_network, - DetailTab::Connection => s.tab_connection, - } -} - -fn draw_tab_bar(f: &mut Frame, app: &App, area: Rect) { - let mut spans = vec![Span::raw("\u{250c}")]; - for &tab in DetailTab::ALL { - let key = tab.key_label(); - let label = tab_label(tab); - let active = tab == app.detail_tab; - let style = if active { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - spans.push(Span::styled(format!(" {key}:{label} "), style)); - } - spans.push(Span::styled( - "\u{2500}".repeat( - area.width - .saturating_sub(spans.iter().map(|s| s.width() as u16).sum::()) - as usize, - ), - Style::default().fg(Color::DarkGray), - )); - - f.render_widget(Line::from(spans), area); -} - -// ── Detail tab: Tree ───────────────────────────────────────────── - -fn draw_tab_tree(f: &mut Frame, app: &App, area: Rect) { let s = i18n::strings(); - let entry = match app.selected_entry() { - Some(e) => e, - None => { - f.render_widget(Paragraph::new(s.no_selected_process), area); - return; - } - }; - - let tree_lines = scanner::build_process_tree(&app.session.entries, entry.entry.process.pid); - let lines: Vec = tree_lines - .iter() - .map(|l| Line::from(format!(" {l}"))) - .collect(); - - f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); -} - -// ── Detail tab: Interface ──────────────────────────────────────── + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", s.detail_panel_title)) + .title_alignment(Alignment::Left) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = block.inner(area); + f.render_widget(block, area); -fn draw_tab_interface(f: &mut Frame, app: &App, area: Rect) { - let s = i18n::strings(); let entry = match app.selected_entry() { Some(e) => e, None => { - f.render_widget(Paragraph::new(s.no_selected_process), area); + f.render_widget(Paragraph::new(s.no_selected_process), inner); return; } }; let e = &entry.entry; + let p = &e.process; let iface = scanner::resolve_interface(&e.local_addr); - let ip_version = if e.local_addr.is_ipv4() { - "IPv4" - } else { - "IPv6" - }; let bind_type = if e.local_addr.ip().is_loopback() { s.iface_localhost_only } else if e.local_addr.ip().is_unspecified() { @@ -441,60 +482,23 @@ fn draw_tab_interface(f: &mut Frame, app: &App, area: Rect) { } else { s.iface_specific }; + let remote = e + .remote_addr + .map(|a| a.to_string()) + .unwrap_or_else(|| "-".into()); - let lines = vec![ + let mut lines = vec![ Line::from(vec![ - Span::styled(s.iface_address, Style::default().fg(Color::Cyan)), - Span::raw(e.local_addr.to_string()), + Span::styled(s.conn_local, Style::default().fg(Color::Cyan)), + Span::raw(format!("{} {} ({})", e.local_addr, e.protocol, bind_type)), ]), Line::from(vec![ Span::styled(s.iface_interface, Style::default().fg(Color::Cyan)), Span::raw(iface), ]), - Line::from(vec![ - Span::styled(s.iface_protocol, Style::default().fg(Color::Cyan)), - Span::raw(format!("{} / {}", e.protocol, ip_version)), - ]), - Line::from(vec![ - Span::styled(s.iface_bind, Style::default().fg(Color::Cyan)), - Span::raw(bind_type), - ]), - ]; - - f.render_widget(Paragraph::new(lines), area); -} - -// ── Detail tab: Connection ─────────────────────────────────────── - -fn draw_tab_connection(f: &mut Frame, app: &App, area: Rect) { - let s = i18n::strings(); - let entry = match app.selected_entry() { - Some(e) => e, - None => { - f.render_widget(Paragraph::new(s.no_selected_process), area); - return; - } - }; - - let e = &entry.entry; - let p = &e.process; - - let mut lines = vec![ - Line::from(vec![ - Span::styled(s.conn_local, Style::default().fg(Color::Cyan)), - Span::raw(e.local_addr.to_string()), - ]), Line::from(vec![ Span::styled(s.conn_remote, Style::default().fg(Color::Cyan)), - Span::raw( - e.remote_addr - .map(|a| a.to_string()) - .unwrap_or_else(|| "-".into()), - ), - ]), - Line::from(vec![ - Span::styled(s.conn_state, Style::default().fg(Color::Cyan)), - Span::raw(e.state.to_string()), + Span::raw(format!("{} [{}]", remote, e.state)), ]), Line::from(vec![ Span::styled(s.conn_process, Style::default().fg(Color::Cyan)), @@ -513,7 +517,6 @@ fn draw_tab_connection(f: &mut Frame, app: &App, area: Rect) { s.fmt_all_ports(conns.len()), Style::default().fg(Color::DarkGray), ))); - for conn in &conns { let c = &conn.entry; let arrow = c @@ -530,67 +533,19 @@ fn draw_tab_connection(f: &mut Frame, app: &App, area: Rect) { } } - f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); -} - -// ── Fullscreen: Chart (connections per process) ────────────────── - -fn draw_chart_fullscreen(f: &mut Frame, app: &App, area: Rect) { - let s = i18n::strings(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" {} ", s.view_chart)) - .title_alignment(Alignment::Left) - .border_style(Style::default().fg(Color::Cyan)); - - let inner = block.inner(area); - f.render_widget(block, area); - - // Also render the table at top with selection, chart below - if app.session.entries.is_empty() { - f.render_widget(Paragraph::new(s.no_selected_process), inner); - return; - } - - // Group connections per process name - let counts: Vec<(String, usize)> = { - let mut map: std::collections::HashMap = std::collections::HashMap::new(); - for e in &app.session.entries { - if e.status != EntryStatus::Gone { - *map.entry(e.entry.process.name.clone()).or_insert(0) += 1; - } + let tree_lines = scanner::build_process_tree(&app.session.entries, p.pid); + if !tree_lines.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + s.detail_panel_tree_header, + Style::default().fg(Color::DarkGray), + ))); + for l in &tree_lines { + lines.push(Line::from(format!(" {l}"))); } - let mut v: Vec<_> = map.into_iter().collect(); - v.sort_by_key(|b| std::cmp::Reverse(b.1)); - v - }; - let max = counts.first().map(|c| c.1).unwrap_or(1).max(1); - let bar_width = inner.width.saturating_sub(25) as usize; - - let lines: Vec = counts - .iter() - .map(|(name, count)| { - let bar_len = (*count as f64 / max as f64 * bar_width as f64).round() as usize; - let bar = "\u{2588}".repeat(bar_len); - let name_display = if name.len() > 14 { - format!("{:.14}", name) - } else { - format!("{:<14}", name) - }; - Line::from(vec![ - Span::styled( - format!(" {name_display} "), - Style::default().fg(Color::Cyan), - ), - Span::styled(bar, Style::default().fg(Color::Green)), - Span::raw(format!(" {count}")), - ]) - }) - .collect(); + } - let max_scroll = (lines.len() as u16).saturating_sub(inner.height); - let scroll = app.scroll_offset.min(max_scroll); - f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); } // ── Fullscreen: Topology (process → port → remote) ────────────── @@ -902,79 +857,6 @@ fn draw_process_detail_fullscreen(f: &mut Frame, app: &App, area: Rect) { f.render_widget(Paragraph::new(right_lines), columns[1]); } -// ── Fullscreen: Namespaces ─────────────────────────────────────── - -fn draw_namespaces_fullscreen(f: &mut Frame, app: &App, area: Rect) { - let s = i18n::strings(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" {} ", s.view_namespaces)) - .title_alignment(Alignment::Left) - .border_style(Style::default().fg(Color::Cyan)); - - let inner = block.inner(area); - f.render_widget(block, area); - - if !cfg!(target_os = "linux") { - f.render_widget( - Paragraph::new(" Network namespaces are only available on Linux"), - inner, - ); - return; - } - - if app.session.entries.is_empty() { - f.render_widget(Paragraph::new(s.no_selected_process), inner); - return; - } - - if app.namespace_cache.is_empty() { - f.render_widget( - Paragraph::new(" No namespace information available (requires /proc access)"), - inner, - ); - return; - } - - let mut lines: Vec = Vec::new(); - for (ns, group_pids) in &app.namespace_cache { - lines.push(Line::from(vec![Span::styled( - format!(" {} ({} processes)", ns.label(), group_pids.len()), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])); - - for &pid in group_pids.iter().take(12) { - let name = app - .session - .entries - .iter() - .find(|e| e.entry.process.pid == pid) - .map(|e| e.entry.process.name.as_str()) - .unwrap_or("?"); - lines.push(Line::from(format!( - " \u{251c}\u{2500} {name} (PID {pid})" - ))); - } - if group_pids.len() > 12 { - lines.push(Line::from(Span::styled( - format!(" \u{2514}\u{2500} ... +{} more", group_pids.len() - 12), - Style::default().fg(Color::DarkGray), - ))); - } - } - - let max_scroll = (lines.len() as u16).saturating_sub(inner.height); - let scroll = app.scroll_offset.min(max_scroll); - f.render_widget( - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)), - inner, - ); -} - // ── Tracer panel (strace split) ────────────────────────────────── fn draw_tracer_panel(f: &mut Frame, app: &App, area: Rect) { @@ -1173,6 +1055,31 @@ fn hint_accent(hints: &mut Vec>, key: &str, color: Color) { )); } +fn hint_cost(key: &str, label: &str) -> usize { + key.chars().count() + label.chars().count() + 4 +} + +fn push_budgeted_hints( + hints: &mut Vec>, + items: &[(&'static str, &'static str)], + max_width: u16, + more_label: &'static str, +) { + let mut used = 0usize; + let more_cost = hint_cost("?", more_label); + for (idx, (key, label)) in items.iter().enumerate() { + let cost = hint_cost(key, label); + let has_more = idx + 1 < items.len(); + let reserved = if has_more { more_cost } else { 0 }; + if used + cost + reserved > max_width as usize { + hint(hints, "?", more_label); + return; + } + hint(hints, key, label); + used += cost; + } +} + fn draw_footer(f: &mut Frame, app: &App, area: Rect) { let s = i18n::strings(); @@ -1203,68 +1110,63 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) { } } - let mut hints: Vec = Vec::new(); + let mut items: Vec<(&'static str, &'static str)> = vec![ + ("?", s.hint_help), + ("Tab", s.hint_section_next), + ("/", s.hint_search), + ("Space", s.hint_action_menu), + ( + "p", + if app.auto_refresh_paused { + s.hint_resume + } else { + s.hint_pause + }, + ), + ]; match app.view_mode { - ViewMode::Table => { - // Table mode: context depends on whether detail panel is open - hint(&mut hints, "?", s.hint_help); - hint(&mut hints, "/", s.hint_search); - if app.show_details { - hint(&mut hints, "\u{2190}\u{2192}", s.hint_tabs); - } else { - hint(&mut hints, "d", s.hint_details); - } - hint(&mut hints, "4-7", s.hint_views); - hint(&mut hints, "8", s.hint_ssh_hosts); - hint(&mut hints, "9", s.hint_tunnels); - hint(&mut hints, "K", s.hint_kill); - hint(&mut hints, "F", s.hint_forward); - hint(&mut hints, "Tab", s.hint_sort); - if !app.session.is_root && !app.session.is_elevated { - hint(&mut hints, "s", s.hint_sudo); + ViewMode::Connections => { + if !app.show_details { + items.push(("d", s.hint_details)); } - hint(&mut hints, "q", s.hint_quit); + items.push(("Enter", s.view_process)); + items.push(("K", s.hint_kill)); + items.push(("c", s.hint_copy)); + items.push(("o/O", s.hint_sort)); } - ViewMode::SshHosts => { - hint(&mut hints, "Esc", s.hint_back); - hint(&mut hints, "j/k", s.hint_navigate); - hint(&mut hints, "Enter", s.hint_open_tunnel); - hint(&mut hints, "r", s.hint_reload); - hint(&mut hints, "?", s.hint_help); - hint(&mut hints, "q", s.hint_quit); + ViewMode::Processes => { + items.push(("[ ]", s.hint_subtab)); + items.push(("K", s.hint_kill)); + items.push(("c", s.hint_copy)); } - ViewMode::Tunnels => { - hint(&mut hints, "Esc", s.hint_back); - hint(&mut hints, "j/k", s.hint_navigate); - hint(&mut hints, "n", s.hint_new_tunnel); - hint(&mut hints, "K", s.hint_kill_tunnel); - hint(&mut hints, "r", s.hint_restart_tunnel); - hint(&mut hints, "s", s.hint_save_tunnels); - hint(&mut hints, "?", s.hint_help); - hint(&mut hints, "q", s.hint_quit); - } - _ => { - // Fullscreen views: Esc is prominent, then relevant actions - hint(&mut hints, "Esc", s.hint_back); - hint(&mut hints, "j/k", s.hint_navigate); - hint(&mut hints, "/", s.hint_search); - if matches!( - app.view_mode, - ViewMode::ProcessDetail | ViewMode::Chart | ViewMode::Topology - ) { - hint(&mut hints, "K", s.hint_kill); - } - if app.view_mode == ViewMode::ProcessDetail { - hint(&mut hints, "c", s.hint_copy); - hint(&mut hints, "t", s.hint_trace); + ViewMode::Ssh => { + items.push(("[ ]", s.hint_subtab)); + match app.ssh_tab { + SshTab::Hosts => { + items.push(("Enter", s.hint_open_tunnel)); + items.push(("r", s.hint_reload)); + } + SshTab::Tunnels => { + items.push(("n", s.hint_new_tunnel)); + items.push(("e", s.hint_edit_tunnel)); + items.push(("K", s.hint_kill_tunnel)); + items.push(("r", s.hint_restart_tunnel)); + items.push(("s", s.hint_save_tunnels)); + } } - hint(&mut hints, "?", s.hint_help); - hint(&mut hints, "q", s.hint_quit); } } - // Language badge (always rightmost) + if !app.session.is_root && !app.session.is_elevated && app.view_mode == ViewMode::Connections { + items.push(("s", s.hint_sudo)); + } + items.push(("q", s.hint_quit)); + + let mut hints: Vec = Vec::new(); + let lang_width = i18n::lang().label().chars().count() + 4; + let max_hint_width = area.width.saturating_sub(lang_width as u16); + push_budgeted_hints(&mut hints, &items, max_hint_width, s.more); hints.push(Span::raw(" ")); hint_accent(&mut hints, i18n::lang().label(), Color::Magenta); diff --git a/crates/prt/src/views/action_menu.rs b/crates/prt/src/views/action_menu.rs new file mode 100644 index 0000000..50d97df --- /dev/null +++ b/crates/prt/src/views/action_menu.rs @@ -0,0 +1,274 @@ +//! Context-aware action menu, opened with `Space` over the selected entry. +//! +//! Builds a list of [`ActionItem`]s relevant to the current view and the +//! capabilities of the selected entry (e.g. `BlockIp` and `Forward` only +//! appear when the entry has a remote address). + +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent}; +use prt_core::i18n; +use prt_core::model::{ActionItem, ViewMode}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + +#[derive(Debug, Clone)] +pub struct ActionMenu { + pub items: Vec, + pub selected: usize, +} + +#[derive(Debug, Clone, Copy)] +pub struct ActionMenuEntry { + pub item: ActionItem, + pub enabled: bool, + pub reason: Option<&'static str>, +} + +impl ActionMenu { + pub fn for_app(app: &App) -> Option { + let entry = app.selected_entry()?; + let has_remote = entry.entry.remote_addr.is_some(); + + let items = match app.view_mode { + ViewMode::Connections => { + let s = i18n::strings(); + let mut v = vec![ + enabled(ActionItem::Kill), + enabled(ActionItem::Copy), + enabled(ActionItem::CopyPid), + ]; + v.push(if has_remote { + enabled(ActionItem::BlockIp) + } else { + disabled(ActionItem::BlockIp, s.action_unavailable_no_remote) + }); + v.push(if has_remote { + enabled(ActionItem::Forward) + } else { + disabled(ActionItem::Forward, s.action_unavailable_no_remote) + }); + v.push(enabled(ActionItem::Trace)); + v + } + ViewMode::Processes => { + vec![ + enabled(ActionItem::Kill), + enabled(ActionItem::Copy), + enabled(ActionItem::CopyPid), + enabled(ActionItem::Trace), + ] + } + ViewMode::Ssh => return None, + }; + + Some(Self { items, selected: 0 }) + } + + fn move_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } else { + self.selected = self.items.len().saturating_sub(1); + } + } + + fn move_down(&mut self) { + if self.selected + 1 < self.items.len() { + self.selected += 1; + } else { + self.selected = 0; + } + } +} + +pub fn open(app: &mut App) { + if app.action_menu.is_some() { + return; + } + if let Some(menu) = ActionMenu::for_app(app) { + app.action_menu = Some(menu); + } +} + +/// Returns true when the key was consumed by the menu (incl. closing it). +pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { + let Some(menu) = app.action_menu.as_mut() else { + return false; + }; + + match key.code { + KeyCode::Esc => { + app.action_menu = None; + } + KeyCode::Up | KeyCode::Char('k') => menu.move_up(), + KeyCode::Down | KeyCode::Char('j') => menu.move_down(), + KeyCode::Char(c) if c.is_ascii_digit() => { + let idx = (c as u8 - b'0') as usize; + if idx >= 1 && idx <= menu.items.len() { + menu.selected = idx - 1; + let entry = menu.items[menu.selected]; + if !entry.enabled { + return true; + } + app.action_menu = None; + execute(app, entry.item); + } + } + KeyCode::Enter => { + let entry = menu.items[menu.selected]; + if !entry.enabled { + return true; + } + app.action_menu = None; + execute(app, entry.item); + } + _ => {} + } + true +} + +fn execute(app: &mut App, item: ActionItem) { + match item { + ActionItem::Kill => { + if let Some(entry) = app.selected_entry() { + let pid = entry.entry.process.pid; + let name = entry.entry.process.name.clone(); + app.confirm_kill = Some((pid, name)); + } + } + ActionItem::Copy => { + if let Some(entry) = app.selected_entry() { + let text = format!( + "{}:{} {} (pid {})", + entry.entry.local_addr.ip(), + entry.entry.local_port(), + entry.entry.process.name, + entry.entry.process.pid, + ); + app.copy_to_clipboard(&text); + } + } + ActionItem::CopyPid => { + if let Some(entry) = app.selected_entry() { + let text = entry.entry.process.pid.to_string(); + app.copy_to_clipboard(&text); + } + } + ActionItem::BlockIp => app.initiate_block(), + ActionItem::Trace => app.toggle_tracer(), + ActionItem::Forward => { + app.forward_prompt = true; + app.forward_input.clear(); + } + } +} + +pub fn draw(f: &mut Frame, app: &App) { + let Some(menu) = &app.action_menu else { return }; + + let s = i18n::strings(); + let area = f.area(); + let width: u16 = 36; + let height: u16 = (menu.items.len() as u16) + 2; + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let popup = Rect { + x, + y, + width, + height, + }; + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", s.action_menu_title)) + .title_alignment(Alignment::Left) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(popup); + f.render_widget(block, popup); + + let lines: Vec = menu + .items + .iter() + .enumerate() + .map(|(i, entry)| { + let style = if !entry.enabled { + Style::default().fg(Color::DarkGray) + } else if i == menu.selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + let label = match entry.reason { + Some(reason) => format!(" {} ({reason}) ", action_label(entry.item)), + None => format!(" {} ", action_label(entry.item)), + }; + Line::from(vec![ + Span::styled(format!(" {} ", i + 1), Style::default().fg(Color::DarkGray)), + Span::styled(label, style), + ]) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + +fn enabled(item: ActionItem) -> ActionMenuEntry { + ActionMenuEntry { + item, + enabled: true, + reason: None, + } +} + +fn disabled(item: ActionItem, reason: &'static str) -> ActionMenuEntry { + ActionMenuEntry { + item, + enabled: false, + reason: Some(reason), + } +} + +fn action_label(item: ActionItem) -> &'static str { + let s = i18n::strings(); + match item { + ActionItem::Kill => s.action_kill, + ActionItem::Copy => s.action_copy, + ActionItem::CopyPid => s.action_copy_pid, + ActionItem::BlockIp => s.action_block, + ActionItem::Trace => s.action_trace, + ActionItem::Forward => s.action_forward, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn move_up_wraps_to_last() { + let mut m = ActionMenu { + items: vec![ + enabled(ActionItem::Kill), + enabled(ActionItem::Copy), + enabled(ActionItem::CopyPid), + ], + selected: 0, + }; + m.move_up(); + assert_eq!(m.selected, 2); + } + + #[test] + fn move_down_wraps_to_first() { + let mut m = ActionMenu { + items: vec![enabled(ActionItem::Kill), enabled(ActionItem::Copy)], + selected: 1, + }; + m.move_down(); + assert_eq!(m.selected, 0); + } +} diff --git a/crates/prt/src/views/command_palette.rs b/crates/prt/src/views/command_palette.rs new file mode 100644 index 0000000..5b70d78 --- /dev/null +++ b/crates/prt/src/views/command_palette.rs @@ -0,0 +1,248 @@ +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent}; +use prt_core::i18n; +use prt_core::model::{ProcessesTab, SshTab, ViewMode}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + +#[derive(Debug, Clone, Default)] +pub struct CommandPalette { + pub input: String, + pub selected: usize, +} + +#[derive(Debug, Clone, Copy)] +struct Command { + label: &'static str, + action: CommandAction, +} + +#[derive(Debug, Clone, Copy)] +enum CommandAction { + Refresh, + TogglePause, + ClearFilter, + Connections, + Processes, + Ssh, + Tunnels, + Kill, + CopyPid, + Trace, + Block, +} + +const COMMANDS: &[Command] = &[ + Command { + label: "refresh", + action: CommandAction::Refresh, + }, + Command { + label: "pause", + action: CommandAction::TogglePause, + }, + Command { + label: "clear filter", + action: CommandAction::ClearFilter, + }, + Command { + label: "connections", + action: CommandAction::Connections, + }, + Command { + label: "processes", + action: CommandAction::Processes, + }, + Command { + label: "ssh", + action: CommandAction::Ssh, + }, + Command { + label: "tunnels", + action: CommandAction::Tunnels, + }, + Command { + label: "kill", + action: CommandAction::Kill, + }, + Command { + label: "copy pid", + action: CommandAction::CopyPid, + }, + Command { + label: "trace", + action: CommandAction::Trace, + }, + Command { + label: "block", + action: CommandAction::Block, + }, +]; + +pub fn open(app: &mut App) { + app.command_palette = Some(CommandPalette::default()); +} + +pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { + let Some(palette) = app.command_palette.as_mut() else { + return false; + }; + + match key.code { + KeyCode::Esc => app.command_palette = None, + KeyCode::Backspace => { + palette.input.pop(); + palette.selected = 0; + } + KeyCode::Up => { + palette.selected = palette.selected.saturating_sub(1); + } + KeyCode::Down => { + let count = matching_commands(&palette.input).len(); + if count > 0 { + palette.selected = (palette.selected + 1).min(count - 1); + } + } + KeyCode::Char(c) if palette.input.len() < 128 => { + palette.input.push(c); + palette.selected = 0; + } + KeyCode::Enter => { + let input = palette.input.clone(); + let selected = palette.selected; + let command = matching_commands(&input).get(selected).copied(); + app.command_palette = None; + if let Some(command) = command { + execute(app, command.action); + } + } + _ => {} + } + true +} + +pub fn draw(f: &mut Frame, app: &App) { + let Some(palette) = &app.command_palette else { + return; + }; + let s = i18n::strings(); + let area = f.area(); + let width = 48u16.min(area.width.saturating_sub(4)); + let height = 10u16.min(area.height.saturating_sub(2)); + let popup = Rect::new( + area.x + (area.width.saturating_sub(width)) / 2, + area.y + (area.height.saturating_sub(height)) / 2, + width, + height, + ); + + f.render_widget(Clear, popup); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", s.command_palette_title)) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(popup); + f.render_widget(block, popup); + + let mut lines = vec![Line::from(vec![ + Span::styled(": ", Style::default().fg(Color::Cyan)), + Span::raw(&palette.input), + Span::styled("\u{2588}", Style::default().fg(Color::White)), + ])]; + + let matches = matching_commands(&palette.input); + if matches.is_empty() { + lines.push(Line::from(Span::styled( + s.command_palette_empty, + Style::default().fg(Color::DarkGray), + ))); + } else { + for (idx, command) in matches + .iter() + .take(inner.height.saturating_sub(1) as usize) + .enumerate() + { + let style = if idx == palette.selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + lines.push(Line::from(Span::styled( + format!(" {}", command.label), + style, + ))); + } + } + + f.render_widget(Paragraph::new(lines), inner); +} + +fn matching_commands(input: &str) -> Vec { + let needle = input.trim().to_lowercase(); + COMMANDS + .iter() + .copied() + .filter(|command| needle.is_empty() || command.label.contains(&needle)) + .collect() +} + +fn execute(app: &mut App, action: CommandAction) { + match action { + CommandAction::Refresh => app.refresh(), + CommandAction::TogglePause => app.auto_refresh_paused = !app.auto_refresh_paused, + CommandAction::ClearFilter => { + app.filter.clear(); + app.update_filtered(); + } + CommandAction::Connections => app.view_mode = ViewMode::Connections, + CommandAction::Processes => { + app.view_mode = ViewMode::Processes; + app.processes_tab = ProcessesTab::Detail; + } + CommandAction::Ssh => app.view_mode = ViewMode::Ssh, + CommandAction::Tunnels => { + app.view_mode = ViewMode::Ssh; + app.ssh_tab = SshTab::Tunnels; + } + CommandAction::Kill => { + if let Some(entry) = app.selected_entry() { + app.confirm_kill = + Some((entry.entry.process.pid, entry.entry.process.name.clone())); + } + } + CommandAction::CopyPid => { + if let Some(entry) = app.selected_entry() { + app.copy_to_clipboard(&entry.entry.process.pid.to_string()); + } + } + CommandAction::Trace => app.toggle_tracer(), + CommandAction::Block => app.initiate_block(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matching_commands_filters_by_substring() { + let labels: Vec<_> = matching_commands("tun") + .into_iter() + .map(|command| command.label) + .collect(); + assert_eq!(labels, vec!["tunnels"]); + } + + #[test] + fn handle_key_allows_typing_j_and_k() { + let mut app = App::new(); + open(&mut app); + + handle_key(&mut app, KeyEvent::from(KeyCode::Char('k'))); + handle_key(&mut app, KeyEvent::from(KeyCode::Char('j'))); + + let palette = app.command_palette.unwrap(); + assert_eq!(palette.input, "kj"); + assert_eq!(palette.selected, 0); + } +} diff --git a/crates/prt/src/views/mod.rs b/crates/prt/src/views/mod.rs index c350f1c..96553b7 100644 --- a/crates/prt/src/views/mod.rs +++ b/crates/prt/src/views/mod.rs @@ -1,5 +1,7 @@ -//! Fullscreen views for the SSH manager and tunnels manager. +//! Fullscreen views and overlays for the TUI. +pub mod action_menu; +pub mod command_palette; pub mod ssh_hosts; pub mod tunnel_form; pub mod tunnels; diff --git a/crates/prt/src/views/tunnel_form.rs b/crates/prt/src/views/tunnel_form.rs index 0a2c1a7..c5619f7 100644 --- a/crates/prt/src/views/tunnel_form.rs +++ b/crates/prt/src/views/tunnel_form.rs @@ -24,6 +24,8 @@ pub struct TunnelFormState { pub remote_port: String, pub host_alias: String, pub focused: TunnelFormField, + /// When set, submit replaces the tunnel at this index instead of creating a new one. + pub editing_idx: Option, } impl TunnelFormState { @@ -35,9 +37,89 @@ impl TunnelFormState { remote_port: String::new(), host_alias: prefill_alias.unwrap_or_default(), focused: TunnelFormField::Kind, + editing_idx: None, } } + pub fn new_from_host(alias: String) -> Self { + let mut form = Self::new(Some(alias)); + form.focused = TunnelFormField::LocalPort; + form + } + + /// Open the form in edit-mode for an existing tunnel. + pub fn edit(spec: &SshTunnelSpec, idx: usize) -> Self { + Self { + kind: spec.kind, + local_port: spec.local_port.to_string(), + remote_host: spec + .remote_host + .clone() + .unwrap_or_else(|| "127.0.0.1".into()), + remote_port: spec.remote_port.map(|p| p.to_string()).unwrap_or_default(), + host_alias: spec.host_alias.clone(), + focused: TunnelFormField::LocalPort, + editing_idx: Some(idx), + } + } + + /// Returns Some(error) if the field as currently typed is invalid. + /// Used for inline (per-field) validation feedback. + pub fn validate_field(&self, field: TunnelFormField) -> Option<&'static str> { + let s = i18n::strings(); + match field { + TunnelFormField::Kind => None, + TunnelFormField::LocalPort => { + if self.local_port.trim().is_empty() { + return Some(s.tunnel_form_field_required); + } + self.local_port + .trim() + .parse::() + .err() + .map(|_| "1..=65535") + } + TunnelFormField::RemoteHost => { + if self.kind == TunnelKind::Dynamic { + return None; + } + if self.remote_host.trim().is_empty() { + Some(s.tunnel_form_field_required) + } else { + None + } + } + TunnelFormField::RemotePort => { + if self.kind == TunnelKind::Dynamic { + return None; + } + if self.remote_port.trim().is_empty() { + return Some(s.tunnel_form_field_required); + } + self.remote_port + .trim() + .parse::() + .err() + .map(|_| "1..=65535") + } + TunnelFormField::HostAlias => { + if self.host_alias.trim().is_empty() { + Some(s.tunnel_form_field_required) + } else { + None + } + } + } + } + + /// Has the user entered any data that would be lost on Esc? + pub fn is_dirty(&self) -> bool { + !self.local_port.is_empty() + || !self.remote_port.is_empty() + || !self.host_alias.is_empty() + || self.remote_host != "127.0.0.1" + } + pub fn next_field(&mut self) { self.focused = match self.focused { TunnelFormField::Kind => TunnelFormField::LocalPort, @@ -80,6 +162,46 @@ impl TunnelFormState { } } + pub fn visible_fields(&self) -> Vec { + let mut fields = vec![TunnelFormField::Kind, TunnelFormField::LocalPort]; + if self.kind == TunnelKind::Local { + fields.push(TunnelFormField::RemoteHost); + fields.push(TunnelFormField::RemotePort); + } + fields.push(TunnelFormField::HostAlias); + fields + } + + pub fn command_preview(&self) -> String { + let local_port = if self.local_port.trim().is_empty() { + "" + } else { + self.local_port.trim() + }; + let host_alias = if self.host_alias.trim().is_empty() { + "" + } else { + self.host_alias.trim() + }; + + match self.kind { + TunnelKind::Local => { + let remote_host = if self.remote_host.trim().is_empty() { + "" + } else { + self.remote_host.trim() + }; + let remote_port = if self.remote_port.trim().is_empty() { + "" + } else { + self.remote_port.trim() + }; + format!("ssh -N -L {local_port}:{remote_host}:{remote_port} {host_alias}") + } + TunnelKind::Dynamic => format!("ssh -N -D {local_port} {host_alias}"), + } + } + pub fn build_spec(&self) -> Result { let local_port: u16 = self .local_port @@ -145,10 +267,15 @@ pub fn draw(f: &mut Frame, app: &App) { f.render_widget(Clear, popup_area); + let title = if form.editing_idx.is_some() { + s.tunnel_form_edit_title + } else { + s.tunnel_form_title + }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)) - .title(s.tunnel_form_title); + .title(title); let inner = block.inner(popup_area); f.render_widget(block, popup_area); @@ -163,36 +290,47 @@ pub fn draw(f: &mut Frame, app: &App) { &format!("\u{25c0} {kind_value} \u{25b6}"), form.focused == TunnelFormField::Kind, false, + None, )); lines.push(field_line( s.tunnel_form_local_port, &form.local_port, form.focused == TunnelFormField::LocalPort, true, + form.validate_field(TunnelFormField::LocalPort), )); - let dim_remote = form.kind == TunnelKind::Dynamic; - lines.push(field_line_dim( - s.tunnel_form_remote_host, - &form.remote_host, - form.focused == TunnelFormField::RemoteHost, - true, - dim_remote, - )); - lines.push(field_line_dim( - s.tunnel_form_remote_port, - &form.remote_port, - form.focused == TunnelFormField::RemotePort, - true, - dim_remote, - )); + let visible_fields = form.visible_fields(); + if visible_fields.contains(&TunnelFormField::RemoteHost) { + lines.push(field_line_dim( + s.tunnel_form_remote_host, + &form.remote_host, + form.focused == TunnelFormField::RemoteHost, + true, + false, + form.validate_field(TunnelFormField::RemoteHost), + )); + lines.push(field_line_dim( + s.tunnel_form_remote_port, + &form.remote_port, + form.focused == TunnelFormField::RemotePort, + true, + false, + form.validate_field(TunnelFormField::RemotePort), + )); + } lines.push(field_line( s.tunnel_form_host_alias, &form.host_alias, form.focused == TunnelFormField::HostAlias, true, + form.validate_field(TunnelFormField::HostAlias), )); lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {}", form.command_preview()), + Style::default().fg(Color::DarkGray), + ))); lines.push(Line::from(Span::styled( s.tunnel_form_hint, Style::default().fg(Color::DarkGray), @@ -201,8 +339,14 @@ pub fn draw(f: &mut Frame, app: &App) { f.render_widget(Paragraph::new(lines), inner); } -fn field_line(label: &'static str, value: &str, focused: bool, cursor: bool) -> Line<'static> { - field_line_dim(label, value, focused, cursor, false) +fn field_line( + label: &'static str, + value: &str, + focused: bool, + cursor: bool, + error: Option<&str>, +) -> Line<'static> { + field_line_dim(label, value, focused, cursor, false, error) } fn field_line_dim( @@ -211,19 +355,25 @@ fn field_line_dim( focused: bool, cursor: bool, dim: bool, + error: Option<&str>, ) -> Line<'static> { + let has_error = error.is_some(); let label_style = if dim { Style::default().fg(Color::DarkGray) + } else if has_error { + Style::default().fg(Color::Red) } else { Style::default().fg(Color::Cyan) }; let value_style = if focused { Style::default() .fg(Color::Black) - .bg(Color::Cyan) + .bg(if has_error { Color::Red } else { Color::Cyan }) .add_modifier(Modifier::BOLD) } else if dim { Style::default().fg(Color::DarkGray) + } else if has_error { + Style::default().fg(Color::Red) } else { Style::default() }; @@ -237,6 +387,13 @@ fn field_line_dim( Style::default().fg(Color::White), )); } + if let Some(err) = error { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("\u{2717} {err}"), + Style::default().fg(Color::Red), + )); + } Line::from(spans) } @@ -248,16 +405,35 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { }; match key.code { KeyCode::Esc => { - app.tunnel_form = None; + // If the form has unsaved input, require a second Esc within the + // arm window before discarding (mirrors the filter cascade). + if form.is_dirty() { + let armed = app + .last_esc + .is_some_and(|t| t.elapsed() < std::time::Duration::from_millis(1500)); + if armed { + app.tunnel_form = None; + app.last_esc = None; + } else { + app.last_esc = Some(std::time::Instant::now()); + let s = i18n::strings(); + app.set_status(s.esc_again_to_discard_form.into()); + } + } else { + app.tunnel_form = None; + } true } KeyCode::Enter => { - // Build spec then drop the borrow on app.tunnel_form before mutating. + let editing_idx = form.editing_idx; let result = form.build_spec(); match result { Ok(spec) => { app.tunnel_form = None; - app.create_tunnel(spec); + match editing_idx { + Some(idx) => app.replace_tunnel(idx, spec), + None => app.create_tunnel(spec), + } } Err(e) => { app.set_status(format!("{}: {e}", i18n::strings().tunnel_form_invalid)); @@ -360,6 +536,46 @@ mod tests { assert_eq!(f.focused, TunnelFormField::HostAlias); } + #[test] + fn edit_constructor_fills_all_fields() { + let spec = SshTunnelSpec { + name: None, + kind: TunnelKind::Local, + local_port: 5433, + remote_host: Some("db.internal".into()), + remote_port: Some(5432), + host_alias: "prod".into(), + }; + let f = TunnelFormState::edit(&spec, 7); + assert_eq!(f.local_port, "5433"); + assert_eq!(f.remote_host, "db.internal"); + assert_eq!(f.remote_port, "5432"); + assert_eq!(f.host_alias, "prod"); + assert_eq!(f.editing_idx, Some(7)); + } + + #[test] + fn validate_field_returns_error_for_empty_required() { + let f = TunnelFormState::new(None); + assert!(f.validate_field(TunnelFormField::LocalPort).is_some()); + assert!(f.validate_field(TunnelFormField::HostAlias).is_some()); + } + + #[test] + fn validate_field_rejects_non_numeric_port() { + let mut f = TunnelFormState::new(Some("p".into())); + f.local_port = "abc".into(); + assert!(f.validate_field(TunnelFormField::LocalPort).is_some()); + } + + #[test] + fn is_dirty_after_typing() { + let mut f = TunnelFormState::new(None); + assert!(!f.is_dirty()); + f.local_port = "1".into(); + assert!(f.is_dirty()); + } + #[test] fn toggle_kind_jumps_focus_off_remote() { let mut f = TunnelFormState::new(None); @@ -368,4 +584,35 @@ mod tests { assert_eq!(f.kind, TunnelKind::Dynamic); assert_eq!(f.focused, TunnelFormField::HostAlias); } + + #[test] + fn new_from_host_focuses_local_port() { + let f = TunnelFormState::new_from_host("prod".into()); + assert_eq!(f.host_alias, "prod"); + assert_eq!(f.focused, TunnelFormField::LocalPort); + } + + #[test] + fn visible_fields_hide_remote_for_dynamic_tunnel() { + let mut f = TunnelFormState::new(None); + assert!(f.visible_fields().contains(&TunnelFormField::RemoteHost)); + assert!(f.visible_fields().contains(&TunnelFormField::RemotePort)); + + f.kind = TunnelKind::Dynamic; + + assert!(!f.visible_fields().contains(&TunnelFormField::RemoteHost)); + assert!(!f.visible_fields().contains(&TunnelFormField::RemotePort)); + } + + #[test] + fn ssh_command_preview_describes_local_and_dynamic_tunnels() { + let mut f = TunnelFormState::new(Some("prod".into())); + f.local_port = "5433".into(); + f.remote_host = "127.0.0.1".into(); + f.remote_port = "5432".into(); + assert_eq!(f.command_preview(), "ssh -N -L 5433:127.0.0.1:5432 prod"); + + f.kind = TunnelKind::Dynamic; + assert_eq!(f.command_preview(), "ssh -N -D 5433 prod"); + } } diff --git a/crates/prt/src/views/tunnels.rs b/crates/prt/src/views/tunnels.rs index 2c980a1..d821ecd 100644 --- a/crates/prt/src/views/tunnels.rs +++ b/crates/prt/src/views/tunnels.rs @@ -1,6 +1,7 @@ //! Fullscreen SSH tunnels manager. use crate::app::App; +use crate::forward::TunnelStatus; use crossterm::event::{KeyCode, KeyEvent}; use prt_core::core::ssh_tunnel::TunnelKind; use prt_core::i18n; @@ -60,9 +61,11 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { ), TunnelKind::Dynamic => "(SOCKS5)".into(), }; - // Status read is fallible without &mut; we render "alive" by default - // since the cleanup loop in app.rs:382 prunes dead tunnels each tick. - let status = s.tunnel_status_alive; + let (status, color) = match t.last_status { + TunnelStatus::Alive => (s.tunnel_status_alive, Color::Green), + TunnelStatus::Starting => (s.tunnel_status_starting, Color::Yellow), + TunnelStatus::Failed => (s.tunnel_status_failed, Color::Red), + }; Row::new(vec![ Cell::from(t.spec.name.clone().unwrap_or_else(|| "-".into())), @@ -70,7 +73,7 @@ pub fn draw(f: &mut Frame, app: &App, area: Rect) { Cell::from(local), Cell::from(remote), Cell::from(t.spec.host_alias.clone()), - Cell::from(status).style(Style::default().fg(Color::Green)), + Cell::from(status).style(Style::default().fg(color)), ]) }) .collect(); @@ -124,6 +127,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> bool { app.open_tunnel_form(None); true } + KeyCode::Char('e') => { + app.open_tunnel_form_edit(app.tunnels_selected); + true + } KeyCode::Char('K') | KeyCode::Delete => { app.kill_selected_tunnel(); true