first commit

This commit is contained in:
DigiJ
2026-03-13 13:59:56 -07:00
commit 2a4867296e
22 changed files with 5373 additions and 0 deletions

21
procmon-tui/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "procmon-tui"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "procmon-tui"
path = "src/main.rs"
[dependencies]
procmon-core = { path = "../procmon-core" }
tokio.workspace = true
anyhow.workspace = true
ratatui.workspace = true
crossterm.workspace = true
chrono.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true

764
procmon-tui/src/app.rs Normal file
View File

@@ -0,0 +1,764 @@
use anyhow::Result;
use procmon_core::{
MisbehaviorDetector, SystemMetrics, SystemMonitor,
process::ProcessSnapshot,
ServiceManager, SystemService,
};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Dashboard,
Processes,
Services,
Storage,
Network,
Partitions,
Alerts,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
Name,
Cpu,
Memory,
DiskIo,
User,
}
pub struct App {
pub monitor: SystemMonitor,
pub detector: MisbehaviorDetector,
pub partition_manager: procmon_core::PartitionManager,
pub service_manager: ServiceManager,
pub system_metrics: SystemMetrics,
pub processes: Vec<ProcessSnapshot>,
pub filtered_processes: Vec<ProcessSnapshot>,
pub services: Vec<SystemService>,
pub filtered_services: Vec<SystemService>,
pub disks: Vec<procmon_core::Disk>,
pub alerts: Vec<procmon_core::MisbehaviorAlert>,
pub current_tab: Tab,
pub selected_process: usize,
pub selected_service: usize,
pub selected_disk: usize,
pub selected_partition: usize,
pub sort_column: SortColumn,
pub sort_ascending: bool,
pub show_only_misbehaving: bool,
pub show_context_menu: bool,
pub show_service_menu: bool,
pub show_partition_menu: bool,
pub context_menu_pid: Option<u32>,
pub context_menu_service: Option<String>,
pub status_message: Option<String>,
pub search_query: String,
pub search_mode: bool,
pub scroll_offset: usize,
pub process_list_area: Option<(u16, u16, u16, u16)>, // (x, y, width, height) for process table
last_update: Instant,
update_interval: Duration,
last_click_time: Option<Instant>,
last_click_row: Option<usize>,
}
impl App {
pub async fn new() -> Result<Self> {
let monitor = SystemMonitor::new();
let detector = MisbehaviorDetector::new();
let partition_manager = procmon_core::PartitionManager::new();
let service_manager = ServiceManager::new();
monitor.refresh();
let system_metrics = monitor.get_system_metrics()?;
let processes = monitor.get_all_processes()?;
let disks = partition_manager.list_disks().unwrap_or_default();
let services = service_manager.list_services().unwrap_or_default();
let filtered_processes = processes.clone();
let filtered_services = services.clone();
Ok(Self {
monitor,
detector,
partition_manager,
service_manager,
system_metrics,
processes,
filtered_processes,
services,
filtered_services,
disks,
alerts: Vec::new(),
current_tab: Tab::Dashboard,
selected_process: 0,
selected_service: 0,
selected_disk: 0,
selected_partition: 0,
sort_column: SortColumn::Cpu,
sort_ascending: false,
show_only_misbehaving: false,
show_context_menu: false,
show_service_menu: false,
show_partition_menu: false,
context_menu_pid: None,
context_menu_service: None,
status_message: None,
search_query: String::new(),
search_mode: false,
scroll_offset: 0,
process_list_area: None,
last_update: Instant::now(),
update_interval: Duration::from_millis(1000),
last_click_time: None,
last_click_row: None,
})
}
pub fn handle_mouse_click(&mut self, x: u16, y: u16) {
// Check if click is within process list area
if let Some((area_x, area_y, area_width, area_height)) = self.process_list_area {
if x >= area_x && x < area_x + area_width && y >= area_y && y < area_y + area_height {
// Calculate which row was clicked (accounting for borders and header)
// area_y is the top of the block, +1 for border, +2 for header with spacing
let header_offset = 3; // border + header + spacing
if y >= area_y + header_offset {
let clicked_row = (y - area_y - header_offset) as usize;
let actual_index = clicked_row + self.scroll_offset;
if actual_index < self.filtered_processes.len() {
// Check for double-click (within 500ms)
let now = Instant::now();
let is_double_click = if let (Some(last_time), Some(last_row)) = (self.last_click_time, self.last_click_row) {
now.duration_since(last_time) < Duration::from_millis(500) && last_row == actual_index
} else {
false
};
self.selected_process = actual_index;
if is_double_click {
// Double-click opens context menu
self.toggle_context_menu();
self.last_click_time = None;
self.last_click_row = None;
} else {
// Single click just selects
self.last_click_time = Some(now);
self.last_click_row = Some(actual_index);
}
}
}
}
}
}
pub fn set_process_list_area(&mut self, x: u16, y: u16, width: u16, height: u16) {
self.process_list_area = Some((x, y, width, height));
}
pub fn toggle_search_mode(&mut self) {
self.search_mode = !self.search_mode;
if !self.search_mode {
self.search_query.clear();
self.filter_processes();
}
}
pub fn add_search_char(&mut self, c: char) {
self.search_query.push(c);
self.filter_processes();
self.selected_process = 0;
self.scroll_offset = 0;
}
pub fn remove_search_char(&mut self) {
self.search_query.pop();
self.filter_processes();
self.selected_process = 0;
self.scroll_offset = 0;
}
fn filter_processes(&mut self) {
if self.search_query.is_empty() {
self.filtered_processes = self.processes.clone();
} else {
let query_lower = self.search_query.to_lowercase();
self.filtered_processes = self.processes
.iter()
.filter(|p| {
p.info.name.to_lowercase().contains(&query_lower)
|| p.info.pid.to_string().contains(&query_lower)
|| p.info.user.to_lowercase().contains(&query_lower)
})
.cloned()
.collect();
}
}
pub fn scroll_up(&mut self, amount: usize) {
if self.scroll_offset >= amount {
self.scroll_offset -= amount;
} else {
self.scroll_offset = 0;
}
}
pub fn scroll_down(&mut self, amount: usize, max_visible: usize) {
let max_scroll = self.filtered_processes.len().saturating_sub(max_visible);
self.scroll_offset = (self.scroll_offset + amount).min(max_scroll);
}
pub fn get_filtered_processes(&self) -> &[ProcessSnapshot] {
&self.filtered_processes
}
pub fn next_disk(&mut self) {
if !self.disks.is_empty() {
self.selected_disk = (self.selected_disk + 1) % self.disks.len();
self.selected_partition = 0;
}
}
pub fn previous_disk(&mut self) {
if !self.disks.is_empty() {
if self.selected_disk == 0 {
self.selected_disk = self.disks.len() - 1;
} else {
self.selected_disk -= 1;
}
self.selected_partition = 0;
}
}
pub fn next_partition(&mut self) {
if self.selected_disk < self.disks.len() {
let partitions = &self.disks[self.selected_disk].partitions;
if !partitions.is_empty() {
self.selected_partition = (self.selected_partition + 1) % partitions.len();
}
}
}
pub fn previous_partition(&mut self) {
if self.selected_disk < self.disks.len() {
let partitions = &self.disks[self.selected_disk].partitions;
if !partitions.is_empty() {
if self.selected_partition == 0 {
self.selected_partition = partitions.len() - 1;
} else {
self.selected_partition -= 1;
}
}
}
}
pub fn toggle_partition_menu(&mut self) {
self.show_partition_menu = !self.show_partition_menu;
}
pub fn refresh_disks(&mut self) {
if let Ok(disks) = self.partition_manager.list_disks() {
self.disks = disks;
self.status_message = Some("Disk list refreshed".to_string());
} else {
self.status_message = Some("Failed to refresh disks".to_string());
}
}
pub fn format_selected_partition(&mut self, filesystem: &str) -> Result<()> {
if self.selected_disk >= self.disks.len() {
self.status_message = Some("No disk selected".to_string());
return Ok(());
}
let disk = &self.disks[self.selected_disk];
if self.selected_partition >= disk.partitions.len() {
self.status_message = Some("No partition selected".to_string());
return Ok(());
}
let partition = &disk.partitions[self.selected_partition];
let device = &partition.device;
match self.partition_manager.format_partition(device, filesystem, None) {
Ok(_) => {
self.status_message = Some(format!("Formatted {} as {}", device, filesystem));
self.refresh_disks();
}
Err(e) => {
self.status_message = Some(format!("Format failed: {}", e));
}
}
Ok(())
}
pub fn delete_selected_partition(&mut self) -> Result<()> {
if self.selected_disk >= self.disks.len() {
self.status_message = Some("No disk selected".to_string());
return Ok(());
}
let disk = &self.disks[self.selected_disk];
if self.selected_partition >= disk.partitions.len() {
self.status_message = Some("No partition selected".to_string());
return Ok(());
}
let partition = &disk.partitions[self.selected_partition];
if let Some(part_num) = partition.partition_number {
match self.partition_manager.delete_partition(&disk.device, part_num) {
Ok(_) => {
self.status_message = Some(format!("Deleted partition {}", partition.device));
self.refresh_disks();
}
Err(e) => {
self.status_message = Some(format!("Delete failed: {}", e));
}
}
} else {
self.status_message = Some("Cannot determine partition number".to_string());
}
Ok(())
}
pub fn check_selected_partition(&mut self) -> Result<()> {
if self.selected_disk >= self.disks.len() {
self.status_message = Some("No disk selected".to_string());
return Ok(());
}
let disk = &self.disks[self.selected_disk];
if self.selected_partition >= disk.partitions.len() {
self.status_message = Some("No partition selected".to_string());
return Ok(());
}
let partition = &disk.partitions[self.selected_partition];
if let Some(ref fs) = partition.filesystem {
match self.partition_manager.check_filesystem(&partition.device, fs, false) {
Ok(result) => {
self.status_message = Some(format!("Check complete. See logs for details."));
}
Err(e) => {
self.status_message = Some(format!("Check failed: {}", e));
}
}
} else {
self.status_message = Some("No filesystem detected".to_string());
}
Ok(())
}
pub async fn update(&mut self) -> Result<()> {
if self.last_update.elapsed() >= self.update_interval {
self.monitor.refresh();
self.system_metrics = self.monitor.get_system_metrics()?;
self.processes = self.monitor.get_all_processes()?;
// Update services list
if let Ok(services) = self.service_manager.list_services() {
self.services = services;
self.filtered_services = self.services.clone();
}
// Check for misbehaving processes
let mut new_alerts = Vec::new();
for process in &self.processes {
let process_alerts = self.detector.check_process(process);
new_alerts.extend(process_alerts);
}
// Keep only recent alerts (last 100)
self.alerts.extend(new_alerts);
if self.alerts.len() > 100 {
self.alerts.drain(0..self.alerts.len() - 100);
}
// Cleanup detector state for dead processes
let active_pids: Vec<u32> = self.processes.iter().map(|p| p.info.pid).collect();
self.detector.cleanup_dead_processes(&active_pids);
// Sort processes and apply filter
self.sort_processes();
self.filter_processes();
self.last_update = Instant::now();
}
Ok(())
}
fn sort_processes(&mut self) {
let ascending = self.sort_ascending;
match self.sort_column {
SortColumn::Name => {
self.processes.sort_by(|a, b| {
if ascending {
a.info.name.cmp(&b.info.name)
} else {
b.info.name.cmp(&a.info.name)
}
});
}
SortColumn::Cpu => {
self.processes.sort_by(|a, b| {
if ascending {
a.stats.cpu_usage.partial_cmp(&b.stats.cpu_usage).unwrap()
} else {
b.stats.cpu_usage.partial_cmp(&a.stats.cpu_usage).unwrap()
}
});
}
SortColumn::Memory => {
self.processes.sort_by(|a, b| {
if ascending {
a.stats.memory_usage.cmp(&b.stats.memory_usage)
} else {
b.stats.memory_usage.cmp(&a.stats.memory_usage)
}
});
}
SortColumn::DiskIo => {
self.processes.sort_by(|a, b| {
let a_io = a.stats.disk_read_bytes + a.stats.disk_write_bytes;
let b_io = b.stats.disk_read_bytes + b.stats.disk_write_bytes;
if ascending {
a_io.cmp(&b_io)
} else {
b_io.cmp(&a_io)
}
});
}
SortColumn::User => {
self.processes.sort_by(|a, b| {
if ascending {
a.info.user.cmp(&b.info.user)
} else {
b.info.user.cmp(&a.info.user)
}
});
}
}
}
pub fn next_process(&mut self) {
if !self.filtered_processes.is_empty() {
self.selected_process = (self.selected_process + 1) % self.filtered_processes.len();
self.ensure_selected_visible();
}
}
pub fn previous_process(&mut self) {
if !self.filtered_processes.is_empty() {
if self.selected_process == 0 {
self.selected_process = self.filtered_processes.len() - 1;
} else {
self.selected_process -= 1;
}
self.ensure_selected_visible();
}
}
fn ensure_selected_visible(&mut self) {
// Assume visible area is around 20 rows (will be adjusted dynamically in UI)
let visible_rows = 20;
// If selected is below visible area, scroll down
if self.selected_process >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected_process.saturating_sub(visible_rows - 1);
}
// If selected is above visible area, scroll up
if self.selected_process < self.scroll_offset {
self.scroll_offset = self.selected_process;
}
}
pub fn set_visible_rows(&mut self, rows: usize) {
// This will be called from UI to set the actual visible area
// For now we use a default in ensure_selected_visible
}
pub fn next_tab(&mut self) {
self.current_tab = match self.current_tab {
Tab::Dashboard => Tab::Processes,
Tab::Processes => Tab::Services,
Tab::Services => Tab::Storage,
Tab::Storage => Tab::Network,
Tab::Network => Tab::Partitions,
Tab::Partitions => Tab::Alerts,
Tab::Alerts => Tab::Dashboard,
};
}
pub fn previous_tab(&mut self) {
self.current_tab = match self.current_tab {
Tab::Dashboard => Tab::Alerts,
Tab::Processes => Tab::Dashboard,
Tab::Services => Tab::Processes,
Tab::Storage => Tab::Services,
Tab::Network => Tab::Storage,
Tab::Partitions => Tab::Network,
Tab::Alerts => Tab::Partitions,
};
}
pub fn set_tab(&mut self, index: usize) {
self.current_tab = match index {
0 => Tab::Dashboard,
1 => Tab::Processes,
2 => Tab::Services,
3 => Tab::Storage,
4 => Tab::Network,
5 => Tab::Partitions,
6 => Tab::Alerts,
_ => self.current_tab,
};
}
pub fn toggle_sort_ascending(&mut self) {
self.sort_ascending = !self.sort_ascending;
self.sort_processes();
}
pub fn next_sort_column(&mut self) {
self.sort_column = match self.sort_column {
SortColumn::Name => SortColumn::Cpu,
SortColumn::Cpu => SortColumn::Memory,
SortColumn::Memory => SortColumn::DiskIo,
SortColumn::DiskIo => SortColumn::User,
SortColumn::User => SortColumn::Name,
};
self.sort_processes();
}
pub fn toggle_filter(&mut self) {
self.show_only_misbehaving = !self.show_only_misbehaving;
}
pub fn get_tab_index(&self) -> usize {
match self.current_tab {
Tab::Dashboard => 0,
Tab::Processes => 1,
Tab::Services => 2,
Tab::Storage => 3,
Tab::Network => 4,
Tab::Partitions => 5,
Tab::Alerts => 6,
}
}
pub fn toggle_context_menu(&mut self) {
if !self.filtered_processes.is_empty() && self.selected_process < self.filtered_processes.len() {
self.show_context_menu = !self.show_context_menu;
if self.show_context_menu {
self.context_menu_pid = Some(self.filtered_processes[self.selected_process].info.pid);
} else {
self.context_menu_pid = None;
}
}
}
pub fn kill_process(&mut self) -> Result<()> {
if let Some(pid) = self.context_menu_pid {
use std::process::Command;
Command::new("kill")
.arg(pid.to_string())
.output()?;
self.show_context_menu = false;
self.context_menu_pid = None;
// Immediately refresh the process list
self.monitor.refresh();
self.processes = self.monitor.get_all_processes()?;
self.sort_processes();
self.filter_processes();
}
Ok(())
}
pub fn kill_process_tree(&mut self) -> Result<()> {
if let Some(pid) = self.context_menu_pid {
use std::process::Command;
// Kill process and all children
Command::new("kill")
.arg("-TERM")
.arg("--")
.arg(format!("-{}", pid))
.output()?;
self.show_context_menu = false;
self.context_menu_pid = None;
// Immediately refresh the process list
self.monitor.refresh();
self.processes = self.monitor.get_all_processes()?;
self.sort_processes();
self.filter_processes();
}
Ok(())
}
pub fn open_process_folder(&mut self) -> Result<()> {
if let Some(pid) = self.context_menu_pid {
if let Some(process) = self.processes.iter().find(|p| p.info.pid == pid) {
if let Some(exe_path) = &process.info.exe_path {
if let Some(parent) = exe_path.parent() {
use std::process::Command;
Command::new("xdg-open")
.arg(parent)
.spawn()?;
}
}
}
self.show_context_menu = false;
self.context_menu_pid = None;
}
Ok(())
}
pub fn restart_process(&mut self) -> Result<()> {
if let Some(pid) = self.context_menu_pid {
if let Some(process) = self.processes.iter().find(|p| p.info.pid == pid) {
// Get the command line and executable path
let exe_path = process.info.exe_path.clone();
let cmd_line = process.info.command_line.clone();
// Kill the process first
use std::process::Command;
Command::new("kill")
.arg(pid.to_string())
.output()?;
// Wait a bit for the process to terminate
std::thread::sleep(std::time::Duration::from_millis(100));
// Restart the process with the same command line
if let Some(exe) = exe_path {
let mut command = Command::new(exe);
if cmd_line.len() > 1 {
// Skip the first argument (the executable itself)
command.args(&cmd_line[1..]);
}
command.spawn()?;
}
}
self.show_context_menu = false;
self.context_menu_pid = None;
// Immediately refresh the process list
self.monitor.refresh();
self.processes = self.monitor.get_all_processes()?;
self.sort_processes();
self.filter_processes();
}
Ok(())
}
// Service navigation methods
pub fn next_service(&mut self) {
if !self.filtered_services.is_empty() {
self.selected_service = (self.selected_service + 1) % self.filtered_services.len();
}
}
pub fn previous_service(&mut self) {
if !self.filtered_services.is_empty() {
if self.selected_service == 0 {
self.selected_service = self.filtered_services.len() - 1;
} else {
self.selected_service -= 1;
}
}
}
pub fn toggle_service_menu(&mut self) {
if !self.filtered_services.is_empty() && self.selected_service < self.filtered_services.len() {
self.show_service_menu = !self.show_service_menu;
if self.show_service_menu {
self.context_menu_service = Some(self.filtered_services[self.selected_service].name.clone());
} else {
self.context_menu_service = None;
}
}
}
// Service management methods
pub fn start_service(&mut self) -> Result<()> {
if let Some(ref service_name) = self.context_menu_service {
self.service_manager.start_service(service_name)?;
self.show_service_menu = false;
self.context_menu_service = None;
// Refresh service list
if let Ok(services) = self.service_manager.list_services() {
self.services = services;
self.filtered_services = self.services.clone();
}
}
Ok(())
}
pub fn stop_service(&mut self) -> Result<()> {
if let Some(ref service_name) = self.context_menu_service {
self.service_manager.stop_service(service_name)?;
self.show_service_menu = false;
self.context_menu_service = None;
// Refresh service list
if let Ok(services) = self.service_manager.list_services() {
self.services = services;
self.filtered_services = self.services.clone();
}
}
Ok(())
}
pub fn restart_service(&mut self) -> Result<()> {
if let Some(ref service_name) = self.context_menu_service {
self.service_manager.restart_service(service_name)?;
self.show_service_menu = false;
self.context_menu_service = None;
// Refresh service list
if let Ok(services) = self.service_manager.list_services() {
self.services = services;
self.filtered_services = self.services.clone();
}
}
Ok(())
}
pub fn enable_service(&mut self) -> Result<()> {
if let Some(ref service_name) = self.context_menu_service {
self.service_manager.enable_service(service_name)?;
self.show_service_menu = false;
self.context_menu_service = None;
// Refresh service list
if let Ok(services) = self.service_manager.list_services() {
self.services = services;
self.filtered_services = self.services.clone();
}
}
Ok(())
}
pub fn disable_service(&mut self) -> Result<()> {
if let Some(ref service_name) = self.context_menu_service {
self.service_manager.disable_service(service_name)?;
self.show_service_menu = false;
self.context_menu_service = None;
// Refresh service list
if let Ok(services) = self.service_manager.list_services() {
self.services = services;
self.filtered_services = self.services.clone();
}
}
Ok(())
}
}

