Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
27 changes: 26 additions & 1 deletion crates/prt-core/src/core/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,15 +27,26 @@ pub fn resolve_container_names(pids: &[u32]) -> HashMap<u32, String> {
return HashMap::new();
}

resolve_container_names_for_platform(pids)
}

#[cfg(target_os = "linux")]
fn resolve_container_names_for_platform(pids: &[u32]) -> HashMap<u32, String> {
select_runtime_names(docker_resolve(pids), podman_resolve(pids))
}

#[cfg(not(target_os = "linux"))]
fn resolve_container_names_for_platform(_pids: &[u32]) -> HashMap<u32, String> {
HashMap::new()
}

/// Check if any entries have container names (used for adaptive column).
pub fn has_containers(names: &HashMap<u32, String>) -> bool {
!names.is_empty()
}

/// Resolve via `docker ps` + `docker inspect`.
#[cfg(target_os = "linux")]
fn docker_resolve(pids: &[u32]) -> Option<HashMap<u32, String>> {
let pid_set: std::collections::HashSet<u32> = pids.iter().copied().collect();
// Get all running containers: ID and Name
Expand Down Expand Up @@ -77,6 +91,7 @@ fn docker_resolve(pids: &[u32]) -> Option<HashMap<u32, String>> {
}

/// Resolve via `podman ps` + `podman inspect`.
#[cfg(target_os = "linux")]
fn podman_resolve(pids: &[u32]) -> Option<HashMap<u32, String>> {
let pid_set: std::collections::HashSet<u32> = pids.iter().copied().collect();
let output = run_with_timeout(
Expand Down Expand Up @@ -119,6 +134,7 @@ fn podman_resolve(pids: &[u32]) -> Option<HashMap<u32, String>> {
}

/// 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<u32> {
let output = run_with_timeout(
runtime,
Expand All @@ -129,6 +145,7 @@ fn get_container_pid(runtime: &str, container_id: &str) -> Option<u32> {

/// 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<String> {
let mut child = Command::new(cmd)
.args(args)
Expand Down Expand Up @@ -167,6 +184,7 @@ fn run_with_timeout(cmd: &str, args: &[&str]) -> Option<String> {
}
}

#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn select_runtime_names(
docker_names: Option<HashMap<u32, String>>,
podman_names: Option<HashMap<u32, String>>,
Expand Down Expand Up @@ -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()));
Expand Down
81 changes: 55 additions & 26 deletions crates/prt-core/src/core/process_detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -120,12 +125,9 @@ fn parse_proc_stat_status(pid: u32) -> (Option<f32>, Option<u64>) {
});

// 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::<f32>().ok()
});

Expand All @@ -136,35 +138,26 @@ fn parse_proc_stat_status(pid: u32) -> (Option<f32>, Option<u64>) {
#[allow(dead_code)]
fn fetch_macos(pid: u32) -> Option<ProcessDetail> {
// 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();
Expand All @@ -178,6 +171,42 @@ fn fetch_macos(pid: u32) -> Option<ProcessDetail> {
})
}

fn command_stdout_with_timeout(cmd: &str, args: &[&str]) -> Option<Vec<u8>> {
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<pid>`, `fcwd`, `n<path>`
fn parse_lsof_cwd(output: &str) -> Option<PathBuf> {
Expand Down
67 changes: 63 additions & 4 deletions crates/prt/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +287 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate process details on refresh

When the Processes/Detail view is open, get_process_detail() only re-fetches if the selected PID changes, so this new refresh path keeps the cached detail forever as long as that PID is still present in session.entries. That leaves CPU/RSS, cwd, env, and open-file data stale across auto-refreshes for long-lived processes (and even for Gone entries during the retention window), which regresses the previous per-refresh invalidation behavior. Please either clear the cache each refresh or add a freshness/TTL check before reusing it.

Useful? React with 👍 / 👎.

self.update_filtered_preserving(prev_key);
}

/// Whether any bell alerts fired this cycle (for TUI to emit BEL).
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
}
Loading