commit 2a4867296e6476012aca8c423d3d5c9e63b33a6f Author: DigiJ Date: Fri Mar 13 13:59:56 2026 -0700 first commit diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f775815 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,50 @@ +[workspace] +members = [ + "procmon-core", + "procmon-tui", + "procmon-gui", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +# Core system monitoring +sysinfo = "0.32" +procfs = "0.17" +libc = "0.2" + +# Async runtime +tokio = { version = "1", features = ["full"] } +async-trait = "0.1" + +# Error handling +anyhow = "1.0" +thiserror = "1.0" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" + +# TUI +ratatui = "0.29" +crossterm = "0.28" + +# GUI +eframe = "0.29" +egui = "0.29" +egui_plot = "0.29" + +# Threading +parking_lot = "0.12" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..deb8349 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Digi J + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..adbdc7c --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,188 @@ +# Quick Start Guide + +## Installation + +### Install Rust (if not already installed) +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env +``` + +### Clone and Build +```bash +cd /home/snake/RustroverProjects/untitled + +# Build everything +cargo build --release + +# Or build individual components +cargo build --release -p procmon-tui # Terminal UI +cargo build --release -p procmon-gui # Graphical UI +``` + +## Running the Applications + +### Terminal UI (Recommended for servers/SSH) +```bash +cargo run --release -p procmon-tui +``` + +Or run the compiled binary: +```bash +./target/release/procmon-tui +``` + +### Graphical UI (Recommended for desktop) +```bash +cargo run --release -p procmon-gui +``` + +Or run the compiled binary: +```bash +./target/release/procmon-gui +``` + +## First Time Setup + +For best results, ensure the following packages are installed on your Linux system: + +### Ubuntu/Debian +```bash +sudo apt-get install lm-sensors build-essential +sudo sensors-detect --auto # Detect and configure thermal sensors +``` + +### Arch Linux +```bash +sudo pacman -S lm_sensors base-devel +sudo sensors-detect --auto +``` + +### Fedora/RHEL +```bash +sudo dnf install lm_sensors gcc +sudo sensors-detect --auto +``` + +## TUI Quick Reference + +Once the TUI is running: + +1. **Navigate Tabs** + - Press `1` for Dashboard (system overview) + - Press `2` for Processes (detailed process list) + - Press `3` for Network (network and disk I/O) + - Press `4` for Alerts (misbehavior alerts) + +2. **Process List Controls** + - `↑/↓` arrows to navigate + - `s` to cycle through sort columns + - `a` to toggle ascending/descending + - `f` to filter by misbehaving processes + +3. **Exit** + - Press `q` or `Ctrl+C` to quit + +## GUI Quick Reference + +The GUI uses tabs at the top: + +1. **Dashboard**: Overview with graphs and gauges +2. **Processes**: Click column headers to sort +3. **Network & I/O**: Real-time network and disk statistics +4. **Alerts**: Color-coded alerts (Red=Critical, Yellow=Warning, Blue=Info) + +## Understanding the Alerts + +The application automatically detects misbehaving processes: + +- **Critical (Red)**: Immediate attention needed + - CPU usage > 95% + - Memory usage > 8GB + +- **Warning (Yellow)**: Potential issues + - CPU > 80% for more than 60 seconds + - Memory > 2GB for more than 30 seconds + - High disk I/O (>100 MB/s for 60 seconds) + +- **Info (Blue)**: Informational alerts + - Zombie processes + - Other noteworthy events + +## Monitoring Specific Metrics + +### CPU Monitoring +- **Dashboard Tab**: Shows overall CPU usage, per-core breakdown, and temperature +- **Temperature**: Reads from `/sys/class/thermal/` or `/sys/class/hwmon/` + +### GPU Monitoring +- Currently supports AMD GPUs via sysfs (`/sys/class/drm/`) +- Shows GPU usage, VRAM, and temperature +- For NVIDIA GPUs, you may need to install additional drivers + +### Network Monitoring +- **Network Tab**: Shows all active network interfaces +- Statistics are cumulative since boot +- Includes packet counts and error rates + +### Disk I/O +- **Network Tab**: Shows per-device disk statistics +- Read/write operations and bytes transferred +- Filters out loop and ram devices for clarity + +### Process Details +- **Processes Tab**: Complete process information +- Click or select a process for details +- Sortable by CPU, Memory, Disk I/O, Name, or User + +## Performance Tips + +1. **Update Interval**: Default is 1 second. This balances responsiveness with CPU usage. + +2. **Running as Root**: Some metrics require root access: + ```bash + sudo ./target/release/procmon-tui + ``` + +3. **Filtering**: Use the filter feature (`f` in TUI) to focus on problematic processes + +4. **Sorting**: Sort by CPU or Memory to quickly identify resource hogs + +## Troubleshooting + +### No GPU Detected +- Check if `/sys/class/drm/` exists: `ls /sys/class/drm/` +- Ensure GPU drivers are properly installed +- NVIDIA users may need additional work (see README) + +### No Temperature Readings +- Run `sensors` command to check if thermal sensors are detected +- Run `sudo sensors-detect` to configure +- Check permissions on `/sys/class/thermal/` + +### Permission Denied Errors +- Some metrics require root access +- Try running with `sudo` +- Check that you can read `/proc/` entries + +### High CPU Usage from Monitor +- This is unusual; the monitor should use <1% CPU +- Try increasing the update interval in the code +- Check for runaway processes being monitored + +## Next Steps + +- Explore the codebase in `procmon-core/` to customize detection rules +- Modify alert thresholds in `procmon-core/src/detector.rs` +- Add custom monitoring logic to suit your needs +- Contribute improvements back to the project + +## Getting Help + +If you encounter issues: +1. Check the main README.md for detailed information +2. Review system logs for errors +3. Ensure all dependencies are installed +4. Verify Rust toolchain is up to date: `rustup update` + +Enjoy monitoring your system! diff --git a/README.md b/README.md new file mode 100644 index 0000000..64f3c01 --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ +Note: The TUI is more complete and functional than the GUI + +# Process Monitor + +A comprehensive system and process monitoring application written in Rust with both Terminal UI (TUI) and Graphical UI (GUI) interfaces. + +## Features + +### System Monitoring +- **CPU Monitoring** + - Overall CPU usage percentage + - Per-core CPU usage with visualization + - CPU temperature tracking + - CPU frequency monitoring + +- **Memory Monitoring** + - Total, used, and available memory + - Swap memory usage + - Per-process memory consumption + +- **GPU Monitoring** + - GPU usage percentage + - VRAM usage + - GPU temperature + - Support for AMD GPUs (via sysfs) + +- **Network Monitoring** + - Per-interface network statistics + - Bytes sent/received + - Packets sent/received + - Error tracking + +- **Disk I/O Monitoring** + - Read/write operations per device + - Bytes read/written + - Per-process disk I/O tracking + +- **USB Monitoring** + - Connected USB device detection + - Device identification (vendor/product IDs) + +### Process Monitoring +- Real-time process listing +- Process owner (user) tracking +- Per-process statistics: + - CPU usage + - Memory usage (RSS and virtual) + - Disk I/O (read/write bytes) + - Thread count + - Process status + - Runtime duration + +### Misbehavior Detection +Automatic detection of misbehaving applications based on configurable rules: + +- **High CPU Usage**: Alerts when processes exceed CPU thresholds +- **Memory Leaks**: Detects processes with excessive memory consumption +- **Excessive Disk I/O**: Identifies processes with high disk activity +- **Zombie Processes**: Flags processes in zombie state +- **Network I/O**: Monitors excessive network usage + +Default alert levels: +- **Critical**: Immediate attention required (>95% CPU, >8GB RAM) +- **Warning**: Potential issues (>80% CPU for 60s, >2GB RAM for 30s) +- **Info**: Informational alerts + +## Architecture + +The project is organized as a Cargo workspace with three crates: + +``` +procmon/ +├── procmon-core/ # Core monitoring library +│ ├── monitor.rs # System monitoring implementation +│ ├── metrics.rs # Metric data structures +│ ├── process.rs # Process information types +│ └── detector.rs # Misbehavior detection logic +├── procmon-tui/ # Terminal UI application +│ ├── main.rs # TUI entry point +│ ├── app.rs # Application state +│ └── ui.rs # Rendering logic +└── procmon-gui/ # Graphical UI application + └── main.rs # GUI application with egui +``` + +## Building + +### Prerequisites +- Rust 1.70 or later +- Linux (designed for Linux systems) + +### Build Commands + +Build all components: +```bash +cargo build --release +``` + +Build individual components: +```bash +cargo build --release -p procmon-tui +cargo build --release -p procmon-gui +``` + +## Running + +### Terminal UI (TUI) +```bash +cargo run --release -p procmon-tui +``` + +### Graphical UI (GUI) +```bash +cargo run --release -p procmon-gui +``` + +## TUI Controls + +- **q** or **Ctrl+C**: Quit application +- **Tab**: Next tab +- **Shift+Tab**: Previous tab +- **1-4**: Jump to specific tab (Dashboard, Processes, Network, Alerts) +- **↑/↓**: Navigate process list +- **s**: Change sort column +- **a**: Toggle sort order (ascending/descending) +- **f**: Toggle filter for misbehaving processes + +## TUI Tabs + +1. **Dashboard**: System overview with CPU, memory, temperature, and top processes +2. **Processes**: Detailed process list with sorting and filtering +3. **Network**: Network interfaces and disk I/O statistics +4. **Alerts**: Real-time misbehavior alerts + +## GUI Features + +The GUI provides an alternative interface with the same monitoring capabilities: + +- **Dashboard Tab**: Visual system overview with graphs and gauges +- **Processes Tab**: Sortable process table +- **Network & I/O Tab**: Network interfaces and disk statistics +- **Alerts Tab**: Color-coded alert list + +## Dependencies + +### Core Monitoring +- `sysinfo`: Cross-platform system information +- `procfs`: Linux /proc filesystem parsing +- `nix`: Unix system APIs + +### TUI +- `ratatui`: Terminal UI framework +- `crossterm`: Terminal manipulation + +### GUI +- `egui`: Immediate mode GUI framework +- `eframe`: egui application framework + +### Common +- `tokio`: Async runtime +- `serde`: Serialization +- `chrono`: Date/time handling +- `tracing`: Logging + +## Platform Support + +Currently optimized for **Linux**. The application reads from: +- `/proc/` filesystem for process information +- `/sys/class/thermal/` for CPU temperature +- `/sys/class/drm/` for GPU information +- `/sys/bus/usb/devices/` for USB devices +- `/proc/diskstats` for disk I/O +- `/etc/passwd` for user information + +## Customizing Misbehavior Rules + +The misbehavior detector can be customized by modifying `procmon-core/src/detector.rs`. Default rules include: + +```rust +MisbehaviorRule { + name: "High CPU Usage", + description: "Process using more than 80% CPU for extended period", + condition: MisbehaviorCondition::CpuUsageAbove { + threshold: 80.0, + duration_secs: 60, + }, + severity: Severity::Warning, +} +``` + +## Performance + +- Updates every 1 second by default +- Minimal CPU overhead (typically <1% on modern systems) +- Efficient memory usage with bounded alert history (last 100 alerts) + +## Permissions + +Some features may require elevated permissions: +- Reading certain `/proc` entries may require root access +- Hardware sensor data may need appropriate permissions + +Run with sudo if you encounter permission errors: +```bash +sudo cargo run --release -p procmon-tui +``` + +## License + +MIT OR Apache-2.0 + +## Contributing + +Contributions are welcome! Areas for improvement: +- NVIDIA GPU support (via NVML) +- macOS and Windows support +- Per-process network I/O tracking +- Historical data graphing +- Configuration file support +- Export monitoring data to formats (CSV, JSON) + +## Troubleshooting + +### No GPU detected +- Ensure GPU drivers are installed +- Check `/sys/class/drm/` exists and is accessible +- AMD GPUs are currently better supported than NVIDIA + +### No temperature readings +- Install `lm-sensors` package +- Run `sensors-detect` to configure thermal sensors +- Check `/sys/class/thermal/` permissions + +### Inaccurate network stats +- Network statistics are cumulative since boot +- Ensure proper permissions to read network interface data + +## Technical Notes + +### CPU Usage Calculation +CPU usage is calculated by the `sysinfo` crate using process CPU time divided by elapsed time. + +### Memory Values +- RSS (Resident Set Size): Physical memory used +- Virtual Memory: Total virtual address space + +### Disk I/O +Disk I/O statistics are read from `/proc/diskstats` and represent cumulative values since boot. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..46257e6 --- /dev/null +++ b/build.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +set -e + +echo "================================" +echo " Process Monitor Build Script " +echo "================================" +echo + +# Check if cargo is installed +if ! command -v cargo &> /dev/null; then + echo "Error: Cargo is not installed." + echo "Please install Rust from https://rustup.rs/" + echo + echo "Run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + exit 1 +fi + +echo "Rust version:" +rustc --version +cargo --version +echo + +# Build all workspace members +echo "Building all workspace members..." +echo + +echo "[1/3] Building procmon-core..." +cargo build --release -p procmon-core + +echo +echo "[2/3] Building procmon-tui..." +cargo build --release -p procmon-tui + +echo +echo "[3/3] Building procmon-gui..." +cargo build --release -p procmon-gui + +echo +echo "================================" +echo " Build Completed Successfully! " +echo "================================" +echo +echo "Binaries are located in ./target/release/" +echo +echo "To run the Terminal UI:" +echo " ./target/release/procmon-tui" +echo +echo "To run the Graphical UI:" +echo " ./target/release/procmon-gui" +echo +echo "For more information, see README.md and QUICKSTART.md" diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/procmon-core/Cargo.toml b/procmon-core/Cargo.toml new file mode 100644 index 0000000..aa69f3e --- /dev/null +++ b/procmon-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "procmon-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +sysinfo.workspace = true +procfs.workspace = true +libc.workspace = true +tokio.workspace = true +async-trait.workspace = true +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +tracing.workspace = true +parking_lot.workspace = true + +# Additional dependencies for system monitoring +nix = { version = "0.29", features = ["process", "user"] } diff --git a/procmon-core/src/detector.rs b/procmon-core/src/detector.rs new file mode 100644 index 0000000..798cf29 --- /dev/null +++ b/procmon-core/src/detector.rs @@ -0,0 +1,299 @@ +use crate::process::ProcessSnapshot; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MisbehaviorRule { + pub name: String, + pub description: String, + pub condition: MisbehaviorCondition, + pub severity: Severity, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MisbehaviorCondition { + CpuUsageAbove { threshold: f32, duration_secs: u64 }, + MemoryUsageAbove { threshold_bytes: u64, duration_secs: u64 }, + MemoryPercentAbove { threshold_percent: f32, duration_secs: u64 }, + DiskIoAbove { threshold_bytes_per_sec: u64, duration_secs: u64 }, + NetworkIoAbove { threshold_bytes_per_sec: u64, duration_secs: u64 }, + TooManyThreads { threshold: u32 }, + ZombieProcess, + HighDiskWrites { threshold_bytes_per_sec: u64, duration_secs: u64 }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Info, + Warning, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MisbehaviorAlert { + pub pid: u32, + pub process_name: String, + pub rule_name: String, + pub description: String, + pub severity: Severity, + pub timestamp: chrono::DateTime, + pub details: String, +} + +pub struct MisbehaviorDetector { + rules: Vec, + violation_history: HashMap>, +} + +#[derive(Debug, Clone)] +struct ViolationRecord { + rule_name: String, + timestamp: chrono::DateTime, +} + +impl MisbehaviorDetector { + pub fn new() -> Self { + Self { + rules: Self::default_rules(), + violation_history: HashMap::new(), + } + } + + pub fn with_rules(rules: Vec) -> Self { + Self { + rules, + violation_history: HashMap::new(), + } + } + + fn default_rules() -> Vec { + vec![ + MisbehaviorRule { + name: "High CPU Usage".to_string(), + description: "Process using more than 80% CPU for extended period".to_string(), + condition: MisbehaviorCondition::CpuUsageAbove { + threshold: 80.0, + duration_secs: 60, + }, + severity: Severity::Warning, + }, + MisbehaviorRule { + name: "Extreme CPU Usage".to_string(), + description: "Process using more than 95% CPU".to_string(), + condition: MisbehaviorCondition::CpuUsageAbove { + threshold: 95.0, + duration_secs: 10, + }, + severity: Severity::Critical, + }, + MisbehaviorRule { + name: "High Memory Usage".to_string(), + description: "Process using more than 2GB of RAM".to_string(), + condition: MisbehaviorCondition::MemoryUsageAbove { + threshold_bytes: 2 * 1024 * 1024 * 1024, + duration_secs: 30, + }, + severity: Severity::Warning, + }, + MisbehaviorRule { + name: "Memory Leak Suspected".to_string(), + description: "Process using more than 8GB of RAM".to_string(), + condition: MisbehaviorCondition::MemoryUsageAbove { + threshold_bytes: 8 * 1024 * 1024 * 1024, + duration_secs: 10, + }, + severity: Severity::Critical, + }, + MisbehaviorRule { + name: "Zombie Process".to_string(), + description: "Process is in zombie state".to_string(), + condition: MisbehaviorCondition::ZombieProcess, + severity: Severity::Warning, + }, + MisbehaviorRule { + name: "High Disk I/O".to_string(), + description: "Process performing excessive disk operations".to_string(), + condition: MisbehaviorCondition::DiskIoAbove { + threshold_bytes_per_sec: 100 * 1024 * 1024, // 100 MB/s + duration_secs: 60, + }, + severity: Severity::Warning, + }, + ] + } + + pub fn add_rule(&mut self, rule: MisbehaviorRule) { + self.rules.push(rule); + } + + pub fn check_process(&mut self, snapshot: &ProcessSnapshot) -> Vec { + let mut alerts = Vec::new(); + let rules = self.rules.clone(); + + for rule in &rules { + if self.check_rule(snapshot, rule) { + let alert = MisbehaviorAlert { + pid: snapshot.info.pid, + process_name: snapshot.info.name.clone(), + rule_name: rule.name.clone(), + description: rule.description.clone(), + severity: rule.severity, + timestamp: chrono::Utc::now(), + details: self.get_violation_details(snapshot, &rule.condition), + }; + + alerts.push(alert); + } + } + + alerts + } + + fn check_rule(&mut self, snapshot: &ProcessSnapshot, rule: &MisbehaviorRule) -> bool { + match &rule.condition { + MisbehaviorCondition::CpuUsageAbove { threshold, duration_secs } => { + if snapshot.stats.cpu_usage > *threshold { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::MemoryUsageAbove { threshold_bytes, duration_secs } => { + if snapshot.stats.memory_usage > *threshold_bytes { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::MemoryPercentAbove { threshold_percent, duration_secs } => { + if snapshot.stats.memory_percent > *threshold_percent { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::DiskIoAbove { threshold_bytes_per_sec, duration_secs } => { + let total_io = snapshot.stats.disk_read_bytes + snapshot.stats.disk_write_bytes; + let io_per_sec = total_io / snapshot.stats.run_time.as_secs().max(1); + + if io_per_sec > *threshold_bytes_per_sec { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::NetworkIoAbove { threshold_bytes_per_sec, duration_secs } => { + let total_net = snapshot.stats.network_rx_bytes + snapshot.stats.network_tx_bytes; + let net_per_sec = total_net / snapshot.stats.run_time.as_secs().max(1); + + if net_per_sec > *threshold_bytes_per_sec { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::TooManyThreads { threshold } => { + snapshot.stats.num_threads > *threshold + } + MisbehaviorCondition::ZombieProcess => { + matches!(snapshot.info.status, crate::process::ProcessStatus::Zombie) + } + MisbehaviorCondition::HighDiskWrites { threshold_bytes_per_sec, duration_secs } => { + let write_per_sec = snapshot.stats.disk_write_bytes / snapshot.stats.run_time.as_secs().max(1); + + if write_per_sec > *threshold_bytes_per_sec { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + } + } + + fn record_violation(&mut self, pid: u32, rule_name: &str, duration_secs: u64) -> bool { + let now = chrono::Utc::now(); + let history = self.violation_history.entry(pid).or_insert_with(Vec::new); + + // Add new violation + history.push(ViolationRecord { + rule_name: rule_name.to_string(), + timestamp: now, + }); + + // Clean up old violations + let cutoff = now - chrono::Duration::seconds(duration_secs as i64); + history.retain(|v| v.timestamp > cutoff && v.rule_name == rule_name); + + // Check if violation has persisted for the required duration + if let Some(first) = history.first() { + let violation_duration = (now - first.timestamp).num_seconds() as u64; + violation_duration >= duration_secs + } else { + false + } + } + + fn get_violation_details(&self, snapshot: &ProcessSnapshot, condition: &MisbehaviorCondition) -> String { + match condition { + MisbehaviorCondition::CpuUsageAbove { threshold, .. } => { + format!("CPU usage: {:.1}% (threshold: {:.1}%)", snapshot.stats.cpu_usage, threshold) + } + MisbehaviorCondition::MemoryUsageAbove { threshold_bytes, .. } => { + format!( + "Memory usage: {:.2} GB (threshold: {:.2} GB)", + snapshot.stats.memory_usage as f64 / (1024.0 * 1024.0 * 1024.0), + *threshold_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ) + } + MisbehaviorCondition::MemoryPercentAbove { threshold_percent, .. } => { + format!("Memory usage: {:.1}% (threshold: {:.1}%)", snapshot.stats.memory_percent, threshold_percent) + } + MisbehaviorCondition::DiskIoAbove { threshold_bytes_per_sec, .. } => { + let total_io = snapshot.stats.disk_read_bytes + snapshot.stats.disk_write_bytes; + let io_per_sec = total_io / snapshot.stats.run_time.as_secs().max(1); + format!( + "Disk I/O: {:.2} MB/s (threshold: {:.2} MB/s)", + io_per_sec as f64 / (1024.0 * 1024.0), + *threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) + ) + } + MisbehaviorCondition::NetworkIoAbove { threshold_bytes_per_sec, .. } => { + let total_net = snapshot.stats.network_rx_bytes + snapshot.stats.network_tx_bytes; + let net_per_sec = total_net / snapshot.stats.run_time.as_secs().max(1); + format!( + "Network I/O: {:.2} MB/s (threshold: {:.2} MB/s)", + net_per_sec as f64 / (1024.0 * 1024.0), + *threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) + ) + } + MisbehaviorCondition::TooManyThreads { threshold } => { + format!("Threads: {} (threshold: {})", snapshot.stats.num_threads, threshold) + } + MisbehaviorCondition::ZombieProcess => { + "Process is in zombie state".to_string() + } + MisbehaviorCondition::HighDiskWrites { threshold_bytes_per_sec, .. } => { + let write_per_sec = snapshot.stats.disk_write_bytes / snapshot.stats.run_time.as_secs().max(1); + format!( + "Disk writes: {:.2} MB/s (threshold: {:.2} MB/s)", + write_per_sec as f64 / (1024.0 * 1024.0), + *threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) + ) + } + } + } + + pub fn cleanup_dead_processes(&mut self, active_pids: &[u32]) { + self.violation_history.retain(|pid, _| active_pids.contains(pid)); + } + + pub fn get_rules(&self) -> &[MisbehaviorRule] { + &self.rules + } +} + +impl Default for MisbehaviorDetector { + fn default() -> Self { + Self::new() + } +} diff --git a/procmon-core/src/detectors.rs b/procmon-core/src/detectors.rs new file mode 100644 index 0000000..798cf29 --- /dev/null +++ b/procmon-core/src/detectors.rs @@ -0,0 +1,299 @@ +use crate::process::ProcessSnapshot; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MisbehaviorRule { + pub name: String, + pub description: String, + pub condition: MisbehaviorCondition, + pub severity: Severity, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MisbehaviorCondition { + CpuUsageAbove { threshold: f32, duration_secs: u64 }, + MemoryUsageAbove { threshold_bytes: u64, duration_secs: u64 }, + MemoryPercentAbove { threshold_percent: f32, duration_secs: u64 }, + DiskIoAbove { threshold_bytes_per_sec: u64, duration_secs: u64 }, + NetworkIoAbove { threshold_bytes_per_sec: u64, duration_secs: u64 }, + TooManyThreads { threshold: u32 }, + ZombieProcess, + HighDiskWrites { threshold_bytes_per_sec: u64, duration_secs: u64 }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Info, + Warning, + Critical, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MisbehaviorAlert { + pub pid: u32, + pub process_name: String, + pub rule_name: String, + pub description: String, + pub severity: Severity, + pub timestamp: chrono::DateTime, + pub details: String, +} + +pub struct MisbehaviorDetector { + rules: Vec, + violation_history: HashMap>, +} + +#[derive(Debug, Clone)] +struct ViolationRecord { + rule_name: String, + timestamp: chrono::DateTime, +} + +impl MisbehaviorDetector { + pub fn new() -> Self { + Self { + rules: Self::default_rules(), + violation_history: HashMap::new(), + } + } + + pub fn with_rules(rules: Vec) -> Self { + Self { + rules, + violation_history: HashMap::new(), + } + } + + fn default_rules() -> Vec { + vec![ + MisbehaviorRule { + name: "High CPU Usage".to_string(), + description: "Process using more than 80% CPU for extended period".to_string(), + condition: MisbehaviorCondition::CpuUsageAbove { + threshold: 80.0, + duration_secs: 60, + }, + severity: Severity::Warning, + }, + MisbehaviorRule { + name: "Extreme CPU Usage".to_string(), + description: "Process using more than 95% CPU".to_string(), + condition: MisbehaviorCondition::CpuUsageAbove { + threshold: 95.0, + duration_secs: 10, + }, + severity: Severity::Critical, + }, + MisbehaviorRule { + name: "High Memory Usage".to_string(), + description: "Process using more than 2GB of RAM".to_string(), + condition: MisbehaviorCondition::MemoryUsageAbove { + threshold_bytes: 2 * 1024 * 1024 * 1024, + duration_secs: 30, + }, + severity: Severity::Warning, + }, + MisbehaviorRule { + name: "Memory Leak Suspected".to_string(), + description: "Process using more than 8GB of RAM".to_string(), + condition: MisbehaviorCondition::MemoryUsageAbove { + threshold_bytes: 8 * 1024 * 1024 * 1024, + duration_secs: 10, + }, + severity: Severity::Critical, + }, + MisbehaviorRule { + name: "Zombie Process".to_string(), + description: "Process is in zombie state".to_string(), + condition: MisbehaviorCondition::ZombieProcess, + severity: Severity::Warning, + }, + MisbehaviorRule { + name: "High Disk I/O".to_string(), + description: "Process performing excessive disk operations".to_string(), + condition: MisbehaviorCondition::DiskIoAbove { + threshold_bytes_per_sec: 100 * 1024 * 1024, // 100 MB/s + duration_secs: 60, + }, + severity: Severity::Warning, + }, + ] + } + + pub fn add_rule(&mut self, rule: MisbehaviorRule) { + self.rules.push(rule); + } + + pub fn check_process(&mut self, snapshot: &ProcessSnapshot) -> Vec { + let mut alerts = Vec::new(); + let rules = self.rules.clone(); + + for rule in &rules { + if self.check_rule(snapshot, rule) { + let alert = MisbehaviorAlert { + pid: snapshot.info.pid, + process_name: snapshot.info.name.clone(), + rule_name: rule.name.clone(), + description: rule.description.clone(), + severity: rule.severity, + timestamp: chrono::Utc::now(), + details: self.get_violation_details(snapshot, &rule.condition), + }; + + alerts.push(alert); + } + } + + alerts + } + + fn check_rule(&mut self, snapshot: &ProcessSnapshot, rule: &MisbehaviorRule) -> bool { + match &rule.condition { + MisbehaviorCondition::CpuUsageAbove { threshold, duration_secs } => { + if snapshot.stats.cpu_usage > *threshold { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::MemoryUsageAbove { threshold_bytes, duration_secs } => { + if snapshot.stats.memory_usage > *threshold_bytes { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::MemoryPercentAbove { threshold_percent, duration_secs } => { + if snapshot.stats.memory_percent > *threshold_percent { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::DiskIoAbove { threshold_bytes_per_sec, duration_secs } => { + let total_io = snapshot.stats.disk_read_bytes + snapshot.stats.disk_write_bytes; + let io_per_sec = total_io / snapshot.stats.run_time.as_secs().max(1); + + if io_per_sec > *threshold_bytes_per_sec { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::NetworkIoAbove { threshold_bytes_per_sec, duration_secs } => { + let total_net = snapshot.stats.network_rx_bytes + snapshot.stats.network_tx_bytes; + let net_per_sec = total_net / snapshot.stats.run_time.as_secs().max(1); + + if net_per_sec > *threshold_bytes_per_sec { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + MisbehaviorCondition::TooManyThreads { threshold } => { + snapshot.stats.num_threads > *threshold + } + MisbehaviorCondition::ZombieProcess => { + matches!(snapshot.info.status, crate::process::ProcessStatus::Zombie) + } + MisbehaviorCondition::HighDiskWrites { threshold_bytes_per_sec, duration_secs } => { + let write_per_sec = snapshot.stats.disk_write_bytes / snapshot.stats.run_time.as_secs().max(1); + + if write_per_sec > *threshold_bytes_per_sec { + self.record_violation(snapshot.info.pid, &rule.name, *duration_secs) + } else { + false + } + } + } + } + + fn record_violation(&mut self, pid: u32, rule_name: &str, duration_secs: u64) -> bool { + let now = chrono::Utc::now(); + let history = self.violation_history.entry(pid).or_insert_with(Vec::new); + + // Add new violation + history.push(ViolationRecord { + rule_name: rule_name.to_string(), + timestamp: now, + }); + + // Clean up old violations + let cutoff = now - chrono::Duration::seconds(duration_secs as i64); + history.retain(|v| v.timestamp > cutoff && v.rule_name == rule_name); + + // Check if violation has persisted for the required duration + if let Some(first) = history.first() { + let violation_duration = (now - first.timestamp).num_seconds() as u64; + violation_duration >= duration_secs + } else { + false + } + } + + fn get_violation_details(&self, snapshot: &ProcessSnapshot, condition: &MisbehaviorCondition) -> String { + match condition { + MisbehaviorCondition::CpuUsageAbove { threshold, .. } => { + format!("CPU usage: {:.1}% (threshold: {:.1}%)", snapshot.stats.cpu_usage, threshold) + } + MisbehaviorCondition::MemoryUsageAbove { threshold_bytes, .. } => { + format!( + "Memory usage: {:.2} GB (threshold: {:.2} GB)", + snapshot.stats.memory_usage as f64 / (1024.0 * 1024.0 * 1024.0), + *threshold_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ) + } + MisbehaviorCondition::MemoryPercentAbove { threshold_percent, .. } => { + format!("Memory usage: {:.1}% (threshold: {:.1}%)", snapshot.stats.memory_percent, threshold_percent) + } + MisbehaviorCondition::DiskIoAbove { threshold_bytes_per_sec, .. } => { + let total_io = snapshot.stats.disk_read_bytes + snapshot.stats.disk_write_bytes; + let io_per_sec = total_io / snapshot.stats.run_time.as_secs().max(1); + format!( + "Disk I/O: {:.2} MB/s (threshold: {:.2} MB/s)", + io_per_sec as f64 / (1024.0 * 1024.0), + *threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) + ) + } + MisbehaviorCondition::NetworkIoAbove { threshold_bytes_per_sec, .. } => { + let total_net = snapshot.stats.network_rx_bytes + snapshot.stats.network_tx_bytes; + let net_per_sec = total_net / snapshot.stats.run_time.as_secs().max(1); + format!( + "Network I/O: {:.2} MB/s (threshold: {:.2} MB/s)", + net_per_sec as f64 / (1024.0 * 1024.0), + *threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) + ) + } + MisbehaviorCondition::TooManyThreads { threshold } => { + format!("Threads: {} (threshold: {})", snapshot.stats.num_threads, threshold) + } + MisbehaviorCondition::ZombieProcess => { + "Process is in zombie state".to_string() + } + MisbehaviorCondition::HighDiskWrites { threshold_bytes_per_sec, .. } => { + let write_per_sec = snapshot.stats.disk_write_bytes / snapshot.stats.run_time.as_secs().max(1); + format!( + "Disk writes: {:.2} MB/s (threshold: {:.2} MB/s)", + write_per_sec as f64 / (1024.0 * 1024.0), + *threshold_bytes_per_sec as f64 / (1024.0 * 1024.0) + ) + } + } + } + + pub fn cleanup_dead_processes(&mut self, active_pids: &[u32]) { + self.violation_history.retain(|pid, _| active_pids.contains(pid)); + } + + pub fn get_rules(&self) -> &[MisbehaviorRule] { + &self.rules + } +} + +impl Default for MisbehaviorDetector { + fn default() -> Self { + Self::new() + } +} diff --git a/procmon-core/src/lib.rs b/procmon-core/src/lib.rs new file mode 100644 index 0000000..cd12dbd --- /dev/null +++ b/procmon-core/src/lib.rs @@ -0,0 +1,16 @@ +pub mod monitor; +pub mod process; +pub mod metrics; +pub mod detector; +pub mod partition; +pub mod service; + +#[cfg(test)] +mod tests; + +pub use monitor::SystemMonitor; +pub use process::{ProcessInfo, ProcessStats}; +pub use metrics::*; +pub use detector::{MisbehaviorDetector, MisbehaviorRule, MisbehaviorAlert}; +pub use partition::{PartitionManager, Disk, Partition}; +pub use service::{ServiceManager, SystemService, ServiceState}; diff --git a/procmon-core/src/metrics.rs b/procmon-core/src/metrics.rs new file mode 100644 index 0000000..0bc3229 --- /dev/null +++ b/procmon-core/src/metrics.rs @@ -0,0 +1,105 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CpuMetrics { + pub total_usage: f32, + pub per_core_usage: Vec, + pub temperature: Option, + pub frequency: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GpuMetrics { + pub name: String, + pub usage: f32, + pub memory_used: u64, + pub memory_total: u64, + pub temperature: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkMetrics { + pub interface_name: String, + pub bytes_sent: u64, + pub bytes_received: u64, + pub packets_sent: u64, + pub packets_received: u64, + pub errors_in: u64, + pub errors_out: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiskIoMetrics { + pub device_name: String, + pub read_bytes: u64, + pub write_bytes: u64, + pub read_ops: u64, + pub write_ops: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsbIoMetrics { + pub device_id: String, + pub device_name: String, + pub vendor_id: u16, + pub product_id: u16, + pub bytes_transferred: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryMetrics { + pub total: u64, + pub used: u64, + pub available: u64, + pub swap_total: u64, + pub swap_used: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemMetrics { + pub timestamp: chrono::DateTime, + pub cpu: CpuMetrics, + pub memory: MemoryMetrics, + pub gpus: Vec, + pub network: HashMap, + pub disk_io: HashMap, + pub usb_io: Vec, +} + +impl Default for CpuMetrics { + fn default() -> Self { + Self { + total_usage: 0.0, + per_core_usage: Vec::new(), + temperature: None, + frequency: None, + } + } +} + +impl Default for MemoryMetrics { + fn default() -> Self { + Self { + total: 0, + used: 0, + available: 0, + swap_total: 0, + swap_used: 0, + } + } +} + +impl Default for SystemMetrics { + fn default() -> Self { + Self { + timestamp: chrono::Utc::now(), + cpu: CpuMetrics::default(), + memory: MemoryMetrics::default(), + gpus: Vec::new(), + network: HashMap::new(), + disk_io: HashMap::new(), + usb_io: Vec::new(), + } + } +} diff --git a/procmon-core/src/monitor.rs b/procmon-core/src/monitor.rs new file mode 100644 index 0000000..8525f8d --- /dev/null +++ b/procmon-core/src/monitor.rs @@ -0,0 +1,428 @@ +use crate::metrics::*; +use crate::process::{ProcessInfo, ProcessStats, ProcessSnapshot, ProcessStatus}; +use anyhow::Result; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use sysinfo::{System, Process, Pid, Networks, Disks}; + +pub struct SystemMonitor { + system: Arc>, + networks: Arc>, + disks: Arc>, + previous_disk_stats: Arc>>, + previous_net_stats: Arc>>, +} + +impl SystemMonitor { + pub fn new() -> Self { + // Start with empty system, we'll populate it on first refresh + let system = System::new(); + + Self { + system: Arc::new(RwLock::new(system)), + networks: Arc::new(RwLock::new(Networks::new_with_refreshed_list())), + disks: Arc::new(RwLock::new(Disks::new_with_refreshed_list())), + previous_disk_stats: Arc::new(RwLock::new(HashMap::new())), + previous_net_stats: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn refresh(&self) { + let mut system = self.system.write(); + // IMPORTANT: We need to completely rebuild the process list to avoid stale PIDs + // sysinfo has a known issue where it doesn't properly remove terminated processes + // So we clear the process list and rebuild it from scratch + use sysinfo::{ProcessRefreshKind, RefreshKind, MemoryRefreshKind, CpuRefreshKind}; + + // Create a completely fresh system to avoid accumulated stale processes + *system = System::new_with_specifics(RefreshKind::new() + .with_processes(ProcessRefreshKind::everything()) + .with_memory(MemoryRefreshKind::everything()) + .with_cpu(CpuRefreshKind::everything())); + + let mut networks = self.networks.write(); + networks.refresh(); + + let mut disks = self.disks.write(); + disks.refresh(); + } + + pub fn get_system_metrics(&self) -> Result { + let system = self.system.read(); + let networks = self.networks.read(); + + let cpu = self.get_cpu_metrics(&system)?; + let memory = self.get_memory_metrics(&system)?; + let gpus = self.get_gpu_metrics()?; + let network = self.get_network_metrics(&networks)?; + let disk_io = self.get_disk_io_metrics()?; + let usb_io = self.get_usb_io_metrics()?; + + Ok(SystemMetrics { + timestamp: chrono::Utc::now(), + cpu, + memory, + gpus, + network, + disk_io, + usb_io, + }) + } + + fn get_cpu_metrics(&self, system: &System) -> Result { + let cpus = system.cpus(); + let total_usage = system.global_cpu_usage(); + let per_core_usage: Vec = cpus.iter().map(|cpu| cpu.cpu_usage()).collect(); + + let temperature = self.read_cpu_temperature(); + let frequency = cpus.first().map(|cpu| cpu.frequency()); + + Ok(CpuMetrics { + total_usage, + per_core_usage, + temperature, + frequency, + }) + } + + fn get_memory_metrics(&self, system: &System) -> Result { + Ok(MemoryMetrics { + total: system.total_memory(), + used: system.used_memory(), + available: system.available_memory(), + swap_total: system.total_swap(), + swap_used: system.used_swap(), + }) + } + + fn get_gpu_metrics(&self) -> Result> { + // GPU monitoring is complex and platform-specific + // On Linux, we can read from /sys/class/drm or use nvml for NVIDIA + let mut gpus = Vec::new(); + + // Try to detect AMD GPUs via sysfs + if let Ok(entries) = fs::read_dir("/sys/class/drm") { + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with("card") && !name_str.contains('-') { + if let Some(gpu) = self.read_amd_gpu_info(&path) { + gpus.push(gpu); + } + } + } + } + + Ok(gpus) + } + + fn read_amd_gpu_info(&self, card_path: &Path) -> Option { + let device_path = card_path.join("device"); + + let name = fs::read_to_string(device_path.join("product_name")) + .or_else(|_| fs::read_to_string(device_path.join("model"))) + .unwrap_or_else(|_| "Unknown GPU".to_string()) + .trim() + .to_string(); + + // Try to read GPU usage + let usage = fs::read_to_string(device_path.join("gpu_busy_percent")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0.0); + + // Try to read VRAM usage + let memory_used = fs::read_to_string(device_path.join("mem_info_vram_used")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + + let memory_total = fs::read_to_string(device_path.join("mem_info_vram_total")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + + // Try to read temperature + let temperature = fs::read_to_string(device_path.join("hwmon/hwmon0/temp1_input")) + .or_else(|_| fs::read_to_string(device_path.join("hwmon/hwmon1/temp1_input"))) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|t| t / 1000.0); // Convert from millidegrees + + Some(GpuMetrics { + name, + usage, + memory_used, + memory_total, + temperature, + }) + } + + fn get_network_metrics(&self, networks: &Networks) -> Result> { + let mut result = HashMap::new(); + + for (interface_name, data) in networks.iter() { + let metrics = NetworkMetrics { + interface_name: interface_name.to_string(), + bytes_sent: data.total_transmitted(), + bytes_received: data.total_received(), + packets_sent: data.total_packets_transmitted(), + packets_received: data.total_packets_received(), + errors_in: data.total_errors_on_received(), + errors_out: data.total_errors_on_transmitted(), + }; + result.insert(interface_name.to_string(), metrics); + } + + Ok(result) + } + + fn get_disk_io_metrics(&self) -> Result> { + let mut result = HashMap::new(); + + // Read disk I/O stats from /proc/diskstats on Linux + if let Ok(content) = fs::read_to_string("/proc/diskstats") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 14 { + let device_name = parts[2].to_string(); + + // Skip loop and ram devices + if device_name.starts_with("loop") || device_name.starts_with("ram") { + continue; + } + + let read_ops = parts[3].parse::().unwrap_or(0); + let read_sectors = parts[5].parse::().unwrap_or(0); + let write_ops = parts[7].parse::().unwrap_or(0); + let write_sectors = parts[9].parse::().unwrap_or(0); + + let metrics = DiskIoMetrics { + device_name: device_name.clone(), + read_bytes: read_sectors * 512, // sectors are 512 bytes + write_bytes: write_sectors * 512, + read_ops, + write_ops, + }; + + result.insert(device_name, metrics); + } + } + } + + Ok(result) + } + + fn get_usb_io_metrics(&self) -> Result> { + let mut usb_devices = Vec::new(); + + // Read USB device information from /sys/bus/usb/devices + if let Ok(entries) = fs::read_dir("/sys/bus/usb/devices") { + for entry in entries.flatten() { + let path = entry.path(); + + // Read vendor and product IDs + let vendor_id = fs::read_to_string(path.join("idVendor")) + .ok() + .and_then(|s| u16::from_str_radix(s.trim(), 16).ok()) + .unwrap_or(0); + + let product_id = fs::read_to_string(path.join("idProduct")) + .ok() + .and_then(|s| u16::from_str_radix(s.trim(), 16).ok()) + .unwrap_or(0); + + if vendor_id == 0 && product_id == 0 { + continue; + } + + let device_name = fs::read_to_string(path.join("product")) + .unwrap_or_else(|_| "Unknown USB Device".to_string()) + .trim() + .to_string(); + + let device_id = entry.file_name().to_string_lossy().to_string(); + + usb_devices.push(UsbIoMetrics { + device_id, + device_name, + vendor_id, + product_id, + bytes_transferred: 0, // Would need more complex tracking + }); + } + } + + Ok(usb_devices) + } + + fn read_cpu_temperature(&self) -> Option { + // Try to read from common thermal zones + for i in 0..10 { + let temp_path = format!("/sys/class/thermal/thermal_zone{}/temp", i); + if let Ok(temp_str) = fs::read_to_string(&temp_path) { + if let Ok(temp) = temp_str.trim().parse::() { + return Some(temp / 1000.0); // Convert from millidegrees + } + } + } + + // Try hwmon + if let Ok(entries) = fs::read_dir("/sys/class/hwmon") { + for entry in entries.flatten() { + let temp_path = entry.path().join("temp1_input"); + if let Ok(temp_str) = fs::read_to_string(&temp_path) { + if let Ok(temp) = temp_str.trim().parse::() { + return Some(temp / 1000.0); + } + } + } + } + + None + } + + pub fn get_all_processes(&self) -> Result> { + let system = self.system.read(); + let mut processes = Vec::new(); + + let total_from_sysinfo = system.processes().len(); + let mut skipped_count = 0; + + // Build a set of actual process PIDs (not threads) by reading /proc directory + // This is the most reliable way to distinguish processes from threads + let mut real_pids = std::collections::HashSet::new(); + if let Ok(entries) = fs::read_dir("/proc") { + for entry in entries.flatten() { + if let Ok(file_name) = entry.file_name().into_string() { + if let Ok(pid) = file_name.parse::() { + real_pids.insert(pid); + } + } + } + } + + for (pid, process) in system.processes() { + let pid_u32 = pid.as_u32(); + + // Only include PIDs that are actual processes (in /proc directory listing) + // This filters out threads which have /proc/{tid} entries but aren't in directory listing + if !real_pids.contains(&pid_u32) { + skipped_count += 1; + continue; + } + + if let Some(snapshot) = self.process_to_snapshot(*pid, process) { + processes.push(snapshot); + } + } + + #[cfg(test)] + eprintln!("get_all_processes: sysinfo reported {}, skipped {}, returning {}", + total_from_sysinfo, skipped_count, processes.len()); + + Ok(processes) + } + + pub fn get_process(&self, pid: u32) -> Result> { + let system = self.system.read(); + let pid = Pid::from_u32(pid); + + Ok(system.process(pid).and_then(|p| self.process_to_snapshot(pid, p))) + } + + fn process_to_snapshot(&self, pid: Pid, process: &Process) -> Option { + let user = self.get_process_user(pid.as_u32()); + + let info = ProcessInfo { + pid: pid.as_u32(), + name: process.name().to_string_lossy().to_string(), + user: user.0, + uid: user.1, + exe_path: process.exe().map(|p| p.to_path_buf()), + command_line: process.cmd().iter().map(|s| s.to_string_lossy().to_string()).collect(), + status: self.convert_process_status(process.status()), + parent_pid: process.parent().map(|p| p.as_u32()), + }; + + let stats = ProcessStats { + pid: pid.as_u32(), + cpu_usage: process.cpu_usage(), + memory_usage: process.memory(), + memory_percent: 0.0, // Calculate if needed + virtual_memory: process.virtual_memory(), + disk_read_bytes: process.disk_usage().read_bytes, + disk_write_bytes: process.disk_usage().written_bytes, + network_rx_bytes: 0, // Would need per-process network tracking + network_tx_bytes: 0, + num_threads: 0, // Not available in sysinfo + start_time: chrono::Utc::now(), // Would need to calculate from process start time + run_time: std::time::Duration::from_secs(process.run_time()), + }; + + Some(ProcessSnapshot { + info, + stats, + timestamp: chrono::Utc::now(), + }) + } + + fn get_process_user(&self, pid: u32) -> (String, u32) { + // Try to read user from /proc + let status_path = format!("/proc/{}/status", pid); + if let Ok(content) = fs::read_to_string(&status_path) { + for line in content.lines() { + if line.starts_with("Uid:") { + if let Some(uid_str) = line.split_whitespace().nth(1) { + if let Ok(uid) = uid_str.parse::() { + let username = self.uid_to_username(uid); + return (username, uid); + } + } + } + } + } + + ("unknown".to_string(), 0) + } + + fn uid_to_username(&self, uid: u32) -> String { + // Try to read from /etc/passwd + if let Ok(content) = fs::read_to_string("/etc/passwd") { + for line in content.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 3 { + if let Ok(line_uid) = parts[2].parse::() { + if line_uid == uid { + return parts[0].to_string(); + } + } + } + } + } + + format!("uid:{}", uid) + } + + fn convert_process_status(&self, status: sysinfo::ProcessStatus) -> ProcessStatus { + match status { + sysinfo::ProcessStatus::Run => ProcessStatus::Running, + sysinfo::ProcessStatus::Sleep => ProcessStatus::Sleeping, + sysinfo::ProcessStatus::Stop => ProcessStatus::Stopped, + sysinfo::ProcessStatus::Zombie => ProcessStatus::Zombie, + sysinfo::ProcessStatus::Dead => ProcessStatus::Dead, + _ => ProcessStatus::Unknown, + } + } +} + +impl Default for SystemMonitor { + fn default() -> Self { + Self::new() + } +} diff --git a/procmon-core/src/partition.rs b/procmon-core/src/partition.rs new file mode 100644 index 0000000..cfc8067 --- /dev/null +++ b/procmon-core/src/partition.rs @@ -0,0 +1,437 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Partition { + pub device: String, + pub partition_number: Option, + pub filesystem: Option, + pub label: Option, + pub size_bytes: u64, + pub used_bytes: u64, + pub mount_point: Option, + pub partition_type: Option, + pub flags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Disk { + pub device: String, + pub model: String, + pub size_bytes: u64, + pub logical_sector_size: u32, + pub physical_sector_size: u32, + pub partitions: Vec, +} + +pub struct PartitionManager { +} + +impl PartitionManager { + pub fn new() -> Self { + Self {} + } + + /// List all block devices and their partitions + pub fn list_disks(&self) -> Result> { + let mut disks = Vec::new(); + + // Use lsblk to get block device information + let output = Command::new("lsblk") + .args(&["-J", "-b", "-o", "NAME,TYPE,SIZE,FSTYPE,LABEL,MOUNTPOINT,MODEL"]) + .output()?; + + if output.status.success() { + let json_str = String::from_utf8_lossy(&output.stdout); + if let Ok(lsblk_data) = serde_json::from_str::(&json_str) { + if let Some(blockdevices) = lsblk_data["blockdevices"].as_array() { + for device in blockdevices { + if device["type"].as_str() == Some("disk") { + disks.push(self.parse_disk(device)?); + } + } + } + } + } + + Ok(disks) + } + + fn parse_disk(&self, device: &serde_json::Value) -> Result { + let device_name = device["name"].as_str().unwrap_or("unknown").to_string(); + let model = device["model"].as_str().unwrap_or("Unknown").trim().to_string(); + let size_bytes = device["size"].as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + // Get sector sizes from sysfs + let (logical_sector_size, physical_sector_size) = self.get_sector_sizes(&device_name); + + // Parse partitions + let mut partitions = Vec::new(); + if let Some(children) = device["children"].as_array() { + for child in children { + if let Some(part) = self.parse_partition(child, &device_name) { + partitions.push(part); + } + } + } + + Ok(Disk { + device: format!("/dev/{}", device_name), + model, + size_bytes, + logical_sector_size, + physical_sector_size, + partitions, + }) + } + + fn parse_partition(&self, part: &serde_json::Value, parent_device: &str) -> Option { + let name = part["name"].as_str()?; + let size_bytes = part["size"].as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + // Extract partition number + let partition_number = name.trim_start_matches(parent_device) + .trim_start_matches('p') + .parse::().ok(); + + // Get filesystem info + let filesystem = part["fstype"].as_str().map(|s| s.to_string()); + let label = part["label"].as_str().map(|s| s.to_string()); + let mount_point = part["mountpoint"].as_str().map(|s| s.to_string()); + + // Get partition type and flags from parted + let (partition_type, flags) = self.get_partition_info(&format!("/dev/{}", name)); + + // Get used space if mounted + let used_bytes = if let Some(ref mp) = mount_point { + self.get_used_space(mp).unwrap_or(0) + } else { + 0 + }; + + Some(Partition { + device: format!("/dev/{}", name), + partition_number, + filesystem, + label, + size_bytes, + used_bytes, + mount_point, + partition_type, + flags, + }) + } + + fn get_sector_sizes(&self, device: &str) -> (u32, u32) { + let logical = fs::read_to_string(format!("/sys/block/{}/queue/logical_block_size", device)) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(512); + + let physical = fs::read_to_string(format!("/sys/block/{}/queue/physical_block_size", device)) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(512); + + (logical, physical) + } + + fn get_partition_info(&self, device: &str) -> (Option, Vec) { + // Use parted to get partition type and flags + let output = Command::new("parted") + .args(&[device, "print"]) + .output(); + + if let Ok(output) = output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse parted output for partition type and flags + // This is a simplified version - full parsing would be more complex + for line in stdout.lines() { + if line.contains("Partition Table:") { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() > 1 { + return (Some(parts[1].trim().to_string()), Vec::new()); + } + } + } + } + } + + (None, Vec::new()) + } + + fn get_used_space(&self, mount_point: &str) -> Option { + let output = Command::new("df") + .args(&["-B1", mount_point]) + .output() + .ok()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + if lines.len() > 1 { + let fields: Vec<&str> = lines[1].split_whitespace().collect(); + if fields.len() > 2 { + return fields[2].parse::().ok(); + } + } + } + + None + } + + /// Create a new partition table (WARNING: destroys all data) + pub fn create_partition_table(&self, device: &str, table_type: &str) -> Result<()> { + // table_type can be: gpt, msdos, etc. + let output = Command::new("parted") + .args(&["-s", device, "mklabel", table_type]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to create partition table: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Create a new partition + pub fn create_partition( + &self, + device: &str, + start: &str, + end: &str, + fs_type: &str, + ) -> Result<()> { + let output = Command::new("parted") + .args(&["-s", device, "mkpart", "primary", fs_type, start, end]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to create partition: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Delete a partition + pub fn delete_partition(&self, device: &str, partition_number: u32) -> Result<()> { + let output = Command::new("parted") + .args(&["-s", device, "rm", &partition_number.to_string()]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to delete partition: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Resize a partition + pub fn resize_partition( + &self, + device: &str, + partition_number: u32, + end: &str, + ) -> Result<()> { + let output = Command::new("parted") + .args(&["-s", device, "resizepart", &partition_number.to_string(), end]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to resize partition: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Format a partition with specified filesystem + pub fn format_partition(&self, device: &str, filesystem: &str, label: Option<&str>) -> Result<()> { + let mut args = vec![device]; + + match filesystem { + "ext2" | "ext3" | "ext4" => { + let mut cmd = Command::new(&format!("mkfs.{}", filesystem)); + if let Some(lbl) = label { + cmd.args(&["-L", lbl]); + } + cmd.arg(device); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!("Failed to format: {}", String::from_utf8_lossy(&output.stderr)); + } + } + "xfs" => { + let mut cmd = Command::new("mkfs.xfs"); + cmd.args(&["-f"]); + if let Some(lbl) = label { + cmd.args(&["-L", lbl]); + } + cmd.arg(device); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!("Failed to format: {}", String::from_utf8_lossy(&output.stderr)); + } + } + "btrfs" => { + let mut cmd = Command::new("mkfs.btrfs"); + cmd.args(&["-f"]); + if let Some(lbl) = label { + cmd.args(&["-L", lbl]); + } + cmd.arg(device); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!("Failed to format: {}", String::from_utf8_lossy(&output.stderr)); + } + } + "f2fs" => { + let mut cmd = Command::new("mkfs.f2fs"); + if let Some(lbl) = label { + cmd.args(&["-l", lbl]); + } + cmd.arg(device); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!("Failed to format: {}", String::from_utf8_lossy(&output.stderr)); + } + } + "ntfs" => { + let mut cmd = Command::new("mkfs.ntfs"); + cmd.args(&["-f"]); + if let Some(lbl) = label { + cmd.args(&["-L", lbl]); + } + cmd.arg(device); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!("Failed to format: {}", String::from_utf8_lossy(&output.stderr)); + } + } + "fat32" | "vfat" => { + let mut cmd = Command::new("mkfs.vfat"); + cmd.args(&["-F", "32"]); + if let Some(lbl) = label { + cmd.args(&["-n", lbl]); + } + cmd.arg(device); + let output = cmd.output()?; + if !output.status.success() { + anyhow::bail!("Failed to format: {}", String::from_utf8_lossy(&output.stderr)); + } + } + _ => anyhow::bail!("Unsupported filesystem type: {}", filesystem), + } + + Ok(()) + } + + /// Resize filesystem (must be done after partition resize) + pub fn resize_filesystem(&self, device: &str, filesystem: &str) -> Result<()> { + match filesystem { + "ext2" | "ext3" | "ext4" => { + let output = Command::new("resize2fs") + .arg(device) + .output()?; + if !output.status.success() { + anyhow::bail!("Failed to resize filesystem: {}", String::from_utf8_lossy(&output.stderr)); + } + } + "xfs" => { + // XFS requires the filesystem to be mounted + anyhow::bail!("XFS filesystem must be mounted to resize. Use 'xfs_growfs' on the mount point."); + } + "btrfs" => { + let output = Command::new("btrfs") + .args(&["filesystem", "resize", "max", device]) + .output()?; + if !output.status.success() { + anyhow::bail!("Failed to resize filesystem: {}", String::from_utf8_lossy(&output.stderr)); + } + } + _ => anyhow::bail!("Filesystem resize not supported for: {}", filesystem), + } + + Ok(()) + } + + /// Set partition flags + pub fn set_partition_flag(&self, device: &str, partition_number: u32, flag: &str, state: bool) -> Result<()> { + let state_str = if state { "on" } else { "off" }; + let output = Command::new("parted") + .args(&["-s", device, "set", &partition_number.to_string(), flag, state_str]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to set flag: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Check filesystem for errors + pub fn check_filesystem(&self, device: &str, filesystem: &str, repair: bool) -> Result { + let output = match filesystem { + "ext2" | "ext3" | "ext4" => { + let mut cmd = Command::new("e2fsck"); + if repair { + cmd.args(&["-p"]); // Automatic repair + } else { + cmd.args(&["-n"]); // No changes, just check + } + cmd.arg(device).output()? + } + "xfs" => { + Command::new("xfs_repair") + .args(&[if repair { "-n" } else { "-n" }, device]) + .output()? + } + "btrfs" => { + Command::new("btrfs") + .args(&["check", if repair { "--repair" } else { "" }, device]) + .output()? + } + _ => anyhow::bail!("Filesystem check not supported for: {}", filesystem), + }; + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + /// Get supported filesystems on this system + pub fn get_supported_filesystems(&self) -> Vec { + let mut filesystems = vec![ + "ext2", "ext3", "ext4", "xfs", "btrfs", "f2fs", + "ntfs", "vfat", "fat32", "exfat", "swap" + ]; + + // Check which mkfs utilities are available + let mut available = Vec::new(); + for fs in filesystems { + let binary = match fs { + "fat32" | "vfat" => "mkfs.vfat", + _ => &format!("mkfs.{}", fs), + }; + + if Command::new("which").arg(binary).output().ok() + .map(|o| o.status.success()) + .unwrap_or(false) + { + available.push(fs.to_string()); + } + } + + available + } +} + +impl Default for PartitionManager { + fn default() -> Self { + Self::new() + } +} diff --git a/procmon-core/src/process.rs b/procmon-core/src/process.rs new file mode 100644 index 0000000..b571efe --- /dev/null +++ b/procmon-core/src/process.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessInfo { + pub pid: u32, + pub name: String, + pub user: String, + pub uid: u32, + pub exe_path: Option, + pub command_line: Vec, + pub status: ProcessStatus, + pub parent_pid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ProcessStatus { + Running, + Sleeping, + Stopped, + Zombie, + Dead, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessStats { + pub pid: u32, + pub cpu_usage: f32, + pub memory_usage: u64, + pub memory_percent: f32, + pub virtual_memory: u64, + pub disk_read_bytes: u64, + pub disk_write_bytes: u64, + pub network_rx_bytes: u64, + pub network_tx_bytes: u64, + pub num_threads: u32, + pub start_time: chrono::DateTime, + pub run_time: std::time::Duration, +} + +impl ProcessInfo { + pub fn new( + pid: u32, + name: String, + user: String, + uid: u32, + ) -> Self { + Self { + pid, + name, + user, + uid, + exe_path: None, + command_line: Vec::new(), + status: ProcessStatus::Unknown, + parent_pid: None, + } + } +} + +impl Default for ProcessStats { + fn default() -> Self { + Self { + pid: 0, + cpu_usage: 0.0, + memory_usage: 0, + memory_percent: 0.0, + virtual_memory: 0, + disk_read_bytes: 0, + disk_write_bytes: 0, + network_rx_bytes: 0, + network_tx_bytes: 0, + num_threads: 0, + start_time: chrono::Utc::now(), + run_time: std::time::Duration::from_secs(0), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessSnapshot { + pub info: ProcessInfo, + pub stats: ProcessStats, + pub timestamp: chrono::DateTime, +} diff --git a/procmon-core/src/service.rs b/procmon-core/src/service.rs new file mode 100644 index 0000000..3d074a2 --- /dev/null +++ b/procmon-core/src/service.rs @@ -0,0 +1,227 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemService { + pub name: String, + pub description: String, + pub state: ServiceState, + pub enabled: bool, + pub active_state: String, + pub sub_state: String, + pub memory_usage: Option, + pub cpu_usage: Option, + pub main_pid: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServiceState { + Running, + Stopped, + Failed, + Unknown, +} + +impl From<&str> for ServiceState { + fn from(s: &str) -> Self { + match s { + "active" | "running" => ServiceState::Running, + "inactive" | "dead" => ServiceState::Stopped, + "failed" => ServiceState::Failed, + _ => ServiceState::Unknown, + } + } +} + +pub struct ServiceManager { + // No state needed, operates on systemctl +} + +impl ServiceManager { + pub fn new() -> Self { + Self {} + } + + /// List all systemd services + pub fn list_services(&self) -> Result> { + let output = Command::new("systemctl") + .args(&["list-units", "--type=service", "--all", "--no-pager", "--plain"]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to list services: {}", String::from_utf8_lossy(&output.stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut services = Vec::new(); + + for line in stdout.lines().skip(1) { // Skip header + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 4 { + continue; + } + + // Format: UNIT LOAD ACTIVE SUB DESCRIPTION + let unit_name = parts[0]; + if !unit_name.ends_with(".service") { + continue; + } + + let name = unit_name.trim_end_matches(".service").to_string(); + let active_state = parts[2].to_string(); + let sub_state = parts[3].to_string(); + let description = if parts.len() > 4 { + parts[4..].join(" ") + } else { + String::new() + }; + + let state = ServiceState::from(active_state.as_str()); + + // Check if service is enabled + let enabled = self.is_service_enabled(&name).unwrap_or(false); + + // Get detailed info including PID and resource usage + let (main_pid, memory_usage, cpu_usage) = self.get_service_details(&name).unwrap_or((None, None, None)); + + services.push(SystemService { + name, + description, + state, + enabled, + active_state, + sub_state, + memory_usage, + cpu_usage, + main_pid, + }); + } + + Ok(services) + } + + /// Get detailed information about a service + fn get_service_details(&self, service_name: &str) -> Result<(Option, Option, Option)> { + let output = Command::new("systemctl") + .args(&["show", &format!("{}.service", service_name), "--no-pager"]) + .output()?; + + if !output.status.success() { + return Ok((None, None, None)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut main_pid = None; + let mut memory_usage = None; + + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("MainPID=") { + if let Ok(pid) = value.parse::() { + if pid > 0 { + main_pid = Some(pid); + } + } + } else if let Some(value) = line.strip_prefix("MemoryCurrent=") { + if let Ok(mem) = value.parse::() { + if mem > 0 { + memory_usage = Some(mem); + } + } + } + } + + // CPU usage would require tracking over time, skip for now + Ok((main_pid, memory_usage, None)) + } + + /// Check if a service is enabled + fn is_service_enabled(&self, service_name: &str) -> Result { + let output = Command::new("systemctl") + .args(&["is-enabled", &format!("{}.service", service_name)]) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.trim() == "enabled") + } + + /// Start a service + pub fn start_service(&self, service_name: &str) -> Result<()> { + let output = Command::new("systemctl") + .args(&["start", &format!("{}.service", service_name)]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to start service: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Stop a service + pub fn stop_service(&self, service_name: &str) -> Result<()> { + let output = Command::new("systemctl") + .args(&["stop", &format!("{}.service", service_name)]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to stop service: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Restart a service + pub fn restart_service(&self, service_name: &str) -> Result<()> { + let output = Command::new("systemctl") + .args(&["restart", &format!("{}.service", service_name)]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to restart service: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Enable a service + pub fn enable_service(&self, service_name: &str) -> Result<()> { + let output = Command::new("systemctl") + .args(&["enable", &format!("{}.service", service_name)]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to enable service: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Disable a service + pub fn disable_service(&self, service_name: &str) -> Result<()> { + let output = Command::new("systemctl") + .args(&["disable", &format!("{}.service", service_name)]) + .output()?; + + if !output.status.success() { + anyhow::bail!("Failed to disable service: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Get service status details + pub fn get_service_status(&self, service_name: &str) -> Result { + let output = Command::new("systemctl") + .args(&["status", &format!("{}.service", service_name), "--no-pager"]) + .output()?; + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +} + +impl Default for ServiceManager { + fn default() -> Self { + Self::new() + } +} diff --git a/procmon-core/src/tests.rs b/procmon-core/src/tests.rs new file mode 100644 index 0000000..dc9d0a7 --- /dev/null +++ b/procmon-core/src/tests.rs @@ -0,0 +1,95 @@ +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::collections::HashSet; + + #[test] + fn test_pid_accuracy() { + // Get PIDs from our monitoring code using get_all_processes() which has the /proc filter + let monitor = crate::monitor::SystemMonitor::new(); + // Refresh multiple times to ensure clean data + monitor.refresh(); + std::thread::sleep(std::time::Duration::from_millis(500)); + monitor.refresh(); + + // This should now return only valid PIDs due to our /proc filter + let processes = monitor.get_all_processes().unwrap(); + + println!("Total processes returned: {}", processes.len()); + + // Check for duplicates + let our_pids: HashSet = processes.iter().map(|p| p.info.pid).collect(); + println!("Unique PIDs: {}, Total processes: {}", our_pids.len(), processes.len()); + if our_pids.len() != processes.len() { + println!("WARNING: Duplicate PIDs detected!"); + } + + // Get PIDs from /proc directly + let mut proc_pids = HashSet::new(); + if let Ok(entries) = fs::read_dir("/proc") { + for entry in entries.flatten() { + if let Ok(file_name) = entry.file_name().into_string() { + if let Ok(pid) = file_name.parse::() { + proc_pids.insert(pid); + } + } + } + } + + println!("Our filtered PIDs: {}, /proc PIDs: {}", our_pids.len(), proc_pids.len()); + + // Find some examples of PIDs we have that /proc doesn't + let mut example_count = 0; + for pid in &our_pids { + if !proc_pids.contains(pid) && example_count < 5 { + eprintln!("Example missing PID: {} (checking if /proc/{}/stat exists...)", pid, pid); + let stat_path = format!("/proc/{}/stat", pid); + let exists = std::path::Path::new(&stat_path).exists(); + let can_read = fs::read_to_string(&stat_path).is_ok(); + eprintln!(" - Path exists: {}, Can read: {}", exists, can_read); + example_count += 1; + } + } + + // Check that ALL of our PIDs exist in /proc (since we filter them) + let mut matched = 0; + let mut total = 0; + for pid in &our_pids { + total += 1; + if proc_pids.contains(pid) { + matched += 1; + } + } + + // Should be 100% or very close (allowing for tiny race conditions) + let match_rate = (matched as f64 / total as f64) * 100.0; + assert!(match_rate > 99.0, + "Only {:.1}% of filtered PIDs matched /proc. Expected >99%. Matched: {}/{}", + match_rate, matched, total); + + println!("PID accuracy test PASSED: {}/{} ({:.1}%) PIDs verified", matched, total, match_rate); + } + + #[test] + fn test_specific_process_pid() { + let monitor = crate::monitor::SystemMonitor::new(); + monitor.refresh(); + let processes = monitor.get_all_processes().unwrap(); + + // Find init process (PID 1) - should always exist + let init = processes.iter().find(|p| p.info.pid == 1); + assert!(init.is_some(), "Init process (PID 1) not found"); + + // Verify our PID matches what's in /proc + for process in processes.iter().take(10) { + let pid = process.info.pid; + let proc_path = format!("/proc/{}/cmdline", pid); + + // If /proc/ exists, verify it + if std::path::Path::new(&proc_path).exists() { + println!("Verified PID {} exists: {}", pid, process.info.name); + } + } + } +} diff --git a/procmon-gui/Cargo.toml b/procmon-gui/Cargo.toml new file mode 100644 index 0000000..3cdd3cf --- /dev/null +++ b/procmon-gui/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "procmon-gui" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "procmon-gui" +path = "src/main.rs" + +[dependencies] +procmon-core = { path = "../procmon-core" } +tokio.workspace = true +anyhow.workspace = true +eframe.workspace = true +egui.workspace = true +egui_plot.workspace = true +chrono.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +serde.workspace = true +parking_lot.workspace = true diff --git a/procmon-gui/src/main.rs b/procmon-gui/src/main.rs new file mode 100644 index 0000000..51bdcf1 --- /dev/null +++ b/procmon-gui/src/main.rs @@ -0,0 +1,1009 @@ +use eframe::egui; +use procmon_core::{ + MisbehaviorDetector, SystemMetrics, SystemMonitor, PartitionManager, Disk, + ServiceManager, SystemService, ServiceState, + process::ProcessSnapshot, + detector::Severity, +}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use parking_lot::RwLock; + +fn main() -> eframe::Result<()> { + tracing_subscriber::fmt::init(); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1400.0, 900.0]) + .with_title("Process Monitor with Partition Manager"), + ..Default::default() + }; + + eframe::run_native( + "Process Monitor", + options, + Box::new(|_cc| Ok(Box::new(ProcessMonitorApp::new()))), + ) +} + +struct ProcessMonitorApp { + monitor: Arc>, + detector: Arc>, + partition_manager: Arc>, + service_manager: Arc>, + system_metrics: Arc>, + processes: Arc>>, + disks: Arc>>, + services: Arc>>, + alerts: Arc>>, + selected_tab: usize, + sort_by_cpu: bool, + selected_process: Option, + selected_process_pid: Option, + show_process_context_menu: bool, + context_menu_pos: egui::Pos2, + selected_disk: Option, + selected_partition: Option, + status_message: String, + show_format_dialog: bool, + format_filesystem: String, + show_delete_confirm: bool, +} + +impl ProcessMonitorApp { + fn new() -> Self { + let monitor = SystemMonitor::new(); + monitor.refresh(); + + let partition_manager = PartitionManager::new(); + let disks = partition_manager.list_disks().unwrap_or_default(); + + let service_manager = ServiceManager::new(); + let services = service_manager.list_services().unwrap_or_default(); + + let system_metrics = monitor.get_system_metrics().unwrap_or_default(); + let processes = monitor.get_all_processes().unwrap_or_default(); + + let monitor = Arc::new(RwLock::new(monitor)); + let detector = Arc::new(RwLock::new(MisbehaviorDetector::new())); + let partition_manager = Arc::new(RwLock::new(partition_manager)); + let service_manager = Arc::new(RwLock::new(service_manager)); + let system_metrics = Arc::new(RwLock::new(system_metrics)); + let processes = Arc::new(RwLock::new(processes)); + let disks = Arc::new(RwLock::new(disks)); + let services = Arc::new(RwLock::new(services)); + let alerts = Arc::new(RwLock::new(Vec::new())); + + // Spawn background update task + let monitor_clone = monitor.clone(); + let detector_clone = detector.clone(); + let partition_manager_clone = partition_manager.clone(); + let service_manager_clone = service_manager.clone(); + let system_metrics_clone = system_metrics.clone(); + let processes_clone = processes.clone(); + let disks_clone = disks.clone(); + let services_clone = services.clone(); + let alerts_clone = alerts.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let monitor = monitor_clone.read(); + monitor.refresh(); + + if let Ok(metrics) = monitor.get_system_metrics() { + *system_metrics_clone.write() = metrics; + } + + if let Ok(procs) = monitor.get_all_processes() { + *processes_clone.write() = procs.clone(); + + let mut detector = detector_clone.write(); + let mut alerts = alerts_clone.write(); + + for process in &procs { + let process_alerts = detector.check_process(process); + alerts.extend(process_alerts); + } + + let alerts_len = alerts.len(); + if alerts_len > 100 { + alerts.drain(0..alerts_len - 100); + } + + let active_pids: Vec = procs.iter().map(|p| p.info.pid).collect(); + detector.cleanup_dead_processes(&active_pids); + } + + // Refresh disks every 5 seconds + if Instant::now().elapsed().as_secs() % 5 == 0 { + let pm = partition_manager_clone.read(); + if let Ok(disk_list) = pm.list_disks() { + *disks_clone.write() = disk_list; + } + } + + // Refresh services every 3 seconds + if Instant::now().elapsed().as_secs() % 3 == 0 { + let sm = service_manager_clone.read(); + if let Ok(service_list) = sm.list_services() { + *services_clone.write() = service_list; + } + } + } + }); + }); + + Self { + monitor, + detector, + partition_manager, + service_manager, + system_metrics, + processes, + disks, + services, + alerts, + selected_tab: 0, + sort_by_cpu: true, + selected_process: None, + selected_process_pid: None, + show_process_context_menu: false, + context_menu_pos: egui::Pos2::ZERO, + selected_disk: None, + selected_partition: None, + status_message: String::new(), + show_format_dialog: false, + format_filesystem: "ext4".to_string(), + show_delete_confirm: false, + } + } + + fn draw_dashboard(&mut self, ui: &mut egui::Ui) { + let metrics = self.system_metrics.read(); + + ui.heading("System Overview"); + ui.add_space(10.0); + + egui::Grid::new("system_metrics") + .num_columns(2) + .spacing([40.0, 10.0]) + .show(ui, |ui| { + ui.label("CPU Usage:"); + ui.add( + egui::ProgressBar::new(metrics.cpu.total_usage / 100.0) + .text(format!("{:.1}%", metrics.cpu.total_usage)), + ); + ui.end_row(); + + let mem_percent = metrics.memory.used as f64 / metrics.memory.total as f64; + ui.label("Memory Usage:"); + ui.add( + egui::ProgressBar::new(mem_percent as f32) + .text(format!( + "{:.1} / {:.1} GB", + metrics.memory.used as f64 / (1024.0 * 1024.0 * 1024.0), + metrics.memory.total as f64 / (1024.0 * 1024.0 * 1024.0) + )), + ); + ui.end_row(); + + ui.label("CPU Temperature:"); + if let Some(temp) = metrics.cpu.temperature { + ui.label(format!("{:.1}°C", temp)); + } else { + ui.label("N/A"); + } + ui.end_row(); + }); + + ui.add_space(20.0); + ui.heading("CPU Core Usage"); + ui.add_space(10.0); + + let bar_width = 30.0; + let bar_spacing = 5.0; + let num_cores = metrics.cpu.per_core_usage.len(); + let chart_height = 150.0; + + let (response, painter) = ui.allocate_painter( + egui::Vec2::new( + (bar_width + bar_spacing) * num_cores as f32, + chart_height, + ), + egui::Sense::hover(), + ); + + let rect = response.rect; + + for (i, usage) in metrics.cpu.per_core_usage.iter().enumerate() { + let x = rect.left() + (bar_width + bar_spacing) * i as f32; + let bar_height = (chart_height - 20.0) * (usage / 100.0); + let y = rect.bottom() - bar_height - 20.0; + + let color = if *usage > 80.0 { + egui::Color32::RED + } else if *usage > 60.0 { + egui::Color32::YELLOW + } else { + egui::Color32::GREEN + }; + + painter.rect_filled( + egui::Rect::from_min_size( + egui::Pos2::new(x, y), + egui::Vec2::new(bar_width, bar_height), + ), + 0.0, + color, + ); + + painter.text( + egui::Pos2::new(x + bar_width / 2.0, rect.bottom() - 10.0), + egui::Align2::CENTER_CENTER, + i.to_string(), + egui::FontId::proportional(12.0), + egui::Color32::WHITE, + ); + } + + if !metrics.gpus.is_empty() { + ui.add_space(20.0); + ui.heading("GPU Information"); + ui.add_space(10.0); + + for gpu in &metrics.gpus { + ui.group(|ui| { + ui.label(format!("Name: {}", gpu.name)); + ui.add( + egui::ProgressBar::new(gpu.usage / 100.0) + .text(format!("{:.1}% usage", gpu.usage)), + ); + if gpu.memory_total > 0 { + ui.label(format!( + "VRAM: {:.1} / {:.1} GB", + gpu.memory_used as f64 / (1024.0 * 1024.0 * 1024.0), + gpu.memory_total as f64 / (1024.0 * 1024.0 * 1024.0) + )); + } + if let Some(temp) = gpu.temperature { + ui.label(format!("Temperature: {:.1}°C", temp)); + } + }); + } + } + } + + fn draw_processes(&mut self, ui: &mut egui::Ui) { + ui.heading("Processes"); + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label("Sort by:"); + if ui.selectable_label(self.sort_by_cpu, "CPU").clicked() { + self.sort_by_cpu = true; + } + if ui.selectable_label(!self.sort_by_cpu, "Memory").clicked() { + self.sort_by_cpu = false; + } + }); + + ui.add_space(10.0); + + let mut processes = self.processes.read().clone(); + + if self.sort_by_cpu { + processes.sort_by(|a, b| b.stats.cpu_usage.partial_cmp(&a.stats.cpu_usage).unwrap()); + } else { + processes.sort_by(|a, b| b.stats.memory_usage.cmp(&a.stats.memory_usage)); + } + + // Header + ui.horizontal(|ui| { + ui.label(egui::RichText::new("PID").strong().size(14.0)); + ui.add_space(20.0); + ui.label(egui::RichText::new("Name").strong().size(14.0)); + ui.add_space(120.0); + ui.label(egui::RichText::new("User").strong().size(14.0)); + ui.add_space(60.0); + ui.label(egui::RichText::new("CPU %").strong().size(14.0)); + ui.add_space(40.0); + ui.label(egui::RichText::new("Memory (MB)").strong().size(14.0)); + ui.add_space(40.0); + ui.label(egui::RichText::new("Disk I/O (MB)").strong().size(14.0)); + ui.add_space(40.0); + ui.label(egui::RichText::new("Status").strong().size(14.0)); + }); + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, process) in processes.iter().take(100).enumerate() { + let is_selected = self.selected_process == Some(i); + + // Create a single clickable row + let row_text = format!( + "{:<8} {:<20} {:<12} {:>6.1} {:>12.1} {:>12.1} {:?}", + process.info.pid, + if process.info.name.len() > 20 { + format!("{}...", &process.info.name[..17]) + } else { + process.info.name.clone() + }, + if process.info.user.len() > 12 { + format!("{}...", &process.info.user[..9]) + } else { + process.info.user.clone() + }, + process.stats.cpu_usage, + process.stats.memory_usage as f64 / (1024.0 * 1024.0), + (process.stats.disk_read_bytes + process.stats.disk_write_bytes) as f64 / (1024.0 * 1024.0), + process.info.status + ); + + let response = ui.selectable_label(is_selected, egui::RichText::new(row_text).monospace()); + + if response.clicked() { + self.selected_process = Some(i); + self.selected_process_pid = Some(process.info.pid); + } + + response.context_menu(|ui| { + self.selected_process_pid = Some(process.info.pid); + + if ui.button("Kill Process").clicked() { + self.kill_process(process.info.pid); + ui.close_menu(); + } + if ui.button("Kill Process Tree").clicked() { + self.kill_process_tree(process.info.pid); + ui.close_menu(); + } + if ui.button("Open Process Folder").clicked() { + if let Some(ref exe_path) = process.info.exe_path { + if let Some(parent) = exe_path.parent() { + let _ = std::process::Command::new("xdg-open") + .arg(parent) + .spawn(); + } + } + ui.close_menu(); + } + if ui.button("Restart Process").clicked() { + self.restart_process(process.info.pid, &process.info.exe_path, &process.info.command_line); + ui.close_menu(); + } + }); + } + }); + } + + fn draw_services_redesigned(&mut self, ui: &mut egui::Ui) { + ui.heading("Services"); + ui.add_space(10.0); + + let mut services = self.services.read().clone(); + services.sort_by(|a, b| a.name.cmp(&b.name)); + + // Header + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Name").strong().size(14.0)); + ui.add_space(150.0); + ui.label(egui::RichText::new("State").strong().size(14.0)); + ui.add_space(60.0); + ui.label(egui::RichText::new("Enabled").strong().size(14.0)); + ui.add_space(40.0); + ui.label(egui::RichText::new("PID").strong().size(14.0)); + ui.add_space(60.0); + ui.label(egui::RichText::new("Memory (MB)").strong().size(14.0)); + ui.add_space(40.0); + ui.label(egui::RichText::new("Description").strong().size(14.0)); + }); + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + for service in services.iter() { + // Determine state color + let state_color = match service.state { + ServiceState::Running => egui::Color32::GREEN, + ServiceState::Failed => egui::Color32::RED, + ServiceState::Stopped => egui::Color32::GRAY, + ServiceState::Unknown => egui::Color32::YELLOW, + }; + + let row_text = format!( + "{:<30} {:>10} {:>8} {:>10} {:>12} {}", + if service.name.len() > 30 { + format!("{}...", &service.name[..27]) + } else { + service.name.clone() + }, + service.sub_state, + if service.enabled { "Yes" } else { "No" }, + service.main_pid.map(|p| p.to_string()).unwrap_or_else(|| "-".to_string()), + service.memory_usage.map(|m| format!("{:.1}", m as f64 / (1024.0 * 1024.0))).unwrap_or_else(|| "-".to_string()), + if service.description.len() > 40 { + format!("{}...", &service.description[..37]) + } else { + service.description.clone() + } + ); + + let response = ui.horizontal(|ui| { + ui.colored_label(state_color, "●"); + ui.label(egui::RichText::new(row_text).monospace()) + }).response; + + response.context_menu(|ui| { + let service_name = service.name.clone(); + + if ui.button("Start").clicked() { + let sm = self.service_manager.read(); + match sm.start_service(&service_name) { + Ok(_) => self.status_message = format!("Started service: {}", service_name), + Err(e) => self.status_message = format!("Failed to start {}: {}", service_name, e), + } + ui.close_menu(); + } + + if ui.button("Stop").clicked() { + let sm = self.service_manager.read(); + match sm.stop_service(&service_name) { + Ok(_) => self.status_message = format!("Stopped service: {}", service_name), + Err(e) => self.status_message = format!("Failed to stop {}: {}", service_name, e), + } + ui.close_menu(); + } + + if ui.button("Restart").clicked() { + let sm = self.service_manager.read(); + match sm.restart_service(&service_name) { + Ok(_) => self.status_message = format!("Restarted service: {}", service_name), + Err(e) => self.status_message = format!("Failed to restart {}: {}", service_name, e), + } + ui.close_menu(); + } + + ui.separator(); + + if ui.button("Enable").clicked() { + let sm = self.service_manager.read(); + match sm.enable_service(&service_name) { + Ok(_) => self.status_message = format!("Enabled service: {}", service_name), + Err(e) => self.status_message = format!("Failed to enable {}: {}", service_name, e), + } + ui.close_menu(); + } + + if ui.button("Disable").clicked() { + let sm = self.service_manager.read(); + match sm.disable_service(&service_name) { + Ok(_) => self.status_message = format!("Disabled service: {}", service_name), + Err(e) => self.status_message = format!("Failed to disable {}: {}", service_name, e), + } + ui.close_menu(); + } + }); + } + }); + } + + fn kill_process(&mut self, pid: u32) { + let _ = std::process::Command::new("kill") + .arg(pid.to_string()) + .output(); + self.status_message = format!("Sent kill signal to PID {}", pid); + } + + fn kill_process_tree(&mut self, pid: u32) { + let _ = std::process::Command::new("kill") + .arg("-TERM") + .arg("--") + .arg(format!("-{}", pid)) + .output(); + self.status_message = format!("Sent kill signal to PID {} and children", pid); + } + + fn restart_process(&mut self, pid: u32, exe_path: &Option, cmd_line: &[String]) { + // Kill first + let _ = std::process::Command::new("kill") + .arg(pid.to_string()) + .output(); + + // Wait a bit + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Restart + if let Some(exe) = exe_path { + let mut command = std::process::Command::new(exe); + if cmd_line.len() > 1 { + command.args(&cmd_line[1..]); + } + let _ = command.spawn(); + self.status_message = format!("Restarted PID {}", pid); + } else { + self.status_message = format!("Cannot restart PID {}: no executable path", pid); + } + } + + fn draw_partitions(&mut self, ui: &mut egui::Ui) { + ui.heading("Partition Manager"); + ui.add_space(10.0); + + if !self.status_message.is_empty() { + ui.colored_label(egui::Color32::YELLOW, &self.status_message); + ui.add_space(10.0); + } + + ui.horizontal(|ui| { + if ui.button("Refresh Disks").clicked() { + let pm = self.partition_manager.read(); + if let Ok(disk_list) = pm.list_disks() { + *self.disks.write() = disk_list; + self.status_message = "Disks refreshed".to_string(); + } + } + + ui.label("(Requires root for full partition management)"); + }); + + ui.add_space(15.0); + + let disks = self.disks.read().clone(); + + if disks.is_empty() { + ui.label("No disks found. Try running with sudo."); + return; + } + + egui::ScrollArea::vertical().show(ui, |ui| { + for (disk_idx, disk) in disks.iter().enumerate() { + let is_disk_selected = self.selected_disk == Some(disk_idx); + + ui.group(|ui| { + ui.horizontal(|ui| { + let response = ui.selectable_label( + is_disk_selected, + format!("{} - {} ({:.2} GB)", + disk.device, + disk.model, + disk.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ) + ); + if response.clicked() { + self.selected_disk = Some(disk_idx); + self.selected_partition = None; + } + }); + + if is_disk_selected && !disk.partitions.is_empty() { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + egui::Grid::new(format!("partitions_{}", disk_idx)) + .num_columns(7) + .striped(true) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.strong("Device"); + ui.strong("Filesystem"); + ui.strong("Label"); + ui.strong("Size (GB)"); + ui.strong("Used (GB)"); + ui.strong("Mount Point"); + ui.strong("Actions"); + ui.end_row(); + + for (part_idx, partition) in disk.partitions.iter().enumerate() { + let is_selected = self.selected_partition == Some(part_idx); + + let response = ui.selectable_label(is_selected, &partition.device); + if response.clicked() { + self.selected_partition = Some(part_idx); + } + + ui.label(partition.filesystem.as_deref().unwrap_or("unknown")); + ui.label(partition.label.as_deref().unwrap_or("-")); + ui.label(format!("{:.2}", partition.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0))); + + let used_gb = partition.used_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + let used_percent = if partition.size_bytes > 0 { + (partition.used_bytes as f64 / partition.size_bytes as f64 * 100.0) + } else { + 0.0 + }; + ui.label(format!("{:.2} ({:.1}%)", used_gb, used_percent)); + + ui.label(partition.mount_point.as_deref().unwrap_or("-")); + + ui.horizontal(|ui| { + if ui.button("Format").clicked() { + self.show_format_dialog = true; + self.selected_disk = Some(disk_idx); + self.selected_partition = Some(part_idx); + } + + if ui.button("Delete").clicked() { + self.show_delete_confirm = true; + self.selected_disk = Some(disk_idx); + self.selected_partition = Some(part_idx); + } + + if partition.filesystem.is_some() && ui.button("Check").clicked() { + self.check_partition(disk_idx, part_idx); + } + }); + + ui.end_row(); + } + }); + } + }); + + ui.add_space(10.0); + } + }); + + // Format dialog + if self.show_format_dialog { + egui::Window::new("Format Partition") + .collapsible(false) + .resizable(false) + .show(ui.ctx(), |ui| { + ui.label("WARNING: This will erase all data on the partition!"); + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label("Filesystem:"); + egui::ComboBox::from_label("") + .selected_text(&self.format_filesystem) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.format_filesystem, "ext4".to_string(), "ext4"); + ui.selectable_value(&mut self.format_filesystem, "ext3".to_string(), "ext3"); + ui.selectable_value(&mut self.format_filesystem, "xfs".to_string(), "xfs"); + ui.selectable_value(&mut self.format_filesystem, "btrfs".to_string(), "btrfs"); + ui.selectable_value(&mut self.format_filesystem, "ntfs".to_string(), "ntfs"); + ui.selectable_value(&mut self.format_filesystem, "fat32".to_string(), "fat32"); + ui.selectable_value(&mut self.format_filesystem, "f2fs".to_string(), "f2fs"); + }); + }); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Format").clicked() { + self.format_partition(); + self.show_format_dialog = false; + } + if ui.button("Cancel").clicked() { + self.show_format_dialog = false; + } + }); + }); + } + + // Delete confirmation + if self.show_delete_confirm { + egui::Window::new("Delete Partition") + .collapsible(false) + .resizable(false) + .show(ui.ctx(), |ui| { + ui.label("Are you sure you want to delete this partition?"); + ui.label("This action cannot be undone!"); + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Delete").clicked() { + self.delete_partition(); + self.show_delete_confirm = false; + } + if ui.button("Cancel").clicked() { + self.show_delete_confirm = false; + } + }); + }); + } + } + + fn format_partition(&mut self) { + if let (Some(disk_idx), Some(part_idx)) = (self.selected_disk, self.selected_partition) { + let disks = self.disks.read(); + if let Some(disk) = disks.get(disk_idx) { + if let Some(partition) = disk.partitions.get(part_idx) { + let pm = self.partition_manager.read(); + match pm.format_partition(&partition.device, &self.format_filesystem, None) { + Ok(_) => { + self.status_message = format!( + "Successfully formatted {} as {}", + partition.device, self.format_filesystem + ); + } + Err(e) => { + self.status_message = format!("Format failed: {}", e); + } + } + } + } + } + } + + fn delete_partition(&mut self) { + if let (Some(disk_idx), Some(part_idx)) = (self.selected_disk, self.selected_partition) { + let disks = self.disks.read(); + if let Some(disk) = disks.get(disk_idx) { + if let Some(partition) = disk.partitions.get(part_idx) { + if let Some(part_num) = partition.partition_number { + let pm = self.partition_manager.read(); + match pm.delete_partition(&disk.device, part_num) { + Ok(_) => { + self.status_message = format!("Deleted partition {}", partition.device); + } + Err(e) => { + self.status_message = format!("Delete failed: {}", e); + } + } + } + } + } + } + } + + fn check_partition(&mut self, disk_idx: usize, part_idx: usize) { + let disks = self.disks.read(); + if let Some(disk) = disks.get(disk_idx) { + if let Some(partition) = disk.partitions.get(part_idx) { + if let Some(ref fs) = partition.filesystem { + let pm = self.partition_manager.read(); + match pm.check_filesystem(&partition.device, fs, false) { + Ok(_) => { + self.status_message = format!("Filesystem check completed for {}", partition.device); + } + Err(e) => { + self.status_message = format!("Check failed: {}", e); + } + } + } + } + } + } + + fn draw_storage(&mut self, ui: &mut egui::Ui) { + let metrics = self.system_metrics.read(); + let processes = self.processes.read().clone(); + + ui.heading("Storage & Disk I/O"); + ui.add_space(10.0); + + // Disk I/O graphs + ui.heading("Disk Usage"); + ui.add_space(10.0); + + egui::ScrollArea::vertical().show(ui, |ui| { + for (name, disk_metrics) in &metrics.disk_io { + if name.starts_with("loop") || name.starts_with("ram") { + continue; + } + + ui.group(|ui| { + ui.strong(name); + ui.add_space(5.0); + + // Read/Write bars + ui.horizontal(|ui| { + ui.label("Read:"); + let read_mb = disk_metrics.read_bytes as f64 / (1024.0 * 1024.0); + ui.add(egui::ProgressBar::new(((read_mb / 10000.0).min(1.0)) as f32) + .text(format!("{:.2} MB ({} ops)", read_mb, disk_metrics.read_ops))); + }); + + ui.horizontal(|ui| { + ui.label("Write:"); + let write_mb = disk_metrics.write_bytes as f64 / (1024.0 * 1024.0); + ui.add(egui::ProgressBar::new(((write_mb / 10000.0).min(1.0)) as f32) + .text(format!("{:.2} MB ({} ops)", write_mb, disk_metrics.write_ops))); + }); + }); + ui.add_space(10.0); + } + + ui.add_space(20.0); + ui.separator(); + ui.add_space(20.0); + + // Process list sorted by disk I/O + ui.heading("Top Processes by Disk I/O"); + ui.add_space(10.0); + + let mut sorted_processes = processes.clone(); + sorted_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) + }); + + egui::Grid::new("disk_io_processes") + .num_columns(5) + .striped(true) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.strong("PID"); + ui.strong("Name"); + ui.strong("User"); + ui.strong("Read (MB)"); + ui.strong("Write (MB)"); + ui.end_row(); + + for process in sorted_processes.iter().take(20) { + let read_mb = process.stats.disk_read_bytes as f64 / (1024.0 * 1024.0); + let write_mb = process.stats.disk_write_bytes as f64 / (1024.0 * 1024.0); + + // Only show processes with significant disk I/O + if read_mb < 0.01 && write_mb < 0.01 { + continue; + } + + ui.label(process.info.pid.to_string()); + ui.label(&process.info.name); + ui.label(&process.info.user); + ui.label(format!("{:.2}", read_mb)); + ui.label(format!("{:.2}", write_mb)); + ui.end_row(); + } + }); + }); + } + + fn draw_network_redesigned(&mut self, ui: &mut egui::Ui) { + let metrics = self.system_metrics.read(); + let processes = self.processes.read().clone(); + + ui.heading("Network Interfaces & Usage"); + ui.add_space(10.0); + + // Network interface statistics + ui.heading("Network Interfaces"); + ui.add_space(10.0); + + egui::ScrollArea::vertical().show(ui, |ui| { + for (name, net_metrics) in &metrics.network { + ui.group(|ui| { + ui.strong(name); + ui.add_space(5.0); + + // Received/Sent bars + ui.horizontal(|ui| { + ui.label("Received:"); + let recv_mb = net_metrics.bytes_received as f64 / (1024.0 * 1024.0); + ui.add(egui::ProgressBar::new(((recv_mb / 10000.0).min(1.0)) as f32) + .text(format!("{:.2} MB ({} packets)", recv_mb, net_metrics.packets_received))); + }); + + ui.horizontal(|ui| { + ui.label("Sent:"); + let sent_mb = net_metrics.bytes_sent as f64 / (1024.0 * 1024.0); + ui.add(egui::ProgressBar::new(((sent_mb / 10000.0).min(1.0)) as f32) + .text(format!("{:.2} MB ({} packets)", sent_mb, net_metrics.packets_sent))); + }); + + if net_metrics.errors_in > 0 || net_metrics.errors_out > 0 { + ui.colored_label( + egui::Color32::RED, + format!("Errors: In={} Out={}", net_metrics.errors_in, net_metrics.errors_out) + ); + } + }); + ui.add_space(10.0); + } + + ui.add_space(20.0); + ui.separator(); + ui.add_space(20.0); + + // Process list sorted by network usage + ui.heading("Top Processes by Network Usage"); + ui.add_space(10.0); + + let mut sorted_processes = processes.clone(); + sorted_processes.sort_by(|a, b| { + let a_net = a.stats.network_rx_bytes + a.stats.network_tx_bytes; + let b_net = b.stats.network_rx_bytes + b.stats.network_tx_bytes; + b_net.cmp(&a_net) + }); + + egui::Grid::new("network_processes") + .num_columns(5) + .striped(true) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.strong("PID"); + ui.strong("Name"); + ui.strong("User"); + ui.strong("RX (MB)"); + ui.strong("TX (MB)"); + ui.end_row(); + + for process in sorted_processes.iter().take(20) { + let rx_mb = process.stats.network_rx_bytes as f64 / (1024.0 * 1024.0); + let tx_mb = process.stats.network_tx_bytes as f64 / (1024.0 * 1024.0); + + // Only show processes with network activity + if rx_mb < 0.01 && tx_mb < 0.01 { + continue; + } + + ui.label(process.info.pid.to_string()); + ui.label(&process.info.name); + ui.label(&process.info.user); + ui.label(format!("{:.2}", rx_mb)); + ui.label(format!("{:.2}", tx_mb)); + ui.end_row(); + } + }); + }); + } + + fn draw_alerts(&mut self, ui: &mut egui::Ui) { + let alerts = self.alerts.read(); + + ui.heading(format!("Alerts ({})", alerts.len())); + ui.add_space(10.0); + + egui::ScrollArea::vertical().show(ui, |ui| { + for alert in alerts.iter().rev().take(50) { + let color = match alert.severity { + Severity::Critical => egui::Color32::RED, + Severity::Warning => egui::Color32::YELLOW, + Severity::Info => egui::Color32::LIGHT_BLUE, + }; + + ui.group(|ui| { + ui.horizontal(|ui| { + ui.colored_label(color, format!("[{:?}]", alert.severity)); + ui.label(format!( + "{} - {} (PID: {})", + alert.timestamp.format("%H:%M:%S"), + alert.process_name, + alert.pid + )); + }); + ui.label(format!("{}: {}", alert.rule_name, alert.details)); + }); + ui.add_space(5.0); + } + }); + } +} + +impl eframe::App for ProcessMonitorApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + ctx.request_repaint(); + + egui::TopBottomPanel::top("tabs").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.selectable_value(&mut self.selected_tab, 0, "Dashboard"); + ui.selectable_value(&mut self.selected_tab, 1, "Processes"); + ui.selectable_value(&mut self.selected_tab, 2, "Services"); + ui.selectable_value(&mut self.selected_tab, 3, "Storage"); + ui.selectable_value(&mut self.selected_tab, 4, "Network"); + ui.selectable_value(&mut self.selected_tab, 5, "Partitions"); + ui.selectable_value(&mut self.selected_tab, 6, "Alerts"); + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + match self.selected_tab { + 0 => self.draw_dashboard(ui), + 1 => self.draw_processes(ui), + 2 => self.draw_services_redesigned(ui), + 3 => self.draw_storage(ui), + 4 => self.draw_network_redesigned(ui), + 5 => self.draw_partitions(ui), + 6 => self.draw_alerts(ui), + _ => {} + } + }); + } +} diff --git a/procmon-tui/Cargo.toml b/procmon-tui/Cargo.toml new file mode 100644 index 0000000..9baf733 --- /dev/null +++ b/procmon-tui/Cargo.toml @@ -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 diff --git a/procmon-tui/src/app.rs b/procmon-tui/src/app.rs new file mode 100644 index 0000000..9a2c1a6 --- /dev/null +++ b/procmon-tui/src/app.rs @@ -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, + pub filtered_processes: Vec, + pub services: Vec, + pub filtered_services: Vec, + pub disks: Vec, + pub alerts: Vec, + 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, + pub context_menu_service: Option, + pub status_message: Option, + 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, + last_click_row: Option, +} + +impl App { + pub async fn new() -> Result { + 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 = 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(()) + } +} diff --git a/procmon-tui/src/main.rs b/procmon-tui/src/main.rs new file mode 100644 index 0000000..eeb4821 --- /dev/null +++ b/procmon-tui/src/main.rs @@ -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( + terminal: &mut Terminal, + 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?; + } +} diff --git a/procmon-tui/src/ui.rs b/procmon-tui/src/ui.rs new file mode 100644 index 0000000..313f817 --- /dev/null +++ b/procmon-tui/src/ui.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 + } +}