Finished main project
This commit is contained in:
parent
6242b97208
commit
85ed7c798f
3 changed files with 634 additions and 53 deletions
101
README.md
101
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).
|
||||||
264
src/app.rs
264
src/app.rs
|
|
@ -1,13 +1,17 @@
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Constraint,
|
layout::{Constraint, Layout},
|
||||||
style::{Color, Style},
|
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 std::ffi::OsStr;
|
||||||
use sysinfo::System;
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use sysinfo::UpdateKind;
|
use sysinfo::UpdateKind;
|
||||||
use sysinfo::Users;
|
use sysinfo::Users;
|
||||||
|
use sysinfo::{Pid, Process, Signal, System};
|
||||||
|
|
||||||
pub struct SystemStats {
|
pub struct SystemStats {
|
||||||
pub sys: System,
|
pub sys: System,
|
||||||
|
|
@ -15,6 +19,21 @@ pub struct SystemStats {
|
||||||
pub users: Users,
|
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 {
|
pub fn init_system() -> SystemStats {
|
||||||
let mut sys = System::new_all();
|
let mut sys = System::new_all();
|
||||||
sys.refresh_all();
|
sys.refresh_all();
|
||||||
|
|
@ -29,18 +48,14 @@ pub fn init_system() -> SystemStats {
|
||||||
|
|
||||||
pub fn draw_ui(
|
pub fn draw_ui(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
sys: &mut System,
|
processes: &[&Process],
|
||||||
users: &mut Users,
|
users: &Users,
|
||||||
cpu_count: f32,
|
cpu_count: f32,
|
||||||
state: &mut TableState,
|
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
|
let rows: Vec<Row> = processes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
|
|
@ -49,7 +64,7 @@ pub fn draw_ui(
|
||||||
if let Some(user) = users.get_user_by_id(user_id) {
|
if let Some(user) = users.get_user_by_id(user_id) {
|
||||||
user.name().to_string()
|
user.name().to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("UID: {}", user_id.to_string())
|
format!("UID: {:?}", user_id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
"Unknown".to_string()
|
"Unknown".to_string()
|
||||||
|
|
@ -109,12 +124,110 @@ pub fn draw_ui(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(" Process Manager ")
|
.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())
|
.row_highlight_style(Style::new().bg(Color::Cyan).fg(Color::Black).bold())
|
||||||
.highlight_symbol(">> ");
|
.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) {
|
pub fn refresh_system_data(sys: &mut System) {
|
||||||
|
|
@ -154,3 +267,124 @@ fn format_duration(seconds: u64) -> String {
|
||||||
format!("{:02}:{:02}", minutes, secs)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
298
src/main.rs
298
src/main.rs
|
|
@ -1,58 +1,303 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode};
|
use crossterm::event::{self, Event, KeyCode};
|
||||||
|
use crossterm::{cursor, execute};
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
use std::io::{self};
|
use std::io::{self};
|
||||||
|
use std::panic;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
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>> {
|
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()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?;
|
if let Err(err) = execute!(
|
||||||
let mut paused: bool = false;
|
stdout,
|
||||||
|
crossterm::terminal::EnterAlternateScreen,
|
||||||
|
cursor::Hide
|
||||||
|
) {
|
||||||
|
restore_terminal_state();
|
||||||
|
return Err(Box::new(err));
|
||||||
|
}
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
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();
|
||||||
|
let _ = execute!(
|
||||||
|
stdout,
|
||||||
|
crossterm::terminal::LeaveAlternateScreen,
|
||||||
|
cursor::Show
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
|
|
||||||
let mut stats = init_system();
|
let mut stats = init_system();
|
||||||
let mut table_state = TableState::default();
|
let mut table_state = TableState::default();
|
||||||
table_state.select(Some(0));
|
table_state.select(Some(0));
|
||||||
let mut last_tick = Instant::now();
|
let mut last_tick = Instant::now();
|
||||||
let tick_rate = Duration::from_secs(1);
|
let tick_rate = Duration::from_secs(1);
|
||||||
|
|
||||||
loop {
|
let mut status_message = String::from("Ready");
|
||||||
terminal.draw(|f| {
|
let mut sort_by = SortBy::CpuDesc;
|
||||||
draw_ui(
|
let mut search_query = String::new();
|
||||||
f,
|
let mut mode = InputMode::Normal;
|
||||||
&mut stats.sys,
|
|
||||||
&mut stats.users,
|
|
||||||
stats.cpu_count,
|
|
||||||
&mut table_state,
|
|
||||||
);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !paused {
|
loop {
|
||||||
if last_tick.elapsed() >= tick_rate {
|
if !paused && last_tick.elapsed() >= tick_rate {
|
||||||
refresh_system_data(&mut stats.sys);
|
refresh_system_data(&mut stats.sys);
|
||||||
last_tick = Instant::now();
|
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,
|
||||||
|
&visible_processes,
|
||||||
|
&stats.users,
|
||||||
|
stats.cpu_count,
|
||||||
|
&mut table_state,
|
||||||
|
&status_message,
|
||||||
|
mode_label(&mode),
|
||||||
|
paused,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !event::poll(Duration::from_millis(50))? {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(50))? {
|
|
||||||
if let Event::Key(key) = event::read()? {
|
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));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
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 {
|
match key.code {
|
||||||
KeyCode::Char('q') => break,
|
KeyCode::Char('q') => break,
|
||||||
KeyCode::Char('p') => paused = !paused,
|
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 => {
|
KeyCode::Down => {
|
||||||
|
if !visible_pids.is_empty() {
|
||||||
let i = match table_state.selected() {
|
let i = match table_state.selected() {
|
||||||
Some(i) => (i + 1).min(stats.sys.processes().len() - 1),
|
Some(i) => (i + 1).min(visible_pids.len() - 1),
|
||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
table_state.select(Some(i));
|
table_state.select(Some(i));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
let i = match table_state.selected() {
|
let i = match table_state.selected() {
|
||||||
Some(i) => i.saturating_sub(1),
|
Some(i) => i.saturating_sub(1),
|
||||||
|
|
@ -64,13 +309,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
crossterm::execute!(
|
|
||||||
io::stdout(),
|
|
||||||
crossterm::terminal::LeaveAlternateScreen,
|
|
||||||
crossterm::cursor::Show
|
|
||||||
)?;
|
|
||||||
crossterm::terminal::disable_raw_mode()?;
|
|
||||||
Ok(())
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue