first commit
This commit is contained in:
50
Cargo.toml
Normal file
50
Cargo.toml
Normal 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
21
LICENSE
Normal 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
188
QUICKSTART.md
Normal 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
248
README.md
Normal 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
52
build.sh
Normal 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
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
23
procmon-core/Cargo.toml
Normal file
23
procmon-core/Cargo.toml
Normal 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"] }
|
||||||
299
procmon-core/src/detector.rs
Normal file
299
procmon-core/src/detector.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
299
procmon-core/src/detectors.rs
Normal file
299
procmon-core/src/detectors.rs
Normal 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
16
procmon-core/src/lib.rs
Normal 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
105
procmon-core/src/metrics.rs
Normal 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
428
procmon-core/src/monitor.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
437
procmon-core/src/partition.rs
Normal file
437
procmon-core/src/partition.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
86
procmon-core/src/process.rs
Normal file
86
procmon-core/src/process.rs
Normal 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
227
procmon-core/src/service.rs
Normal 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
95
procmon-core/src/tests.rs
Normal 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
23
procmon-gui/Cargo.toml
Normal 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
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
21
procmon-tui/Cargo.toml
Normal 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
764
procmon-tui/src/app.rs
Normal 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
212
procmon-tui/src/main.rs
Normal 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
769
procmon-tui/src/ui.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user