diff --git a/Cargo.lock b/Cargo.lock index 2f31dd2..98ac9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,7 +1079,7 @@ dependencies = [ [[package]] name = "prt" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "arboard", @@ -1093,7 +1093,7 @@ dependencies = [ [[package]] name = "prt-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 3d99c3e..a8a6621 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.5.0" +version = "0.5.1" 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.5.0" } +prt-core = { path = "crates/prt-core", version = "0.5.1" } diff --git a/README.md b/README.md index ed2163f..31e33c0 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,7 @@ ports, and the process tree — no tab switching needed. 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`. ### Action menu (`Space`) diff --git a/crates/prt-core/src/core/container.rs b/crates/prt-core/src/core/container.rs index d09d5e4..15adc37 100644 --- a/crates/prt-core/src/core/container.rs +++ b/crates/prt-core/src/core/container.rs @@ -2,16 +2,19 @@ //! //! Resolves process PIDs to container names using: //! - **Linux:** `/proc/{pid}/cgroup` → container ID → `docker ps` lookup -//! - **macOS:** `docker ps` with PID matching via `docker inspect` +//! - **macOS/other:** skipped to keep the TUI startup and refresh path non-blocking //! //! All lookups are batched per refresh cycle to minimize CLI overhead. //! Missing Docker/Podman is handled gracefully (empty results, no errors). use std::collections::HashMap; +#[cfg(target_os = "linux")] use std::process::Command; +#[cfg(target_os = "linux")] use std::time::Duration; /// Timeout for docker CLI calls to avoid blocking the TUI. +#[cfg(target_os = "linux")] const DOCKER_TIMEOUT_SECS: u64 = 2; /// Resolve container names for a batch of PIDs. @@ -24,15 +27,26 @@ pub fn resolve_container_names(pids: &[u32]) -> HashMap { return HashMap::new(); } + resolve_container_names_for_platform(pids) +} + +#[cfg(target_os = "linux")] +fn resolve_container_names_for_platform(pids: &[u32]) -> HashMap { select_runtime_names(docker_resolve(pids), podman_resolve(pids)) } +#[cfg(not(target_os = "linux"))] +fn resolve_container_names_for_platform(_pids: &[u32]) -> HashMap { + HashMap::new() +} + /// Check if any entries have container names (used for adaptive column). pub fn has_containers(names: &HashMap) -> bool { !names.is_empty() } /// Resolve via `docker ps` + `docker inspect`. +#[cfg(target_os = "linux")] fn docker_resolve(pids: &[u32]) -> Option> { let pid_set: std::collections::HashSet = pids.iter().copied().collect(); // Get all running containers: ID and Name @@ -77,6 +91,7 @@ fn docker_resolve(pids: &[u32]) -> Option> { } /// Resolve via `podman ps` + `podman inspect`. +#[cfg(target_os = "linux")] fn podman_resolve(pids: &[u32]) -> Option> { let pid_set: std::collections::HashSet = pids.iter().copied().collect(); let output = run_with_timeout( @@ -119,6 +134,7 @@ fn podman_resolve(pids: &[u32]) -> Option> { } /// Get the main PID of a container via `docker/podman inspect`. +#[cfg(target_os = "linux")] fn get_container_pid(runtime: &str, container_id: &str) -> Option { let output = run_with_timeout( runtime, @@ -129,6 +145,7 @@ fn get_container_pid(runtime: &str, container_id: &str) -> Option { /// Run a command with timeout, returning stdout as String. /// Returns None if command not found, timeout, or non-zero exit. +#[cfg(target_os = "linux")] fn run_with_timeout(cmd: &str, args: &[&str]) -> Option { let mut child = Command::new(cmd) .args(args) @@ -167,6 +184,7 @@ fn run_with_timeout(cmd: &str, args: &[&str]) -> Option { } } +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn select_runtime_names( docker_names: Option>, podman_names: Option>, @@ -234,6 +252,13 @@ mod tests { assert!(result.is_empty()); } + #[cfg(not(target_os = "linux"))] + #[test] + fn resolve_non_linux_skips_external_runtime_calls() { + let result = resolve_container_names(&[1, 2, 3]); + assert!(result.is_empty()); + } + #[test] fn has_containers_empty() { assert!(!has_containers(&HashMap::new())); diff --git a/crates/prt-core/src/core/process_detail.rs b/crates/prt-core/src/core/process_detail.rs index add6aff..3acc0f8 100644 --- a/crates/prt-core/src/core/process_detail.rs +++ b/crates/prt-core/src/core/process_detail.rs @@ -8,6 +8,11 @@ //! - **macOS:** uses `lsof -p {pid}` and `ps -o %cpu,rss -p {pid}` use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const DETAIL_COMMAND_TIMEOUT: Duration = Duration::from_millis(700); /// Detailed information about a single process, fetched on demand. #[derive(Debug, Clone)] @@ -120,12 +125,9 @@ fn parse_proc_stat_status(pid: u32) -> (Option, Option) { }); // CPU% — we'd need two samples to compute; use ps as a simpler approach - let cpu_percent = std::process::Command::new("ps") - .args(["-o", "%cpu=", "-p", &pid.to_string()]) - .output() - .ok() + let cpu_percent = command_stdout_with_timeout("ps", &["-o", "%cpu=", "-p", &pid.to_string()]) .and_then(|o| { - let s = String::from_utf8_lossy(&o.stdout); + let s = String::from_utf8_lossy(&o); s.trim().parse::().ok() }); @@ -136,35 +138,26 @@ fn parse_proc_stat_status(pid: u32) -> (Option, Option) { #[allow(dead_code)] fn fetch_macos(pid: u32) -> Option { // Check process exists - let exists = std::process::Command::new("ps") - .args(["-p", &pid.to_string()]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); + let exists = command_stdout_with_timeout("ps", &["-p", &pid.to_string()]).is_some(); if !exists { return None; } - let cwd = std::process::Command::new("lsof") - .args(["-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"]) - .output() - .ok() - .and_then(|o| parse_lsof_cwd(&String::from_utf8_lossy(&o.stdout))); + let cwd = command_stdout_with_timeout( + "lsof", + &["-nP", "-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"], + ) + .and_then(|o| parse_lsof_cwd(&String::from_utf8_lossy(&o))); - let open_files = std::process::Command::new("lsof") - .args(["-p", &pid.to_string(), "-Fn"]) - .output() - .ok() - .map(|o| parse_lsof_files(&String::from_utf8_lossy(&o.stdout))) + let open_files = command_stdout_with_timeout("lsof", &["-nP", "-p", &pid.to_string(), "-Fn"]) + .map(|o| parse_lsof_files(&String::from_utf8_lossy(&o))) .unwrap_or_default(); - let (cpu_percent, rss_kb) = std::process::Command::new("ps") - .args(["-o", "%cpu=,rss=", "-p", &pid.to_string()]) - .output() - .ok() - .map(|o| parse_ps_cpu_rss(&String::from_utf8_lossy(&o.stdout))) - .unwrap_or((None, None)); + let (cpu_percent, rss_kb) = + command_stdout_with_timeout("ps", &["-o", "%cpu=,rss=", "-p", &pid.to_string()]) + .map(|o| parse_ps_cpu_rss(&String::from_utf8_lossy(&o))) + .unwrap_or((None, None)); // environ not easily accessible on macOS without SIP issues let env_vars = Vec::new(); @@ -178,6 +171,42 @@ fn fetch_macos(pid: u32) -> Option { }) } +fn command_stdout_with_timeout(cmd: &str, args: &[&str]) -> Option> { + let mut child = Command::new(cmd) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let mut stdout = child.stdout.take()?; + let reader = thread::spawn(move || { + let mut out = Vec::new(); + use std::io::Read; + let _ = stdout.read_to_end(&mut out); + out + }); + + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let out = reader.join().ok()?; + return status.success().then_some(out); + } + Ok(None) if start.elapsed() >= DETAIL_COMMAND_TIMEOUT => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return None; + } + Ok(None) => thread::sleep(Duration::from_millis(20)), + Err(_) => return None, + } + } +} + /// Parse `lsof -d cwd -Fn` output for the cwd path. /// Lines: `p`, `fcwd`, `n` fn parse_lsof_cwd(output: &str) -> Option { diff --git a/crates/prt/src/app.rs b/crates/prt/src/app.rs index 5d8f46a..d3ca93f 100644 --- a/crates/prt/src/app.rs +++ b/crates/prt/src/app.rs @@ -278,14 +278,23 @@ impl App { } pub fn refresh(&mut self) { + let prev_key = self.selected_key(); if let Err(msg) = self.session.refresh() { self.set_status(msg); } // Evaluate alert rules self.active_alerts = alerts::evaluate(&self.session.config.alerts, &self.session.entries); - // Invalidate detail cache to pick up fresh data - self.detail_cache = None; - self.update_filtered(); + if let Some((pid, _)) = self.detail_cache.as_ref() { + if !self + .session + .entries + .iter() + .any(|entry| entry.entry.process.pid == *pid) + { + self.detail_cache = None; + } + } + self.update_filtered_preserving(prev_key); } /// Whether any bell alerts fired this cycle (for TUI to emit BEL). @@ -310,9 +319,11 @@ impl App { } pub fn update_filtered(&mut self) { - // Remember which entry was focused before the update let prev_key = self.selected_key(); + self.update_filtered_preserving(prev_key); + } + fn update_filtered_preserving(&mut self, prev_key: Option<(u16, u32)>) { self.filtered_indices = self.session.filtered_indices(&self.filter); // Try to restore focus to the same (port, pid) entry @@ -595,6 +606,34 @@ fn parse_forward_input(input: &str) -> Option<(String, u16)> { #[cfg(test)] mod tests { use super::*; + use prt_core::model::{ConnectionState, EntryStatus, PortEntry, ProcessInfo, Protocol}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + fn tracked(port: u16, pid: u32, name: &str) -> TrackedEntry { + TrackedEntry { + entry: PortEntry { + protocol: Protocol::Tcp, + local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port), + remote_addr: None, + state: ConnectionState::Listen, + process: ProcessInfo { + pid, + name: name.into(), + path: None, + cmdline: None, + user: None, + parent_pid: None, + parent_name: None, + }, + }, + status: EntryStatus::Unchanged, + seen_at: Instant::now(), + first_seen: None, + suspicious: Vec::new(), + container_name: None, + service_name: None, + } + } #[test] fn parse_forward_simple_host_port() { @@ -637,4 +676,24 @@ mod tests { assert!(parse_forward_input("host:abc").is_none()); assert!(parse_forward_input("host:99999").is_none()); } + + #[test] + fn update_filtered_preserves_selection_from_pre_refresh_key() { + let mut app = App::new(); + app.session.entries = vec![tracked(80, 1, "old-a"), tracked(443, 2, "old-b")]; + app.filtered_indices = vec![0, 1]; + app.selected = 1; + + let prev_key = app.selected_key(); + app.session.entries = vec![ + tracked(22, 3, "new-a"), + tracked(80, 1, "old-a"), + tracked(443, 2, "old-b"), + ]; + + app.update_filtered_preserving(prev_key); + + assert_eq!(app.selected, 2); + assert_eq!(app.selected_entry().unwrap().entry.process.pid, 2); + } }