From 747c14053a673ca5a987fbb903be9f16d866ae41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Kr=C3=BCger=20Svensson?= Date: Wed, 1 Apr 2026 09:12:04 +0200 Subject: [PATCH] refactor: extract shared process and ports modules, add toast notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process.rs: - Shared kill_port() used by both CLI and API - SIGTERM → poll every 100ms with ps -p (EPERM-safe) → SIGKILL after 2s - Tracks original PIDs to avoid killing replacement processes - find_listeners returns Result (surfaces lsof failures) - pid_exists uses ps -p (ownership-independent, not kill -0) - KillResult: Killed, ForceKilled, NotFound, Error - ForceKilled only when kill -9 actually succeeds - Error when process is still alive but unkillable (EPERM) - 6 tests covering find_listeners, pid_exists, kill flow ports.rs: - merge_alive() for combining TCP scan + container ports - Replaces 4 duplicated merge loops across scanner, handlers, and CLI template.rs: - Toast notifications for kill and unregister actions - Success/error toasts with auto-dismiss after 3s Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib.rs | 65 +++++------------ src/main.rs | 41 ++--------- src/ports.rs | 109 ++++++++++++++++++++++++++++ src/process.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++++++ src/template.rs | 56 +++++++++++++- 5 files changed, 379 insertions(+), 81 deletions(-) create mode 100644 src/ports.rs create mode 100644 src/process.rs diff --git a/src/lib.rs b/src/lib.rs index 4ade9f4..38765dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ pub mod config; pub mod container; pub mod db; pub mod known_ports; +pub mod ports; +pub mod process; pub mod scanner; pub mod template; @@ -65,7 +67,7 @@ pub fn create_router(state: AppState) -> Router { get(get_app).put(update_app).delete(delete_app), ) .route("/events", get(sse_handler)) - .route("/api/kill/{port}", post(kill_port)) + .route("/api/kill/{port}", post(kill_port_handler)) .route("/api/refresh", post(trigger_refresh)) .route("/api/tag-colors", get(list_tag_colors)) .route( @@ -122,14 +124,8 @@ async fn publish_scan(state: &AppState, alive: &[u16]) { let tag_colors = db::list_tag_colors(&state.db).await.unwrap_or_default(); let container_ports = container::discover().await; - // Merge container ports into alive list let mut merged_alive = alive.to_vec(); - for cp in &container_ports { - if !merged_alive.contains(&cp.port) && cp.port != state.dashboard_port { - merged_alive.push(cp.port); - } - } - merged_alive.sort_unstable(); + ports::merge_alive(&mut merged_alive, &container_ports, state.dashboard_port); let rows = template::build_rows(&merged_alive, &apps, &container_ports); let total = rows.len(); @@ -220,13 +216,7 @@ pub async fn scanner_loop( if is_full_scan { cached_container_ports = container::discover().await; } - // Add container ports that the TCP scan might have missed - for cp in &cached_container_ports { - if !alive.contains(&cp.port) && cp.port != dashboard_port { - alive.push(cp.port); - } - } - alive.sort_unstable(); + ports::merge_alive(&mut alive, &cached_container_ports, dashboard_port); let rows = template::build_rows(&alive, &apps, &cached_container_ports); let total = rows.len(); @@ -454,31 +444,20 @@ async fn trigger_refresh(State(state): State) -> StatusCode { StatusCode::NO_CONTENT } -async fn kill_port(State(state): State, Path(port): Path) -> StatusCode { - let output = std::process::Command::new("lsof") - .args(["-ti", &format!(":{port}"), "-sTCP:LISTEN"]) - .output(); - - let Ok(output) = output else { - return StatusCode::INTERNAL_SERVER_ERROR; - }; - - let pids: Vec<&str> = std::str::from_utf8(&output.stdout) - .unwrap_or("") - .lines() - .filter(|l| !l.is_empty()) - .collect(); - - if pids.is_empty() { - return StatusCode::NOT_FOUND; - } - - for pid in &pids { - let _ = std::process::Command::new("kill").arg(pid).status(); +async fn kill_port_handler( + State(state): State, + Path(port): Path, +) -> Result { + match process::kill_port(port).await { + process::KillResult::NotFound => { + Err((StatusCode::NOT_FOUND, "Nothing running".to_string())) + } + process::KillResult::Error(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), + process::KillResult::Killed | process::KillResult::ForceKilled => { + state.scan_notify.notify_one(); + Ok(StatusCode::NO_CONTENT) + } } - - state.scan_notify.notify_one(); - StatusCode::NO_CONTENT } // -- SSE live updates -- @@ -588,13 +567,7 @@ async fn dashboard(State(state): State, headers: HeaderMap) -> Respons state.dashboard_port, ) .await; - // Add container ports - for cp in &container_ports { - if !alive.contains(&cp.port) && cp.port != state.dashboard_port { - alive.push(cp.port); - } - } - alive.sort_unstable(); + ports::merge_alive(&mut alive, &container_ports, state.dashboard_port); let tag_colors = db::list_tag_colors(&state.db).await.unwrap_or_default(); let html = template::render( &alive, diff --git a/src/main.rs b/src/main.rs index 14736e5..7319721 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,13 +200,7 @@ async fn cmd_list(db_path: &str, dashboard_port: u16) { let mut alive = portmap::scanner::scan_ports(1000, 9999, 0).await; let container_ports = portmap::container::discover().await; - // Add container ports that TCP scan might have missed - for cp in &container_ports { - if !alive.contains(&cp.port) { - alive.push(cp.port); - } - } - alive.sort_unstable(); + portmap::ports::merge_alive(&mut alive, &container_ports, 0); if apps.is_empty() && alive.is_empty() { println!("No ports found."); @@ -391,8 +385,6 @@ async fn cmd_update( } async fn cmd_kill(db_path: &str, target: &str) { - use std::process::Command as Cmd; - let db = portmap::db::init(db_path) .await .expect("Failed to open database"); @@ -403,27 +395,6 @@ async fn cmd_kill(db_path: &str, target: &str) { return; }; - // Find PIDs *listening* on this port (not clients connected to it) - let output = Cmd::new("lsof") - .args(["-ti", &format!(":{port}"), "-sTCP:LISTEN"]) - .output(); - - let Ok(output) = output else { - eprintln!("Failed to run lsof"); - return; - }; - - let pids: Vec<&str> = std::str::from_utf8(&output.stdout) - .unwrap_or("") - .lines() - .filter(|l| !l.is_empty()) - .collect(); - - if pids.is_empty() { - println!("Nothing running on :{port}"); - return; - } - let display = app.as_ref().map_or(format!(":{port}"), |a| { if a.name.is_empty() { format!(":{port}") @@ -432,10 +403,14 @@ async fn cmd_kill(db_path: &str, target: &str) { } }); - for pid in &pids { - let _ = Cmd::new("kill").arg(pid).status(); + match portmap::process::kill_port(port).await { + portmap::process::KillResult::NotFound => println!("Nothing running on :{port}"), + portmap::process::KillResult::Killed => println!("Killed {display} (port {port})"), + portmap::process::KillResult::ForceKilled => { + println!("Force killed {display} (port {port})"); + } + portmap::process::KillResult::Error(e) => eprintln!("Error: {e}"), } - println!("Killed {display} (port {port})"); } fn is_homebrew_install() -> bool { diff --git a/src/ports.rs b/src/ports.rs new file mode 100644 index 0000000..0fc5dfc --- /dev/null +++ b/src/ports.rs @@ -0,0 +1,109 @@ +use crate::container::ContainerPort; +use crate::db::App; + +/// Merge TCP scan results with container ports into a single alive list. +/// Container ports outside the scan range are added automatically. +pub fn merge_alive(alive: &mut Vec, container_ports: &[ContainerPort], exclude_port: u16) { + for cp in container_ports { + if !alive.contains(&cp.port) && cp.port != exclude_port { + alive.push(cp.port); + } + } + alive.sort_unstable(); +} + +/// A unified port entry used by both CLI and API. +#[derive(Clone)] +pub struct PortEntry { + pub port: u16, + pub name: String, + pub category: String, + pub source: String, + pub registered: bool, + pub alive: bool, +} + +/// Build a merged list of port entries from apps, alive ports, and container data. +/// This is the single source of truth for how ports are presented. +pub fn build_port_entries( + alive: &[u16], + apps: &[App], + container_ports: &[ContainerPort], +) -> Vec { + let container_map: std::collections::HashMap = + container_ports.iter().map(|cp| (cp.port, cp)).collect(); + let mut entries = Vec::new(); + let mut seen_ports = std::collections::HashSet::new(); + + // Alive ports first + for &port in alive { + seen_ports.insert(port); + let app = apps.iter().find(|a| a.port == i64::from(port)); + let cp = container_map.get(&port); + let source = cp.map_or(String::new(), |c| c.source.clone()); + + if let Some(a) = app { + entries.push(PortEntry { + port, + name: if a.name.is_empty() { + String::new() + } else { + a.name.clone() + }, + category: a.category.clone(), + source, + registered: true, + alive: true, + }); + } else if let Some(c) = cp { + entries.push(PortEntry { + port, + name: c.container_name.clone(), + category: String::new(), + source: c.source.clone(), + registered: false, + alive: true, + }); + } else if let Some(k) = crate::known_ports::lookup(port) { + entries.push(PortEntry { + port, + name: k.name.to_string(), + category: "macos".to_string(), + source: String::new(), + registered: false, + alive: true, + }); + } else { + entries.push(PortEntry { + port, + name: String::new(), + category: String::new(), + source: String::new(), + registered: false, + alive: true, + }); + } + } + + // Offline registered apps + for app in apps { + let port = u16::try_from(app.port).unwrap_or(0); + if seen_ports.contains(&port) { + continue; + } + entries.push(PortEntry { + port, + name: if app.name.is_empty() { + String::new() + } else { + app.name.clone() + }, + category: app.category.clone(), + source: String::new(), + registered: true, + alive: false, + }); + } + + entries +} diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..f7436e1 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,189 @@ +use std::process::Command; + +/// Find PIDs listening on a given port. +/// Returns `Err` if lsof fails to execute, `Ok(vec)` otherwise. +pub fn find_listeners(port: u16) -> Result, std::io::Error> { + let output = Command::new("lsof") + .args(["-ti", &format!(":{port}"), "-sTCP:LISTEN"]) + .output()?; + // lsof exits 1 when no matches found (normal), but other codes are errors + if !output.status.success() && output.status.code() != Some(1) { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(std::io::Error::other(format!( + "lsof exited with {}: {}", + output.status, + stderr.trim() + ))); + } + let stdout = String::from_utf8(output.stdout).unwrap_or_default(); + Ok(stdout + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect()) +} + +/// Kill result indicating what happened. +#[derive(Debug)] +pub enum KillResult { + /// Nothing was listening on the port. + NotFound, + /// Process exited after SIGTERM. + Killed, + /// Process required SIGKILL after SIGTERM didn't work. + ForceKilled, + /// Failed to execute lsof or kill, or permission denied. + Error(String), +} + +/// Check if a PID exists, independent of process ownership. +/// Uses `ps -p` which works for any process regardless of who owns it. +/// Returns `Err` if `ps` itself fails to execute. +fn pid_exists(pid: &str) -> Result { + Command::new("ps") + .args(["-p", pid, "-o", "pid="]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .map(|o| o.status.success()) + .map_err(|e| format!("Failed to check process state: {e}")) +} + +fn all_exited(pids: &[String]) -> Result { + for pid in pids { + if pid_exists(pid)? { + return Ok(false); + } + } + Ok(true) +} + +/// Kill the process listening on a port. Sends SIGTERM first, polls for exit +/// (up to 2s), then SIGKILL any survivors. +pub async fn kill_port(port: u16) -> KillResult { + let pids = match find_listeners(port) { + Ok(p) => p, + Err(e) => return KillResult::Error(format!("Failed to find listeners: {e}")), + }; + if pids.is_empty() { + return KillResult::NotFound; + } + + // SIGTERM (graceful) + for pid in &pids { + let _ = Command::new("kill").arg(pid).status(); + } + + // Poll for exit using ps (ownership-independent) + for _ in 0..20 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match all_exited(&pids) { + Ok(true) => return KillResult::Killed, + Err(e) => return KillResult::Error(e), + Ok(false) => {} + } + } + + // Still alive after 2s — SIGKILL survivors + let mut sent_sigkill = false; + for pid in &pids { + match pid_exists(pid) { + Ok(false) => {} // already gone + Err(e) => return KillResult::Error(e), + Ok(true) => { + let ok = Command::new("kill") + .args(["-9", pid]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if ok { + sent_sigkill = true; + } else { + match pid_exists(pid) { + Ok(true) => { + return KillResult::Error(format!( + "Failed to kill PID {pid} (permission denied?)" + )); + } + Err(e) => return KillResult::Error(e), + Ok(false) => {} // raced away + } + } + } + } + } + + if sent_sigkill { + KillResult::ForceKilled + } else { + KillResult::Killed + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_listeners_unused_port() { + let result = find_listeners(19); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_find_listeners_returns_ok() { + let result = find_listeners(1); + assert!(result.is_ok()); + } + + #[test] + fn test_pid_exists_init() { + // PID 1 (launchd/init) always exists + assert_eq!(pid_exists("1"), Ok(true)); + } + + #[test] + fn test_pid_exists_dead() { + // PID 999999999 should not exist + assert_eq!(pid_exists("999999999"), Ok(false)); + } + + #[tokio::test] + async fn test_kill_port_not_found() { + let result = kill_port(19).await; + assert!(matches!(result, KillResult::NotFound)); + } + + #[tokio::test] + async fn test_kill_port_kills_subprocess() { + use std::process::Stdio; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let child = Command::new("nc") + .args(["-l", &port.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + let Ok(mut child) = child else { + return; // nc not available, skip + }; + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let result = kill_port(port).await; + assert!(matches!( + result, + KillResult::Killed | KillResult::ForceKilled + )); + + let _ = child.kill(); + let _ = child.wait(); + } +} diff --git a/src/template.rs b/src/template.rs index 900774d..f3ce37f 100644 --- a/src/template.rs +++ b/src/template.rs @@ -215,6 +215,7 @@ pub fn render(
+
{SCRIPT} "# @@ -905,6 +906,40 @@ const CSS: &str = r" color: #999; } + #toasts { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 200; + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: flex-end; + } + + .toast { + font-family: inherit; + font-size: 0.75rem; + padding: 0.5rem 0.8rem; + border-radius: 6px; + background: #1a1a1e; + border: 1px solid rgba(255,255,255,0.08); + color: #ccc; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + animation: toast-in 0.2s ease-out; + transition: opacity 0.3s; + } + + .toast.toast-error { + border-color: rgba(239, 68, 68, 0.3); + color: #ef4444; + } + + @keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes spin { to { transform: rotate(360deg); } } .btn.spinning svg { animation: spin 0.6s linear infinite; } "; @@ -996,12 +1031,29 @@ function reapplyFilter() { }); } +function showToast(msg, isError) { + const container = document.getElementById('toasts'); + const el = document.createElement('div'); + el.className = 'toast' + (isError ? ' toast-error' : ''); + el.textContent = msg; + container.appendChild(el); + setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3000); +} + async function deleteApp(appId) { - await fetch(`/api/apps/${appId}`, { method: 'DELETE' }); + const resp = await fetch(`/api/apps/${appId}`, { method: 'DELETE' }); + if (resp.ok) showToast('Unregistered'); + else showToast('Failed to unregister', true); } async function killPort(port) { - await fetch(`/api/kill/${port}`, { method: 'POST' }); + const resp = await fetch(`/api/kill/${port}`, { method: 'POST' }); + if (resp.ok) { + showToast('Process killed'); + } else { + const msg = await resp.text(); + showToast(msg || 'Failed to kill process', true); + } } // -- Row context menu --