first commit

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

50
Cargo.toml Normal file
View File

@@ -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 <your.email@example.com>"]
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"

21
LICENSE Normal file
View File

@@ -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.

188
QUICKSTART.md Normal file
View File

@@ -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!

248
README.md Normal file
View File

@@ -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.

52
build.sh Normal file
View File

@@ -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"

1
package.json Normal file
View File

@@ -0,0 +1 @@
{}

23
procmon-core/Cargo.toml Normal file
View File

@@ -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"] }

View File

@@ -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<chrono::Utc>,
pub details: String,
}
pub struct MisbehaviorDetector {
rules: Vec<MisbehaviorRule>,
violation_history: HashMap<u32, Vec<ViolationRecord>>,
}
#[derive(Debug, Clone)]
struct ViolationRecord {
rule_name: String,
timestamp: chrono::DateTime<chrono::Utc>,
}
impl MisbehaviorDetector {
pub fn new() -> Self {
Self {
rules: Self::default_rules(),
violation_history: HashMap::new(),
}
}
pub fn with_rules(rules: Vec<MisbehaviorRule>) -> Self {
Self {
rules,
violation_history: HashMap::new(),
}
}
fn default_rules() -> Vec<MisbehaviorRule> {
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<MisbehaviorAlert> {
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()
}
}

View File

@@ -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<chrono::Utc>,
pub details: String,
}
pub struct MisbehaviorDetector {
rules: Vec<MisbehaviorRule>,
violation_history: HashMap<u32, Vec<ViolationRecord>>,
}
#[derive(Debug, Clone)]
struct ViolationRecord {
rule_name: String,
timestamp: chrono::DateTime<chrono::Utc>,
}
impl MisbehaviorDetector {
pub fn new() -> Self {
Self {
rules: Self::default_rules(),
violation_history: HashMap::new(),
}
}
pub fn with_rules(rules: Vec<MisbehaviorRule>) -> Self {
Self {
rules,
violation_history: HashMap::new(),
}
}
fn default_rules() -> Vec<MisbehaviorRule> {
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<MisbehaviorAlert> {
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()
}
}

16
procmon-core/src/lib.rs Normal file
View File

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

105
procmon-core/src/metrics.rs Normal file
View File

@@ -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<f32>,
pub temperature: Option<f32>,
pub frequency: Option<u64>,
}
#[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<f32>,
}
#[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<chrono::Utc>,
pub cpu: CpuMetrics,
pub memory: MemoryMetrics,
pub gpus: Vec<GpuMetrics>,
pub network: HashMap<String, NetworkMetrics>,
pub disk_io: HashMap<String, DiskIoMetrics>,
pub usb_io: Vec<UsbIoMetrics>,
}
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(),
}
}
}

428
procmon-core/src/monitor.rs Normal file
View File

@@ -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<RwLock<System>>,
networks: Arc<RwLock<Networks>>,
disks: Arc<RwLock<Disks>>,
previous_disk_stats: Arc<RwLock<HashMap<String, (u64, u64)>>>,
previous_net_stats: Arc<RwLock<HashMap<String, (u64, u64)>>>,
}
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<SystemMetrics> {
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<CpuMetrics> {
let cpus = system.cpus();
let total_usage = system.global_cpu_usage();
let per_core_usage: Vec<f32> = 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<MemoryMetrics> {
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<Vec<GpuMetrics>> {
// 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<GpuMetrics> {
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::<f32>().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::<u64>().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::<u64>().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::<f32>().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<HashMap<String, NetworkMetrics>> {
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<HashMap<String, DiskIoMetrics>> {
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::<u64>().unwrap_or(0);
let read_sectors = parts[5].parse::<u64>().unwrap_or(0);
let write_ops = parts[7].parse::<u64>().unwrap_or(0);
let write_sectors = parts[9].parse::<u64>().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<Vec<UsbIoMetrics>> {
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<f32> {
// 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::<f32>() {
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::<f32>() {
return Some(temp / 1000.0);
}
}
}
}
None
}
pub fn get_all_processes(&self) -> Result<Vec<ProcessSnapshot>> {
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::<u32>() {
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<Option<ProcessSnapshot>> {
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<ProcessSnapshot> {
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::<u32>() {
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::<u32>() {
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()
}
}

View File

@@ -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<u32>,
pub filesystem: Option<String>,
pub label: Option<String>,
pub size_bytes: u64,
pub used_bytes: u64,
pub mount_point: Option<String>,
pub partition_type: Option<String>,
pub flags: Vec<String>,
}
#[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<Partition>,
}
pub struct PartitionManager {
}
impl PartitionManager {
pub fn new() -> Self {
Self {}
}
/// List all block devices and their partitions
pub fn list_disks(&self) -> Result<Vec<Disk>> {
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::<serde_json::Value>(&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<Disk> {
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::<u64>().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<Partition> {
let name = part["name"].as_str()?;
let size_bytes = part["size"].as_str()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
// Extract partition number
let partition_number = name.trim_start_matches(parent_device)
.trim_start_matches('p')
.parse::<u32>().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::<u32>().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::<u32>().ok())
.unwrap_or(512);
(logical, physical)
}
fn get_partition_info(&self, device: &str) -> (Option<String>, Vec<String>) {
// 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<u64> {
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::<u64>().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<String> {
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<String> {
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()
}
}

View File

@@ -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<PathBuf>,
pub command_line: Vec<String>,
pub status: ProcessStatus,
pub parent_pid: Option<u32>,
}
#[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<chrono::Utc>,
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<chrono::Utc>,
}

227
procmon-core/src/service.rs Normal file
View File

@@ -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<u64>,
pub cpu_usage: Option<f32>,
pub main_pid: Option<u32>,
}
#[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<Vec<SystemService>> {
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<u32>, Option<u64>, Option<f32>)> {
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::<u32>() {
if pid > 0 {
main_pid = Some(pid);
}
}
} else if let Some(value) = line.strip_prefix("MemoryCurrent=") {
if let Ok(mem) = value.parse::<u64>() {
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<bool> {
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<String> {
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()
}
}

95
procmon-core/src/tests.rs Normal file
View File

@@ -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<u32> = 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::<u32>() {
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/<pid> exists, verify it
if std::path::Path::new(&proc_path).exists() {
println!("Verified PID {} exists: {}", pid, process.info.name);
}
}
}
}

23
procmon-gui/Cargo.toml Normal file
View File

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

1009
procmon-gui/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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