212
procmon-tui/src/main.rs Normal file
View File

@@ -0,0 +1,212 @@
mod app;
mod ui;
use anyhow::Result;
use app::App;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
Terminal,
};
use std::io;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<()> {
// Setup logging
tracing_subscriber::fmt::init();
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app
let mut app = App::new().await?;
// Run app
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error: {:?}", err);
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> Result<()> {
loop {
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
// Handle search mode separately
if app.search_mode {
match key.code {
KeyCode::Char(c) => app.add_search_char(c),
KeyCode::Backspace => app.remove_search_char(),
KeyCode::Esc => app.toggle_search_mode(),
KeyCode::Enter => app.toggle_search_mode(),
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(());
}
KeyCode::Char('/') => app.toggle_search_mode(),
KeyCode::Up => {
if app.current_tab == app::Tab::Partitions {
app.previous_partition();
} else if app.current_tab == app::Tab::Services {
app.previous_service();
} else {
app.previous_process();
}
}
KeyCode::Down => {
if app.current_tab == app::Tab::Partitions {
app.next_partition();
} else if app.current_tab == app::Tab::Services {
app.next_service();
} else {
app.next_process();
}
}
KeyCode::PageUp => app.scroll_up(10),
KeyCode::PageDown => app.scroll_down(10, 20),
KeyCode::Left if app.current_tab == app::Tab::Partitions => {
app.previous_disk();
}
KeyCode::Right if app.current_tab == app::Tab::Partitions => {
app.next_disk();
}
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
KeyCode::Char('1') => app.set_tab(0),
KeyCode::Char('2') => app.set_tab(1),
KeyCode::Char('3') => app.set_tab(2),
KeyCode::Char('4') => app.set_tab(3),
KeyCode::Char('5') => app.set_tab(4),
KeyCode::Char('6') => app.set_tab(5),
KeyCode::Char('7') => app.set_tab(6),
KeyCode::Char('a') => app.toggle_sort_ascending(),
KeyCode::Char('s') => app.next_sort_column(),
KeyCode::Char('f') => app.toggle_filter(),
KeyCode::Char('m') | KeyCode::Enter => {
if app.current_tab == app::Tab::Partitions {
app.toggle_partition_menu();
} else if app.current_tab == app::Tab::Services {
app.toggle_service_menu();
} else {
app.toggle_context_menu();
}
}
KeyCode::Char('r') if app.current_tab == app::Tab::Partitions => {
app.refresh_disks();
}
KeyCode::Char('d') if app.show_partition_menu => {
let _ = app.delete_selected_partition();
app.show_partition_menu = false;
}
KeyCode::Char('c') if app.show_partition_menu => {
let _ = app.check_selected_partition();
app.show_partition_menu = false;
}
KeyCode::Char('e') if app.show_partition_menu => {
let _ = app.format_selected_partition("ext4");
app.show_partition_menu = false;
}
KeyCode::Char('x') if app.show_partition_menu => {
let _ = app.format_selected_partition("xfs");
app.show_partition_menu = false;
}
KeyCode::Char('b') if app.show_partition_menu => {
let _ = app.format_selected_partition("btrfs");
app.show_partition_menu = false;
}
KeyCode::Char('n') if app.show_partition_menu => {
let _ = app.format_selected_partition("ntfs");
app.show_partition_menu = false;
}
KeyCode::Char('k') if app.show_context_menu => {
let _ = app.kill_process();
}
KeyCode::Char('t') if app.show_context_menu => {
let _ = app.kill_process_tree();
}
KeyCode::Char('o') if app.show_context_menu => {
let _ = app.open_process_folder();
}
KeyCode::Char('r') if app.show_context_menu => {
let _ = app.restart_process();
}
// Service menu actions
KeyCode::Char('s') if app.show_service_menu => {
let _ = app.start_service();
}
KeyCode::Char('p') if app.show_service_menu => {
let _ = app.stop_service();
}
KeyCode::Char('r') if app.show_service_menu => {
let _ = app.restart_service();
}
KeyCode::Char('e') if app.show_service_menu => {
let _ = app.enable_service();
}
KeyCode::Char('d') if app.show_service_menu => {
let _ = app.disable_service();
}
KeyCode::Esc => {
if app.show_context_menu {
app.show_context_menu = false;
app.context_menu_pid = None;
} else if app.show_service_menu {
app.show_service_menu = false;
app.context_menu_service = None;
} else if app.search_mode {
app.toggle_search_mode();
}
}
_ => {}
}
}
}
Event::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollDown => app.scroll_down(3, 20),
MouseEventKind::ScrollUp => app.scroll_up(3),
MouseEventKind::Down(_button) => {
// Handle mouse click
app.handle_mouse_click(mouse.column, mouse.row);
}
_ => {}
}
}
_ => {}
}
}
app.update().await?;
}
}

