Files
setec_proc/procmon-tui/src/ui.rs
2026-03-13 13:59:56 -07:00

770 lines
26 KiB
Rust

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
}
}