Finished main project

This commit is contained in:
ManOfGoldForever 2026-03-02 10:48:20 -05:00
parent 6242b97208
commit 85ed7c798f
3 changed files with 634 additions and 53 deletions

View file

@ -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<Row> = 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<usize>) -> Option<Pid> {
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<SoftKillOutcome, String> {
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");
}
}

View file

@ -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<dyn std::error::Error>> {
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<Self, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Pid> = 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<dyn std::error::Error>> {
install_panic_hook();
let mut terminal_session = TerminalSession::enter()?;
let run_result = run_app();
terminal_session.restore();
run_result
}