From 85ed7c798f9b3eb646121b37414c1880a8c5b88b Mon Sep 17 00:00:00 2001 From: ManOfGoldForever Date: Mon, 2 Mar 2026 10:48:20 -0500 Subject: [PATCH] Finished main project --- README.md | 101 ++++++++++++++++ src/app.rs | 264 +++++++++++++++++++++++++++++++++++++++--- src/main.rs | 322 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 634 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e69de29..233c9d1 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,101 @@ +# process-killer-dashboard 🖥️ + +A fast terminal process dashboard built in Rust. Monitor system processes, sort by resource usage, filter quickly, and terminate processes with confirmation. + +## Table of Contents + +- [Why use this?](#why-use-this) +- [Installation](#installation) +- [Build from Source](#build-from-source) +- [Usage](#usage) +- [Screenshot](#screenshot) +- [Keybinds](#keybinds) +- [Safety and Process Signals](#safety-and-process-signals) +- [Future Plans](#future-plans) +- [Contributing](#contributing) +- [License](#license) + +### Why use this? + +- **Fast TUI:** Lightweight terminal UI with instant startup. +- **Actionable Process View:** See CPU, memory, disk I/O, owner, uptime, and command line. +- **Interactive Sorting + Filtering:** Sort by multiple resources and search by name, PID, or command text. +- **Safer Kill Flow:** Soft/force kill paths with explicit confirmation. + +### Installation + +Right now this project is source-first. Build it locally with Cargo. + +### Build from Source + +1. **Prerequisites** + +- Install Rust via [rustup.rs](https://rustup.rs/). + +2. **Run** + +```bash +cd process-killer-dashboard +cargo run +``` + +3. **Build Release Binary** + +```bash +cargo build --release +``` + +Binary path: + +- `target/release/process-killer-dashboard` + +### Usage + +Start the app: + +```bash +cargo run +``` + +The interface opens in an alternate terminal screen with a live process table. + +### Keybinds + +- `q`: Quit +- `p`: Pause/resume refresh +- `↑` / `↓`: Move selection +- `/`: Enter search mode +- `Enter`: Apply search (Search mode) or confirm kill (Confirm mode) +- `Esc`: Clear search (Search mode) or cancel kill (Confirm mode) +- `y`: Confirm kill (Confirm mode) +- `n`: Cancel kill (Confirm mode), or sort by name (Normal mode) +- `k`: Soft kill (TERM with timeout + fallback) +- `K`: Force kill (KILL) +- `c`: Sort by CPU descending +- `m`: Sort by memory descending +- `r`: Sort by disk read descending +- `w`: Sort by disk write descending +- `i`: Sort by PID ascending + +### Safety and Process Signals + +- Soft kill sends `Signal::Term`, waits up to 2 seconds, then falls back to `Signal::Kill` if needed. +- Force kill sends `Signal::Kill` and waits for termination. +- Signal support and permissions vary by OS and process privileges. +- Terminal state restoration is guarded for normal exits and panic paths. + +### Future Plans + +Potential next steps: + +- Per-process detail panel +- Optional process-group kill controls +- Optional export/snapshot of filtered process table + +### Contributing + +Contributions are welcome. Open an issue or pull request with a clear problem statement and test coverage where possible. + +### License + +This project is licensed under the MIT License. See [LICENSE](./LICENSE). diff --git a/src/app.rs b/src/app.rs index ccafc8b..f5e7118 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,17 @@ use ratatui::{ Frame, - layout::Constraint, + layout::{Constraint, Layout}, style::{Color, Style}, - widgets::{Block, Borders, Row, Table, TableState}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Row, Table, TableState}, }; +use std::cmp::Reverse; use std::ffi::OsStr; -use sysinfo::System; +use std::thread; +use std::time::{Duration, Instant}; use sysinfo::UpdateKind; use sysinfo::Users; +use sysinfo::{Pid, Process, Signal, System}; pub struct SystemStats { pub sys: System, @@ -15,6 +19,21 @@ pub struct SystemStats { pub users: Users, } +#[derive(Clone, Copy)] +pub enum SortBy { + CpuDesc, + MemoryDesc, + DiskReadDesc, + DiskWriteDesc, + NameAsc, + PidAsc, +} + +pub enum SoftKillOutcome { + Terminated, + EscalatedToForceKill, +} + pub fn init_system() -> SystemStats { let mut sys = System::new_all(); sys.refresh_all(); @@ -29,18 +48,14 @@ pub fn init_system() -> SystemStats { pub fn draw_ui( f: &mut Frame, - sys: &mut System, - users: &mut Users, + processes: &[&Process], + users: &Users, cpu_count: f32, state: &mut TableState, + status: &str, + mode_label: &str, + paused: bool, ) { - let mut processes: Vec<_> = sys.processes().values().collect(); - processes.sort_by(|a, b| { - b.cpu_usage() - .partial_cmp(&a.cpu_usage()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let rows: Vec = processes .iter() .map(|p| { @@ -49,7 +64,7 @@ pub fn draw_ui( if let Some(user) = users.get_user_by_id(user_id) { user.name().to_string() } else { - format!("UID: {}", user_id.to_string()) + format!("UID: {:?}", user_id) } } else { "Unknown".to_string() @@ -109,12 +124,110 @@ pub fn draw_ui( Block::default() .borders(Borders::ALL) .title(" Process Manager ") - .title_bottom(" Use ↑/↓ to Scroll, 'q' to Quit, 'p' to Pause Refresh "), + .title_bottom( + " Use ↑/↓ Scroll, q Quit, p Pause, / Search, Enter Apply/Confirm, Esc Clear/Cancel, y Confirm, n Cancel, k Soft Kill, K Force Kill, c CPU, m Memory, r Disk Read, w Disk Write, n Name, i PID ", + ), ) .row_highlight_style(Style::new().bg(Color::Cyan).fg(Color::Black).bold()) .highlight_symbol(">> "); - f.render_stateful_widget(table, f.area(), state); + let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area()); + let footer_chunks = + Layout::horizontal([Constraint::Min(1), Constraint::Length(18)]).split(chunks[1]); + let mode_color = match mode_label { + "NORMAL" => Color::Green, + "SEARCH" => Color::Cyan, + "CONFIRM" => Color::Red, + _ => Color::White, + }; + let status_color = if status.contains("failed") { + Color::Red + } else if status.contains("cancelled") || status.contains("cleared") { + Color::LightYellow + } else { + Color::Yellow + }; + let footer = Line::from(vec![ + Span::styled("[", Style::new().fg(Color::DarkGray)), + Span::styled(mode_label, Style::new().fg(mode_color).bold()), + Span::styled("] ", Style::new().fg(Color::DarkGray)), + Span::styled(status, Style::new().fg(status_color)), + ]); + + let pause_label = if paused { "PAUSED" } else { "UNPAUSED" }; + let pause_color = if paused { Color::Yellow } else { Color::Green }; + let pause_badge = Line::from(vec![ + Span::styled("[", Style::new().fg(Color::DarkGray)), + Span::styled(pause_label, Style::new().fg(pause_color).bold()), + Span::styled("]", Style::new().fg(Color::DarkGray)), + ]); + + f.render_stateful_widget(table, chunks[0], state); + f.render_widget(Paragraph::new(footer), footer_chunks[0]); + f.render_widget( + Paragraph::new(pause_badge).alignment(ratatui::layout::Alignment::Right), + footer_chunks[1], + ); +} + +pub fn sort_label(sort_by: SortBy) -> &'static str { + match sort_by { + SortBy::CpuDesc => "CPU desc", + SortBy::MemoryDesc => "Memory desc", + SortBy::DiskReadDesc => "Disk Read desc", + SortBy::DiskWriteDesc => "Disk Write desc", + SortBy::NameAsc => "Name asc", + SortBy::PidAsc => "PID asc", + } +} + +pub fn sorted_processes<'a>( + sys: &'a System, + sort_by: SortBy, + search_query: &str, +) -> Vec<&'a Process> { + let mut processes: Vec<_> = sys.processes().values().collect(); + let query = search_query.trim(); + + if !query.is_empty() { + processes.retain(|p| { + let name = p.name().to_string_lossy(); + let cmd_joined = p.cmd().join(OsStr::new(" ")); + let cmd = cmd_joined.to_string_lossy(); + process_matches_query(&name, &cmd, p.pid(), query) + }); + } + + match sort_by { + SortBy::CpuDesc => processes.sort_by(|a, b| { + b.cpu_usage() + .partial_cmp(&a.cpu_usage()) + .unwrap_or(std::cmp::Ordering::Equal) + }), + SortBy::MemoryDesc => processes.sort_by_key(|p| Reverse(p.memory())), + SortBy::DiskReadDesc => processes.sort_by_key(|p| Reverse(p.disk_usage().total_read_bytes)), + SortBy::DiskWriteDesc => { + processes.sort_by_key(|p| Reverse(p.disk_usage().total_written_bytes)) + } + SortBy::NameAsc => processes.sort_by(|a, b| a.name().cmp(b.name())), + SortBy::PidAsc => processes.sort_by_key(|p| p.pid().as_u32()), + } + + processes +} + +pub fn selected_pid_from_visible(visible_pids: &[Pid], selected: Option) -> Option { + let index = selected?; + visible_pids.get(index).copied() +} + +fn process_matches_query(name: &str, cmd: &str, pid: Pid, query: &str) -> bool { + let query_lc = query.to_lowercase(); + let name_lc = name.to_lowercase(); + let cmd_lc = cmd.to_lowercase(); + let pid_str = pid.to_string(); + + name_lc.contains(&query_lc) || cmd_lc.contains(&query_lc) || pid_str.contains(&query_lc) } pub fn refresh_system_data(sys: &mut System) { @@ -154,3 +267,124 @@ fn format_duration(seconds: u64) -> String { format!("{:02}:{:02}", minutes, secs) } } + +fn send_signal(pid: Pid, signal: Signal) -> Result<(), String> { + let mut sys = System::new_all(); + sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + + let process = sys + .process(pid) + .ok_or_else(|| format!("PID {} not found", pid.as_u32()))?; + + match process.kill_with(signal) { + Some(true) => Ok(()), + Some(false) => Err(format!( + "Failed to send {:?} to PID {}", + signal, + pid.as_u32() + )), + None => Err(format!( + "Signal {:?} is not supported on this platform", + signal + )), + } +} + +fn wait_for_process_exit(pid: Pid, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + let mut sys = System::new_all(); + sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + if sys.process(pid).is_none() { + return true; + } + thread::sleep(Duration::from_millis(100)); + } + false +} + +pub fn soft_kill_process(pid: Pid) -> Result { + send_signal(pid, Signal::Term)?; + + if wait_for_process_exit(pid, Duration::from_secs(2)) { + return Ok(SoftKillOutcome::Terminated); + } + + send_signal(pid, Signal::Kill)?; + + if wait_for_process_exit(pid, Duration::from_secs(1)) { + Ok(SoftKillOutcome::EscalatedToForceKill) + } else { + Err(format!( + "PID {} did not exit after TERM timeout and KILL fallback", + pid.as_u32() + )) + } +} + +pub fn force_kill_process(pid: Pid) -> Result<(), String> { + send_signal(pid, Signal::Kill)?; + if wait_for_process_exit(pid, Duration::from_secs(1)) { + Ok(()) + } else { + Err(format!( + "PID {} did not exit after force kill", + pid.as_u32() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_query_matches_name_cmd_and_pid_case_insensitive() { + let pid = Pid::from(4242usize); + assert!(process_matches_query( + "Firefox", + "firefox --private", + pid, + "fire" + )); + assert!(process_matches_query( + "daemon", + "python server.py", + pid, + "PYTHON" + )); + assert!(process_matches_query( + "daemon", + "python server.py", + pid, + "4242" + )); + assert!(!process_matches_query( + "daemon", + "python server.py", + pid, + "nomatch" + )); + } + + #[test] + fn selected_pid_from_visible_returns_expected_index() { + let pids = vec![Pid::from(10usize), Pid::from(22usize), Pid::from(35usize)]; + assert_eq!( + selected_pid_from_visible(&pids, Some(1)), + Some(Pid::from(22usize)) + ); + assert_eq!(selected_pid_from_visible(&pids, Some(3)), None); + assert_eq!(selected_pid_from_visible(&pids, None), None); + } + + #[test] + fn sort_labels_are_stable() { + assert_eq!(sort_label(SortBy::CpuDesc), "CPU desc"); + assert_eq!(sort_label(SortBy::MemoryDesc), "Memory desc"); + assert_eq!(sort_label(SortBy::DiskReadDesc), "Disk Read desc"); + assert_eq!(sort_label(SortBy::DiskWriteDesc), "Disk Write desc"); + assert_eq!(sort_label(SortBy::NameAsc), "Name asc"); + assert_eq!(sort_label(SortBy::PidAsc), "PID asc"); + } +} diff --git a/src/main.rs b/src/main.rs index 0e4a9e4..bd5b0ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,76 +1,322 @@ mod app; use crossterm::event::{self, Event, KeyCode}; +use crossterm::{cursor, execute}; use ratatui::widgets::TableState; use ratatui::{Terminal, backend::CrosstermBackend}; use std::io::{self}; +use std::panic; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; +use sysinfo::Pid; -use app::{draw_ui, init_system, refresh_system_data}; +use app::{ + SoftKillOutcome, SortBy, draw_ui, force_kill_process, init_system, refresh_system_data, + selected_pid_from_visible, soft_kill_process, sort_label, sorted_processes, +}; -fn main() -> Result<(), Box> { - crossterm::terminal::enable_raw_mode()?; +static TERMINAL_RESTORED: AtomicBool = AtomicBool::new(true); + +enum InputMode { + Normal, + Search, + ConfirmKill { pid: Pid, force: bool }, +} + +struct TerminalSession; + +impl TerminalSession { + fn enter() -> Result> { + TERMINAL_RESTORED.store(false, Ordering::SeqCst); + crossterm::terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + if let Err(err) = execute!( + stdout, + crossterm::terminal::EnterAlternateScreen, + cursor::Hide + ) { + restore_terminal_state(); + return Err(Box::new(err)); + } + Ok(Self) + } + + fn restore(&mut self) { + restore_terminal_state(); + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + restore_terminal_state(); + } +} + +fn restore_terminal_state() { + if TERMINAL_RESTORED.swap(true, Ordering::SeqCst) { + return; + } + + let _ = crossterm::terminal::disable_raw_mode(); let mut stdout = io::stdout(); - crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?; - let mut paused: bool = false; + let _ = execute!( + stdout, + crossterm::terminal::LeaveAlternateScreen, + cursor::Show + ); +} - let backend = CrosstermBackend::new(stdout); +fn install_panic_hook() { + let default_hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic_info| { + restore_terminal_state(); + default_hook(panic_info); + })); +} + +fn mode_label(mode: &InputMode) -> &'static str { + match mode { + InputMode::Normal => "NORMAL", + InputMode::Search => "SEARCH", + InputMode::ConfirmKill { .. } => "CONFIRM", + } +} + +fn run_app() -> Result<(), Box> { + let mut paused = false; + + let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; terminal.clear()?; + let mut stats = init_system(); let mut table_state = TableState::default(); table_state.select(Some(0)); let mut last_tick = Instant::now(); let tick_rate = Duration::from_secs(1); + let mut status_message = String::from("Ready"); + let mut sort_by = SortBy::CpuDesc; + let mut search_query = String::new(); + let mut mode = InputMode::Normal; + loop { + if !paused && last_tick.elapsed() >= tick_rate { + refresh_system_data(&mut stats.sys); + last_tick = Instant::now(); + } + + let visible_processes = sorted_processes(&stats.sys, sort_by, &search_query); + let visible_pids: Vec = visible_processes.iter().map(|p| p.pid()).collect(); + + if visible_pids.is_empty() { + table_state.select(None); + } else { + let selected = table_state + .selected() + .unwrap_or(0) + .min(visible_pids.len() - 1); + table_state.select(Some(selected)); + } + terminal.draw(|f| { draw_ui( f, - &mut stats.sys, - &mut stats.users, + &visible_processes, + &stats.users, stats.cpu_count, &mut table_state, + &status_message, + mode_label(&mode), + paused, ); })?; - if !paused { - if last_tick.elapsed() >= tick_rate { - refresh_system_data(&mut stats.sys); - last_tick = Instant::now(); - } + if !event::poll(Duration::from_millis(50))? { + continue; } - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Char('q') => break, - KeyCode::Char('p') => paused = !paused, - KeyCode::Down => { - let i = match table_state.selected() { - Some(i) => (i + 1).min(stats.sys.processes().len() - 1), - None => 0, - }; - table_state.select(Some(i)); + if let Event::Key(key) = event::read()? { + match &mut mode { + InputMode::Search => { + match key.code { + KeyCode::Esc => { + search_query.clear(); + mode = InputMode::Normal; + status_message = "Search cleared".to_string(); + table_state.select(Some(0)); + } + KeyCode::Enter => { + mode = InputMode::Normal; + if search_query.trim().is_empty() { + status_message = "Search cleared".to_string(); + } else { + status_message = format!("Filter: {}", search_query); + } + table_state.select(Some(0)); + } + KeyCode::Backspace => { + search_query.pop(); + status_message = format!("Search: {}", search_query); + table_state.select(Some(0)); + } + KeyCode::Char(c) => { + search_query.push(c); + status_message = format!("Search: {}", search_query); + table_state.select(Some(0)); + } + _ => {} } - KeyCode::Up => { - let i = match table_state.selected() { - Some(i) => i.saturating_sub(1), - None => 0, - }; - table_state.select(Some(i)); - } - _ => {} + continue; } + InputMode::ConfirmKill { pid, force } => { + match key.code { + KeyCode::Enter | KeyCode::Char('y') => { + if *force { + match force_kill_process(*pid) { + Ok(()) => { + status_message = + format!("Force-killed PID {}", pid.as_u32()); + } + Err(err) => { + status_message = format!( + "Force kill failed for PID {}: {}", + pid.as_u32(), + err + ); + } + } + } else { + match soft_kill_process(*pid) { + Ok(SoftKillOutcome::Terminated) => { + status_message = + format!("Soft-killed PID {}", pid.as_u32()); + } + Ok(SoftKillOutcome::EscalatedToForceKill) => { + status_message = format!( + "Soft kill timed out; force-killed PID {}", + pid.as_u32() + ); + } + Err(err) => { + status_message = format!( + "Soft kill failed for PID {}: {}", + pid.as_u32(), + err + ); + } + } + } + + refresh_system_data(&mut stats.sys); + mode = InputMode::Normal; + } + KeyCode::Esc | KeyCode::Char('n') => { + mode = InputMode::Normal; + status_message = "Kill cancelled".to_string(); + } + _ => {} + } + continue; + } + InputMode::Normal => {} + } + + if !matches!(mode, InputMode::Normal) { + continue; + } + + match key.code { + KeyCode::Char('q') => break, + KeyCode::Char('p') => paused = !paused, + KeyCode::Char('/') => { + mode = InputMode::Search; + status_message = format!("Search: {}", search_query); + } + KeyCode::Char('k') => { + if let Some(pid) = + selected_pid_from_visible(&visible_pids, table_state.selected()) + { + mode = InputMode::ConfirmKill { pid, force: false }; + status_message = format!( + "Confirm soft kill PID {}? Press Enter/y to confirm, Esc/n to cancel", + pid.as_u32() + ); + } else { + status_message = "No process selected".to_string(); + } + } + KeyCode::Char('K') => { + if let Some(pid) = + selected_pid_from_visible(&visible_pids, table_state.selected()) + { + mode = InputMode::ConfirmKill { pid, force: true }; + status_message = format!( + "Confirm force kill PID {}? Press Enter/y to confirm, Esc/n to cancel", + pid.as_u32() + ); + } else { + status_message = "No process selected".to_string(); + } + } + KeyCode::Char('c') => { + sort_by = SortBy::CpuDesc; + status_message = format!("Sort: {}", sort_label(sort_by)); + table_state.select(Some(0)); + } + KeyCode::Char('m') => { + sort_by = SortBy::MemoryDesc; + status_message = format!("Sort: {}", sort_label(sort_by)); + table_state.select(Some(0)); + } + KeyCode::Char('r') => { + sort_by = SortBy::DiskReadDesc; + status_message = format!("Sort: {}", sort_label(sort_by)); + table_state.select(Some(0)); + } + KeyCode::Char('w') => { + sort_by = SortBy::DiskWriteDesc; + status_message = format!("Sort: {}", sort_label(sort_by)); + table_state.select(Some(0)); + } + KeyCode::Char('n') => { + sort_by = SortBy::NameAsc; + status_message = format!("Sort: {}", sort_label(sort_by)); + table_state.select(Some(0)); + } + KeyCode::Char('i') => { + sort_by = SortBy::PidAsc; + status_message = format!("Sort: {}", sort_label(sort_by)); + table_state.select(Some(0)); + } + KeyCode::Down => { + if !visible_pids.is_empty() { + let i = match table_state.selected() { + Some(i) => (i + 1).min(visible_pids.len() - 1), + None => 0, + }; + table_state.select(Some(i)); + } + } + KeyCode::Up => { + let i = match table_state.selected() { + Some(i) => i.saturating_sub(1), + None => 0, + }; + table_state.select(Some(i)); + } + _ => {} } } } - crossterm::execute!( - io::stdout(), - crossterm::terminal::LeaveAlternateScreen, - crossterm::cursor::Show - )?; - crossterm::terminal::disable_raw_mode()?; Ok(()) } + +fn main() -> Result<(), Box> { + install_panic_hook(); + let mut terminal_session = TerminalSession::enter()?; + let run_result = run_app(); + terminal_session.restore(); + run_result +}