769
procmon-tui/src/ui.rs Normal file
View File

@@ -0,0 +1,769 @@
use crate::app::{App, SortColumn, Tab};
use procmon_core::detector::Severity;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Bar, BarChart, BarGroup, Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row,
Table, Tabs,
},
Frame,
};
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
draw_tabs(f, app, chunks[0]);
draw_main_content(f, app, chunks[1]);
draw_footer(f, app, chunks[2]);
}
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
let titles = vec![
"Dashboard (1)",
"Processes (2)",
"Services (3)",
"Storage (4)",
"Network (5)",
"Partitions (6)",
"Alerts (7)"
];
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title("Process Monitor with Partition Manager"))
.select(app.get_tab_index())
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn draw_main_content(f: &mut Frame, app: &mut App, area: Rect) {
match app.current_tab {
Tab::Dashboard => draw_dashboard(f, app, area),
Tab::Processes => draw_processes(f, app, area),
Tab::Services => draw_services(f, app, area),
Tab::Storage => draw_storage(f, app, area),
Tab::Network => draw_network(f, app, area),
Tab::Partitions => draw_partitions(f, app, area),
Tab::Alerts => draw_alerts(f, app, area),
}
}
fn draw_dashboard(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7),
Constraint::Length(10),
Constraint::Min(0),
])
.split(area);
draw_system_overview(f, app, chunks[0]);
draw_cpu_cores(f, app, chunks[1]);
draw_top_processes(f, app, chunks[2]);
}
fn draw_system_overview(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(area);
// CPU Usage
let cpu_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title("CPU Usage"))
.gauge_style(Style::default().fg(get_usage_color(app.system_metrics.cpu.total_usage)))
.percent(app.system_metrics.cpu.total_usage as u16)
.label(format!("{:.1}%", app.system_metrics.cpu.total_usage));
f.render_widget(cpu_gauge, chunks[0]);
// Memory Usage
let mem_percent = (app.system_metrics.memory.used as f64 / app.system_metrics.memory.total as f64 * 100.0) as u16;
let mem_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title("Memory"))
.gauge_style(Style::default().fg(get_usage_color(mem_percent as f32)))
.percent(mem_percent)
.label(format!(
"{:.1} / {:.1} GB",
app.system_metrics.memory.used as f64 / (1024.0 * 1024.0 * 1024.0),
app.system_metrics.memory.total as f64 / (1024.0 * 1024.0 * 1024.0)
));
f.render_widget(mem_gauge, chunks[1]);
// CPU Temperature
let temp_text = if let Some(temp) = app.system_metrics.cpu.temperature {
format!("{:.1}°C", temp)
} else {
"N/A".to_string()
};
let temp_color = app.system_metrics.cpu.temperature
.map(|t| {
if t > 80.0 {
Color::Red
} else if t > 60.0 {
Color::Yellow
} else {
Color::Green
}
})
.unwrap_or(Color::Gray);
let temp_para = Paragraph::new(temp_text)
.block(Block::default().borders(Borders::ALL).title("CPU Temp"))
.style(Style::default().fg(temp_color))
.alignment(Alignment::Center);
f.render_widget(temp_para, chunks[2]);
// GPU Info
let gpu_text = if let Some(gpu) = app.system_metrics.gpus.first() {
format!("{}\n{:.1}%", gpu.name, gpu.usage)
} else {
"No GPU\nDetected".to_string()
};
let gpu_para = Paragraph::new(gpu_text)
.block(Block::default().borders(Borders::ALL).title("GPU"))
.alignment(Alignment::Center);
f.render_widget(gpu_para, chunks[3]);
}
fn draw_cpu_cores(f: &mut Frame, app: &App, area: Rect) {
let core_data: Vec<(&str, u64)> = app.system_metrics.cpu.per_core_usage
.iter()
.enumerate()
.map(|(i, usage)| {
(Box::leak(format!("{}", i).into_boxed_str()) as &str, *usage as u64)
})
.collect();
let bars: Vec<Bar> = core_data
.iter()
.map(|(label, value)| {
Bar::default()
.value(*value)
.label(Line::from(*label))
.style(Style::default().fg(get_usage_color(*value as f32)))
})
.collect();
let chart = BarChart::default()
.block(Block::default().borders(Borders::ALL).title("CPU Cores"))
.data(BarGroup::default().bars(&bars))
.bar_width(3)
.bar_gap(1);
f.render_widget(chart, area);
}
fn draw_top_processes(f: &mut Frame, app: &App, area: Rect) {
let mut processes = app.processes.clone();
processes.sort_by(|a, b| b.stats.cpu_usage.partial_cmp(&a.stats.cpu_usage).unwrap());
processes.truncate(10);
let rows: Vec<Row> = processes
.iter()
.map(|p| {
Row::new(vec![
Cell::from(p.info.pid.to_string()),
Cell::from(p.info.name.clone()),
Cell::from(p.info.user.clone()),
Cell::from(format!("{:.1}%", p.stats.cpu_usage)),
Cell::from(format!("{:.1} MB", p.stats.memory_usage as f64 / (1024.0 * 1024.0))),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Min(20),
Constraint::Length(12),
Constraint::Length(10),
Constraint::Length(12),
],
)
.header(
Row::new(vec!["PID", "Name", "User", "CPU", "Memory"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.block(Block::default().borders(Borders::ALL).title("Top Processes by CPU"));
f.render_widget(table, area);
}
fn draw_processes(f: &mut Frame, app: &mut App, area: Rect) {
use ratatui::widgets::TableState;
// Split area for search bar if needed
let (main_area, search_area) = if app.search_mode {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
(chunks[0], Some(chunks[1]))
} else {
(area, None)
};
// Store the area for mouse click handling
app.set_process_list_area(main_area.x, main_area.y, main_area.width, main_area.height);
let sort_indicator = if app.sort_ascending { "" } else { "" };
let sort_column_name = match app.sort_column {
SortColumn::Name => "Name",
SortColumn::Cpu => "CPU",
SortColumn::Memory => "Memory",
SortColumn::DiskIo => "Disk I/O",
SortColumn::User => "User",
};
let filtered_procs = app.get_filtered_processes();
let rows: Vec<Row> = filtered_procs
.iter()
.enumerate()
.map(|(i, p)| {
Row::new(vec![
Cell::from(p.info.pid.to_string()),
Cell::from(p.info.name.clone()),
Cell::from(p.info.user.clone()),
Cell::from(format!("{:.1}%", p.stats.cpu_usage)),
Cell::from(format!("{:.1}", p.stats.memory_usage as f64 / (1024.0 * 1024.0))),
Cell::from(format!("{:.1}", (p.stats.disk_read_bytes + p.stats.disk_write_bytes) as f64 / (1024.0 * 1024.0))),
Cell::from(format!("{:?}", p.info.status)),
])
})
.collect();
let title = if app.search_mode {
format!("Processes ({}) - Search Mode Active", filtered_procs.len())
} else {
format!("Processes ({}) - Sort: {} {} - ↑↓: Select, Enter: Menu, /: Search",
filtered_procs.len(), sort_column_name, sort_indicator)
};
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Min(20),
Constraint::Length(12),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(10),
],
)
.header(
Row::new(vec!["PID", "Name", "User", "CPU %", "Mem (MB)", "Disk (MB)", "Status"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
)
.row_highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
)
.highlight_symbol(">> ");
// Create table state and set selected
let mut table_state = TableState::default();
table_state.select(Some(app.selected_process));
f.render_stateful_widget(table, main_area, &mut table_state);
// Draw search bar if in search mode
if let Some(search_area) = search_area {
let search_text = format!("Search: {}", app.search_query);
let search_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Search (ESC to exit)"));
f.render_widget(search_bar, search_area);
}
// Draw context menu if active
if app.show_context_menu {
draw_context_menu(f, app);
}
}
fn draw_context_menu(f: &mut Frame, app: &App) {
// Create a centered popup
let area = f.area();
let popup_width = 40;
let popup_height = 8;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
// Get selected process info from filtered processes
let filtered_procs = app.get_filtered_processes();
let process_info = if !filtered_procs.is_empty() && app.selected_process < filtered_procs.len() {
let p = &filtered_procs[app.selected_process];
format!("{} (PID: {})", p.info.name, p.info.pid)
} else {
"No process selected".to_string()
};
let menu_items = vec![
Line::from(Span::styled(process_info, Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::raw("k - Kill process")),
Line::from(Span::raw("t - Kill process tree")),
Line::from(Span::raw("o - Open process folder")),
Line::from(Span::raw("r - Restart process")),
Line::from(""),
Line::from(Span::styled("ESC - Close menu", Style::default().fg(Color::Gray))),
];
let paragraph = Paragraph::new(menu_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title("Process Actions")
.style(Style::default().bg(Color::Black))
)
.alignment(Alignment::Left);
f.render_widget(paragraph, popup_area);
}
fn draw_services(f: &mut Frame, app: &App, area: Rect) {
use ratatui::widgets::TableState;
use procmon_core::ServiceState;
let services = &app.filtered_services;
let rows: Vec<Row> = services
.iter()
.map(|s| {
let state_style = match s.state {
ServiceState::Running => Style::default().fg(Color::Green),
ServiceState::Stopped => Style::default().fg(Color::Gray),
ServiceState::Failed => Style::default().fg(Color::Red),
ServiceState::Unknown => Style::default().fg(Color::Yellow),
};
let state_str = format!("{:?}", s.state);
let enabled_str = if s.enabled { "enabled" } else { "disabled" };
let mem_str = if let Some(mem) = s.memory_usage {
format!("{:.1} MB", mem as f64 / (1024.0 * 1024.0))
} else {
"-".to_string()
};
let pid_str = if let Some(pid) = s.main_pid {
pid.to_string()
} else {
"-".to_string()
};
Row::new(vec![
Cell::from(s.name.clone()),
Cell::from(state_str).style(state_style),
Cell::from(s.sub_state.clone()),
Cell::from(enabled_str),
Cell::from(pid_str),
Cell::from(mem_str),
Cell::from(s.description.clone()),
])
})
.collect();
let title = format!("Services ({}) - ↑↓: Select, Enter: Menu", services.len());
let table = Table::new(
rows,
[
Constraint::Length(25),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(12),
Constraint::Min(30),
],
)
.header(
Row::new(vec!["Name", "State", "Sub State", "Enabled", "PID", "Memory", "Description"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
)
.row_highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
)
.highlight_symbol(">> ");
// Create table state and set selected
let mut table_state = TableState::default();
table_state.select(Some(app.selected_service));
f.render_stateful_widget(table, area, &mut table_state);
// Draw service menu if active
if app.show_service_menu {
draw_service_menu(f, app);
}
}
fn draw_service_menu(f: &mut Frame, app: &App) {
// Create a centered popup
let area = f.area();
let popup_width = 40;
let popup_height = 10;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
// Get selected service info
let service_info = if !app.filtered_services.is_empty() && app.selected_service < app.filtered_services.len() {
let s = &app.filtered_services[app.selected_service];
format!("{} ({:?})", s.name, s.state)
} else {
"No service selected".to_string()
};
let menu_items = vec![
Line::from(Span::styled(service_info, Style::default().add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::raw("s - Start service")),
Line::from(Span::raw("p - Stop service")),
Line::from(Span::raw("r - Restart service")),
Line::from(Span::raw("e - Enable service")),
Line::from(Span::raw("d - Disable service")),
Line::from(""),
Line::from(Span::styled("ESC - Close menu", Style::default().fg(Color::Gray))),
];
let paragraph = Paragraph::new(menu_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title("Service Actions")
.style(Style::default().bg(Color::Black))
)
.alignment(Alignment::Left);
f.render_widget(paragraph, popup_area);
}
fn draw_storage(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Disk I/O summary
let disk_items: Vec<ListItem> = app
.system_metrics
.disk_io
.iter()
.map(|(name, metrics)| {
let content = format!(
"{}: Read: {:.2} MB ({} ops) Write: {:.2} MB ({} ops)",
name,
metrics.read_bytes as f64 / (1024.0 * 1024.0),
metrics.read_ops,
metrics.write_bytes as f64 / (1024.0 * 1024.0),
metrics.write_ops
);
ListItem::new(content)
})
.collect();
let disk_list = List::new(disk_items)
.block(Block::default().borders(Borders::ALL).title("Disk I/O"));
f.render_widget(disk_list, chunks[0]);
// Top processes by disk I/O
let mut processes = app.processes.clone();
processes.sort_by(|a, b| {
let a_io = a.stats.disk_read_bytes + a.stats.disk_write_bytes;
let b_io = b.stats.disk_read_bytes + b.stats.disk_write_bytes;
b_io.cmp(&a_io)
});
processes.truncate(20);
let rows: Vec<Row> = processes
.iter()
.map(|p| {
Row::new(vec![
Cell::from(p.info.pid.to_string()),
Cell::from(p.info.name.clone()),
Cell::from(format!("{:.2}", p.stats.disk_read_bytes as f64 / (1024.0 * 1024.0))),
Cell::from(format!("{:.2}", p.stats.disk_write_bytes as f64 / (1024.0 * 1024.0))),
Cell::from(format!("{:.2}", (p.stats.disk_read_bytes + p.stats.disk_write_bytes) as f64 / (1024.0 * 1024.0))),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Min(20),
Constraint::Length(15),
Constraint::Length(15),
Constraint::Length(15),
],
)
.header(
Row::new(vec!["PID", "Name", "Read (MB)", "Write (MB)", "Total (MB)"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.block(Block::default().borders(Borders::ALL).title("Processes by Disk I/O"));
f.render_widget(table, chunks[1]);
}
fn draw_network(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Network interfaces
let net_items: Vec<ListItem> = app
.system_metrics
.network
.iter()
.map(|(name, metrics)| {
let content = format!(
"{}: ↓ {:.2} MB ↑ {:.2} MB (Packets: ↓ {}{})",
name,
metrics.bytes_received as f64 / (1024.0 * 1024.0),
metrics.bytes_sent as f64 / (1024.0 * 1024.0),
metrics.packets_received,
metrics.packets_sent
);
ListItem::new(content)
})
.collect();
let net_list = List::new(net_items)
.block(Block::default().borders(Borders::ALL).title("Network Interfaces"));
f.render_widget(net_list, chunks[0]);
// Top processes by network (placeholder - we don't have per-process network stats yet)
let text = Paragraph::new("Per-process network statistics not yet available.\nThis will show processes sorted by network usage.")
.block(Block::default().borders(Borders::ALL).title("Processes by Network Usage"))
.alignment(Alignment::Center);
f.render_widget(text, chunks[1]);
}
fn draw_alerts(f: &mut Frame, app: &App, area: Rect) {
let alert_items: Vec<ListItem> = app
.alerts
.iter()
.rev()
.take(50)
.map(|alert| {
let severity_color = match alert.severity {
Severity::Critical => Color::Red,
Severity::Warning => Color::Yellow,
Severity::Info => Color::Blue,
};
let content = vec![
Line::from(vec![
Span::styled(
format!("[{:?}] ", alert.severity),
Style::default().fg(severity_color).add_modifier(Modifier::BOLD),
),
Span::raw(format!(
"{} - {} (PID: {})",
alert.timestamp.format("%H:%M:%S"),
alert.process_name,
alert.pid
)),
]),
Line::from(vec![
Span::raw(" "),
Span::raw(&alert.rule_name),
Span::raw(": "),
Span::raw(&alert.details),
]),
];
ListItem::new(content)
})
.collect();
let alert_list = List::new(alert_items).block(
Block::default()
.borders(Borders::ALL)
.title(format!("Alerts ({} total)", app.alerts.len())),
);
f.render_widget(alert_list, area);
}
fn draw_partitions(f: &mut Frame, app: &App, area: Rect) {
if app.disks.is_empty() {
let text = Paragraph::new("No disks found or permission denied.\nRun with sudo for full partition management capabilities.")
.block(Block::default().borders(Borders::ALL).title("Partition Manager"))
.alignment(Alignment::Center);
f.render_widget(text, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Disk list
let disk_items: Vec<ListItem> = app
.disks
.iter()
.map(|disk| {
let size_gb = disk.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let content = format!(
"{} - {} ({:.2} GB) - {} partitions",
disk.device,
disk.model,
size_gb,
disk.partitions.len()
);
ListItem::new(content)
})
.collect();
let disk_list = List::new(disk_items)
.block(Block::default().borders(Borders::ALL).title("Disks (Select with ↑↓)"));
f.render_widget(disk_list, chunks[0]);
// Partition table for selected disk
if app.selected_disk < app.disks.len() {
let disk = &app.disks[app.selected_disk];
if disk.partitions.is_empty() {
let text = Paragraph::new(format!("No partitions on {}\n\nUse gparted or parted to create partitions.", disk.device))
.block(Block::default().borders(Borders::ALL).title(format!("Partitions on {}", disk.device)))
.alignment(Alignment::Center);
f.render_widget(text, chunks[1]);
} else {
let rows: Vec<Row> = disk
.partitions
.iter()
.map(|p| {
let size_gb = p.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let used_gb = p.used_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let used_percent = if p.size_bytes > 0 {
(p.used_bytes as f64 / p.size_bytes as f64 * 100.0)
} else {
0.0
};
Row::new(vec![
Cell::from(p.device.clone()),
Cell::from(p.filesystem.clone().unwrap_or_else(|| "unknown".to_string())),
Cell::from(p.label.clone().unwrap_or_else(|| "-".to_string())),
Cell::from(format!("{:.2}", size_gb)),
Cell::from(format!("{:.2} ({:.1}%)", used_gb, used_percent)),
Cell::from(p.mount_point.clone().unwrap_or_else(|| "-".to_string())),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(15),
Constraint::Length(10),
Constraint::Length(15),
Constraint::Length(12),
Constraint::Length(18),
Constraint::Min(20),
],
)
.header(
Row::new(vec!["Device", "Filesystem", "Label", "Size (GB)", "Used (GB)", "Mount Point"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1),
)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Partitions on {} - {} ({:.2} GB)",
disk.device,
disk.model,
disk.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
))
);
f.render_widget(table, chunks[1]);
}
}
}
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
let text = if app.search_mode {
"Search Mode: Type to search, Backspace to delete, Enter/ESC to exit"
} else {
"q: Quit | Tab: Next Tab | 1-7: Switch Tabs | ↑↓: Navigate | /: Search | s: Sort | a: Order | m: Menu | PgUp/PgDn: Scroll | Mouse Wheel: Scroll"
};
let footer = Paragraph::new(text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(footer, area);
}
fn get_usage_color(usage: f32) -> Color {
if usage > 80.0 {
Color::Red
} else if usage > 60.0 {
Color::Yellow
} else {
Color::Green
}
}