Compare commits

..

10 Commits

Author SHA1 Message Date
jason 09cfafb8d4 only show parent level processes on main tui
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2026-06-01 10:55:29 -07:00
jason 0ec428d86f fix windows build 2026-05-31 01:46:18 -07:00
jason f89d2f376d bump crossterm and optimize various types, remove stale code. 2026-05-31 01:34:18 -07:00
jasonwitty e0fe239bac style: cargo fmt 2026-05-17 05:32:46 -07:00
jasonwitty 6138ebd43d fix: collapse nested if into match guard 2026-05-17 05:26:43 -07:00
jasonwitty 7999bdef76 fix: replace manual zero-guarded divisions with checked_div 2026-05-17 05:22:56 -07:00
jasonwitty f7fbd648cb style: cargo fmt 2026-05-17 05:13:09 -07:00
jasonwitty 552d2c1375 chore: update ratatui from 0.28 to 0.30 2026-05-17 05:10:55 -07:00
jason 3024816525 hotfix for issue with socktop agent not creating ssl certificate on first launch after upgrade of axum server version.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-21 00:21:05 -08:00
jason 1d7bc42d59 fix unit test, move to macro cargo_bin! 2025-11-21 00:07:44 -08:00
31 changed files with 2099 additions and 2486 deletions
Generated
+870 -202
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -24,8 +24,8 @@ serde_json = "1.0"
sysinfo = "0.37"
# CLI UI
ratatui = "0.28"
crossterm = "0.27"
ratatui = "0.30"
crossterm = "0.29"
# web server (remote-agent)
axum = { version = "0.7", features = ["ws"] }
-121
View File
@@ -1,121 +0,0 @@
# Process Details Race Condition Fix
## Problem
The `collect_process_metrics()` function was calling:
```rust
system.refresh_processes_specifics(ProcessesToUpdate::All, ...)
```
This caused several issues:
1. **Race Condition**: Refreshing ALL processes invalidated CPU baselines for main metrics collection
2. **Thread Pollution**: Main process list included threads (not desired in main UI)
3. **CPU Waste**: Refreshing ~500-1000+ processes when we only need 1
4. **Memory Waste**: Storing thread data unnecessarily
## Solution: Lightweight Child Process Enumeration
### Key Changes
#### 1. Targeted Process Refresh
```rust
// OLD: Refreshed ALL processes (expensive, causes race condition)
system.refresh_processes_specifics(ProcessesToUpdate::All, ...)
// NEW: Only refresh the specific process we care about
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]),
...
)
```
#### 2. Direct /proc Access for Children (Linux)
Instead of iterating through all sysinfo processes, we now:
- Scan `/proc/` directory directly
- Read `/proc/{pid}/stat` to check parent PID
- Extract process details from `/proc/{pid}/` files
- Fall back to sysinfo for non-Linux platforms
### Performance Impact
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| CPU per request | ~15-20ms | ~1-3ms | **~85% reduction** |
| Processes refreshed | All (~500-1000+) | 1 | **99.9% reduction** |
| Memory overhead | All processes + threads | Single process | **~95% reduction** |
| Race condition risk | High | None | **100% eliminated** |
### Implementation Details
#### Linux Implementation
**`enumerate_child_processes_lightweight()`**
- Scans `/proc/` directory for child processes
- Uses `read_parent_pid_from_proc()` to filter by parent
- Calls `collect_process_info_from_proc()` to extract details
- Reads from:
- `/proc/{pid}/stat` - Process state, parent PID, start time
- `/proc/{pid}/status` - UID, GID, threads, memory, state
- `/proc/{pid}/cmdline` - Command line
- `/proc/{pid}/io` - I/O statistics (if available)
- `/proc/{pid}/cwd` - Working directory (symlink)
- `/proc/{pid}/exe` - Executable path (symlink)
#### Non-Linux Fallback
- Uses sysinfo's process iteration (less efficient but functional)
- Maintains cross-platform compatibility
- Same API, just different implementation
### Testing Instructions
1. **Start the agent:**
```bash
cargo run --bin socktop_agent --release -- --port 8123
```
2. **Connect with the client:**
```bash
cargo run --bin socktop --release -- ws://localhost:8123/ws
```
3. **Test process details:**
- Navigate to a process with the arrow keys
- Press Enter to open process details modal
- Verify child processes are shown correctly
- Check that the main UI still shows only top-level processes (no threads)
4. **Verify no race condition:**
- Open process details modal
- Watch main UI CPU percentages
- They should remain stable and accurate
- No sudden spikes or drops in CPU percentages
### Code Locations
- **Main fix:** `socktop_agent/src/metrics.rs`
- `collect_process_metrics()` - Modified to use targeted refresh
- `enumerate_child_processes_lightweight()` - New function for Linux
- `read_parent_pid_from_proc()` - Helper to read parent PID
- `collect_process_info_from_proc()` - Helper to read process details
### Benefits
1. **Lightweight**: Minimal CPU and memory usage
2. **No Race Conditions**: Doesn't interfere with main metrics collection
3. **Clean Separation**: Main UI never sees threads
4. **Cross-Platform**: Works on Linux (optimized) and other platforms (fallback)
5. **Maintainable**: Clear, well-documented code
### Future Enhancements
Potential optimizations if needed:
- Cache `/proc` file descriptors for frequently accessed processes
- Batch read multiple `/proc` files in parallel
- Add support for thread enumeration (currently not needed)
## Verification
✅ Compiles without errors
✅ No race conditions
✅ Child processes correctly enumerated
✅ Main UI remains clean (no threads)
✅ Significantly reduced CPU usage
✅ Cross-platform compatible
-150
View File
@@ -1,150 +0,0 @@
# Thread Support Implementation
## Overview
Added per-thread CPU metrics collection and visualization to the process details modal. Threads and child processes are now clearly distinguished in both the scatter plot and the table view.
## Changes Made
### 1. Data Structures
#### `socktop_connector/src/types.rs` & `socktop_agent/src/types.rs`
- **New:** `ThreadInfo` struct
- `tid: u32` - Thread ID
- `name: String` - Thread name from `/proc/{pid}/task/{tid}/comm`
- `cpu_time_user: u64` - User CPU time in microseconds
- `cpu_time_system: u64` - System CPU time in microseconds
- `status: String` - Thread status (Running, Sleeping, etc.)
- **Updated:** `DetailedProcessInfo` struct
- Added `threads: Vec<ThreadInfo>` field
### 2. Agent - Thread Collection
#### `socktop_agent/src/metrics.rs`
**New Function: `collect_thread_info(pid: u32)` (Linux only)**
- Reads `/proc/{pid}/task/` directory to enumerate all threads
- For each thread:
- Reads thread name from `/proc/{pid}/task/{tid}/comm`
- Parses `/proc/{pid}/task/{tid}/stat` for CPU times and status
- Converts clock ticks (100 Hz) to microseconds: `ticks * 10,000`
- Extracts utime (field 13) and stime (field 14) from stat file
**Updated: `collect_process_metrics()`**
- Calls `collect_thread_info(pid)` to collect thread data
- Includes threads in the `DetailedProcessInfo` response
**Updated: `collect_process_info_from_proc()`**
- Added `threads: Vec::new()` to child process info (not collected recursively)
**Updated: `enumerate_child_processes_lightweight()` (non-Linux)**
- Added `threads: Vec::new()` for cross-platform compatibility
### 3. Client - UI Visualization
#### `socktop/src/ui/modal.rs`
**Updated: `render_cpu_scatter_plot()`**
- Title changed to "Thread & Process CPU Time Distribution"
- Includes threads in max value scaling calculation
- Plots threads with hollow circle marker `○`
- Plots child processes with filled circle marker `•`
- Uses different markers for overlapping items:
- `○` - Single thread
- `◎` - Multiple threads at same point
- `•` - Single child process
- `◉` - Multiple items (threads/processes) at same point
**Updated: Legend**
- Now shows: `● Main Process ○ Thread • Child Process ◉ Multiple`
**Updated: `render_thread_table()`**
- Title now shows counts: `"Threads (N) & Children (M)"`
- Table format:
```
Type TID/PID Name/Status
─────────────────────────────
[T] 12345 thread-name
[P] 12346 child-process
```
- `[T]` prefix in cyan for threads
- `[P]` prefix in green for child processes
- Displays up to 10 items total
- Threads listed first, then child processes
## Platform Support
### Linux
- **Full support** for per-thread metrics
- Reads directly from `/proc/{pid}/task/*/` for efficiency
- No additional dependencies required
### Non-Linux
- Returns empty thread list
- Falls back gracefully
- Child process enumeration still works via sysinfo
## Performance
- **Thread enumeration**: ~0.5-2ms for typical processes
- **No additional locks**: Thread data collected outside sysinfo mutex
- **Minimal overhead**: Only collected when process details modal is open
- **No race conditions**: Doesn't interfere with main metrics collection
## Use Cases
Perfect for visualizing:
- Multi-threaded applications (web servers, databases, compilers)
- Thread pool behavior
- Worker thread distribution
- Identifying busy vs idle threads
- Comparing thread CPU usage patterns
## Example Output
For a process with 8 threads and 2 child processes:
**Scatter Plot:**
- Main process shown as ``
- 8 threads shown as `` distributed based on their CPU times
- 2 child processes shown as ``
- X-axis: User CPU time
- Y-axis: System CPU time
**Table:**
```
Threads (8) & Children (2)
Type TID/PID Name/Status
─────────────────────────────
[T] 12345 web-worker-1
[T] 12346 web-worker-2
[T] 12347 io-handler
...
[P] 12355 nginx: cache
[P] 12356 nginx: worker
```
## Testing
Test with multi-threaded applications:
```bash
# Terminal 1: Start agent
cargo run --release --bin socktop_agent -- --port 3000
# Terminal 2: Start client
cargo run --release --bin socktop -- ws://localhost:3000/ws
# Navigate to a multi-threaded process (e.g., Firefox, Chrome, Node.js)
# Press Enter to open process details
# Scatter plot will show thread distribution
# Table will show threads marked with [T] and children with [P]
```
## Future Enhancements
Potential improvements:
- Per-thread memory usage (requires parsing `/proc/{pid}/task/{tid}/statm`)
- Thread-level I/O statistics
- Thread CPU percentage (requires delta calculation with caching)
- Sorting threads by CPU time in the table
- Thread state filtering (show only running/sleeping threads)
-320
View File
@@ -1,320 +0,0 @@
# Auto-Generated Man Pages
This document explains how man pages are automatically generated from the CLI definitions using `clap` and `clap_mangen`.
## Overview
Starting from version 1.50.0+, socktop uses **clap** for CLI parsing and **clap_mangen** to automatically generate man pages at build time. This approach has several advantages:
✅ Man pages are always in sync with the actual CLI
✅ Single source of truth (CLI definitions)
✅ No manual maintenance of separate man page files
✅ Generated during `cargo build` automatically
✅ Can be installed alongside binaries
## How It Works
### 1. CLI Definitions
Both `socktop` and `socktop_agent` use clap's derive macros to define their CLI:
- **`socktop/src/cli.rs`** - Client CLI definition
- **`socktop_agent/src/cli.rs`** - Agent CLI definition
These files use clap's attributes to specify:
- Arguments and options
- Help text and descriptions
- Value names and types
- Environment variable support
- Hidden options (for testing)
### 2. Build-Time Generation
Each crate has a `build.rs` script that:
1. Includes the CLI definition file
2. Uses `clap_mangen` to generate the man page
3. Saves it to `$OUT_DIR/man/*.1`
The generation happens automatically during:
```bash
cargo build
cargo build --release
cargo install
```
### 3. Generated Man Pages Location
After building, man pages are located at:
```
target/debug/build/socktop-*/out/man/socktop.1
target/debug/build/socktop_agent-*/out/man/socktop_agent.1
# Or for release builds:
target/release/build/socktop-*/out/man/socktop.1
target/release/build/socktop_agent-*/out/man/socktop_agent.1
```
## Installation Options
### Option 1: Use the Installation Script (Recommended)
The `scripts/install-with-man.sh` script builds the binaries, extracts the generated man pages, and installs everything:
```bash
# User installation (no sudo)
./scripts/install-with-man.sh
# System-wide installation (requires sudo)
sudo ./scripts/install-with-man.sh --system
# Only install man pages (after building)
./scripts/install-with-man.sh --man-only
```
This script:
- Builds the project in release mode
- Extracts generated man pages from `OUT_DIR`
- Installs binaries to `~/.cargo/bin` or `/usr/local/bin`
- Installs man pages to `~/.local/share/man/man1` or `/usr/local/share/man/man1`
### Option 2: Manual Installation After Build
```bash
# Build the project
cargo build --release
# Find generated man pages
SOCKTOP_MAN=$(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
AGENT_MAN=$(find target/release/build/socktop_agent-*/out/man/socktop_agent.1 | head -1)
# Install to user directory
mkdir -p ~/.local/share/man/man1
cp "$SOCKTOP_MAN" ~/.local/share/man/man1/
cp "$AGENT_MAN" ~/.local/share/man/man1/
# Or install system-wide
sudo mkdir -p /usr/local/share/man/man1
sudo cp "$SOCKTOP_MAN" /usr/local/share/man/man1/
sudo cp "$AGENT_MAN" /usr/local/share/man/man1/
sudo mandb # Update man database
```
### Option 3: View Without Installing
You can view the generated man pages directly:
```bash
# After building
man -l $(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
man -l $(find target/release/build/socktop_agent-*/out/man/socktop_agent.1 | head -1)
```
## Viewing Installed Man Pages
After installation:
```bash
man socktop
man socktop_agent
```
If `man socktop` doesn't work after user installation, add to your shell rc:
```bash
# For bash
echo 'export MANPATH="$HOME/.local/share/man:$MANPATH"' >> ~/.bashrc
source ~/.bashrc
# For zsh
echo 'export MANPATH="$HOME/.local/share/man:$MANPATH"' >> ~/.zshrc
source ~/.zshrc
```
## Updating CLI and Man Pages
When you need to update the CLI or man pages:
1. **Edit the CLI definition** in `src/cli.rs`:
```rust
/// Your new option description
#[arg(short = 'x', long = "example")]
pub example: bool,
```
2. **Rebuild** to regenerate man pages:
```bash
cargo build --release
```
3. **Reinstall** man pages:
```bash
./scripts/install-with-man.sh --man-only
```
The man pages will automatically reflect your changes!
## CLI Definition Format
### Basic Structure
```rust
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "myapp",
version,
author,
about = "Short description",
long_about = "Longer description that appears in man page and --help"
)]
pub struct Cli {
/// Short description of this option
///
/// Longer description that appears in the man page.
/// Can span multiple lines.
#[arg(short = 't', long = "thing", value_name = "VALUE")]
pub thing: Option<String>,
/// Boolean flag
#[arg(long)]
pub flag: bool,
/// Hidden option (won't appear in man page or --help)
#[arg(long, hide = true)]
pub secret: bool,
}
```
### Environment Variable Support
```rust
/// Port to listen on
///
/// Can also be set via MYAPP_PORT environment variable.
#[arg(short = 'p', long = "port", env = "MYAPP_PORT")]
pub port: Option<u16>,
```
### Value Parsing
```rust
/// Custom parser
#[arg(long, value_parser = parse_custom)]
pub custom: Option<String>,
fn parse_custom(s: &str) -> Result<String, String> {
// Custom validation logic
Ok(s.to_string())
}
```
## Advantages Over Manual Man Pages
| Feature | Auto-Generated | Manual |
|---------|---------------|--------|
| Always in sync with CLI | ✅ Yes | ❌ Manual updates required |
| Single source of truth | ✅ Yes | ❌ Duplicated info |
| Maintenance effort | ✅ Low | ❌ High |
| Consistency | ✅ Guaranteed | ❌ Can drift |
| Generated at build time | ✅ Yes | ❌ Separate process |
| Works with `--help` | ✅ Same source | ❌ Separate |
| Rich formatting | ⚠️ Good | ✅ Full control |
## Comparison with Manual Man Pages
The project also includes manually written man pages in `docs/man/` for comparison and as templates. These are more detailed and include additional sections like:
- EXAMPLES with complex scenarios
- SECURITY CONSIDERATIONS
- PLATFORM NOTES
- Systemd integration guides
- Troubleshooting tips
The auto-generated man pages from clap are excellent for:
- Options and arguments
- Basic descriptions
- Version and author info
- Environment variables
But may be limited for:
- Complex examples
- Extensive narrative documentation
- Custom formatting
- Additional reference sections
## Best Practices
1. **Write good doc comments** in `cli.rs` - they become man page content
2. **Use `long_about`** for detailed descriptions
3. **Specify `value_name`** for clarity (e.g., `<PORT>`, `<URL>`)
4. **Document environment variables** in the option description
5. **Use `hide = true`** for internal/test options
6. **Keep descriptions concise** but informative
7. **Rebuild after CLI changes** to update man pages
## Testing Man Page Generation
```bash
# Clean build to ensure regeneration
cargo clean
# Build and check for man page warning
cargo build --release 2>&1 | grep "Man page generated"
# View the generated man page
man -l $(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
# Check for errors
lexgrog $(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
```
## Troubleshooting
### Man page not generated
**Solution:** Check that `build.rs` ran successfully:
```bash
cargo clean
cargo build -vv 2>&1 | grep build.rs
```
### Can't find generated man page
**Solution:** Look in the correct build output:
```bash
find target -name "socktop.1" -type f
```
### Man page content is outdated
**Solution:** Clean and rebuild:
```bash
cargo clean
cargo build --release
```
### MANPATH not working
**Solution:** Verify the path is correct:
```bash
echo $MANPATH
man -w # Show current man paths
```
## Future Enhancements
Potential improvements:
- [ ] Add more detailed examples section using clap's `after_help`
- [ ] Generate shell completions alongside man pages
- [ ] Create a custom man page template with additional sections
- [ ] Package man pages in release artifacts
- [ ] Auto-install man pages during `cargo install`
## See Also
- [clap documentation](https://docs.rs/clap/)
- [clap_mangen documentation](https://docs.rs/clap_mangen/)
- [Manual man pages](man/README.md) - The original manually written versions
- [Quick Reference](QUICK_REFERENCE.md) - Command cheat sheet
-380
View File
@@ -1,380 +0,0 @@
# Migration to Clap for Auto-Generated Man Pages
## Summary
Socktop has been migrated from manual argument parsing to **clap** (Command Line Argument Parser) with automatic man page generation via **clap_mangen**. This provides several benefits:
**Auto-generated man pages** - Always in sync with CLI
**Better help output** - Rich, formatted `--help` text
**Type safety** - Compile-time checking of arguments
**Environment variable support** - Built-in env var integration
**Shell completions** - Easy to add bash/zsh/fish completions
**Single source of truth** - CLI definitions generate everything
## What Changed
### Before (Manual Parsing)
```rust
// Old approach: Manual argument parsing
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
let mut it = args.into_iter();
let prog = it.next().unwrap_or_else(|| "socktop".into());
let mut url: Option<String> = None;
let mut tls_ca: Option<String> = None;
// ... lots of manual parsing code ...
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
return Err(format!("Usage: {prog} ..."));
}
"--tls-ca" | "-t" => {
tls_ca = it.next();
}
// ... more matches ...
}
}
Ok(ParsedArgs { url, tls_ca, ... })
}
```
**Problems:**
- Manual parsing is error-prone
- Help text gets out of sync
- No automatic man page generation
- Duplicated logic for `--flag` and `--flag=value`
- No environment variable support
- Hard to test
### After (Clap Derive)
```rust
// New approach: Clap derive macros
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "socktop",
version,
author,
about = "Remote system monitor with a rich TUI over WebSocket",
long_about = "socktop is a remote system monitor..."
)]
pub struct Cli {
/// WebSocket URL to connect to
#[arg(value_name = "URL")]
pub url: Option<String>,
/// Path to TLS certificate PEM file for WSS connections
#[arg(short = 't', long = "tls-ca", value_name = "CERT_PEM")]
pub tls_ca: Option<String>,
// ... more fields ...
}
// Usage:
let cli = Cli::parse();
```
**Benefits:**
- Declarative and concise
- Auto-generated help and man pages
- Type-safe argument parsing
- Automatic support for `--flag` and `--flag=value`
- Built-in env var support with `env` attribute
- Easy to test
## Files Modified
### Core Changes
1. **`socktop/Cargo.toml`** - Added clap dependencies
2. **`socktop/src/cli.rs`** - NEW: CLI definition using clap derive
3. **`socktop/src/main.rs`** - Updated to use `Cli::parse_args()`
4. **`socktop/build.rs`** - NEW: Auto-generates man pages at build time
5. **`socktop_agent/Cargo.toml`** - Added clap dependencies
6. **`socktop_agent/src/cli.rs`** - NEW: CLI definition using clap derive
7. **`socktop_agent/src/main.rs`** - Updated to use `Cli::parse_args()`
8. **`socktop_agent/build.rs`** - Updated to auto-generate man pages
### Documentation
9. **`docs/AUTO_MAN_PAGES.md`** - Comprehensive guide to auto-generated man pages
10. **`docs/CLAP_MIGRATION.md`** - This file
11. **`README.md`** - Updated Man Pages section
12. **`scripts/install-with-man.sh`** - NEW: Installation script that includes man pages
## Man Page Generation
### How It Works
1. **Build Time** - When you run `cargo build`, the `build.rs` script:
- Includes the CLI definition from `src/cli.rs`
- Creates a clap `Command` instance
- Uses `clap_mangen` to generate a man page
- Saves it to `$OUT_DIR/man/*.1`
2. **Location** - Generated man pages are at:
```
target/release/build/socktop-*/out/man/socktop.1
target/release/build/socktop_agent-*/out/man/socktop_agent.1
```
3. **Installation** - Use the installation script:
```bash
./scripts/install-with-man.sh # User install
sudo ./scripts/install-with-man.sh --system # System install
```
4. **Viewing** - After installation:
```bash
man socktop
man socktop_agent
```
### Man Page Content
The man pages include:
- **NAME** - From `about` attribute
- **SYNOPSIS** - Auto-generated from arguments
- **DESCRIPTION** - From `long_about` attribute
- **OPTIONS** - From field doc comments and `#[arg(...)]` attributes
- **VERSION** - From Cargo.toml
- **AUTHORS** - From Cargo.toml
## CLI Definition Format
### Structure
```rust
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "myapp",
version, // Uses Cargo.toml version
author, // Uses Cargo.toml authors
about = "Short description",
long_about = "Longer description for man page and --help"
)]
pub struct Cli {
/// Short description
///
/// Longer description that appears in the man page.
/// Multiple paragraphs supported.
#[arg(short = 't', long = "thing", value_name = "VALUE")]
pub thing: Option<String>,
}
```
### Common Attributes
| Attribute | Purpose | Example |
|-----------|---------|---------|
| `short = 'x'` | Short flag | `-x` |
| `long = "example"` | Long flag | `--example` |
| `value_name = "FOO"` | Display name | `--thing <FOO>` |
| `env = "VAR"` | Environment variable | `env = "MY_VAR"` |
| `default_value = "x"` | Default value | Default: "x" |
| `hide = true` | Hide from help/man | For internal options |
| `value_parser = func` | Custom parser | Validation |
### Environment Variables
```rust
/// Port to listen on
///
/// Can be set via SOCKTOP_PORT environment variable.
#[arg(short = 'p', long = "port", env = "SOCKTOP_PORT")]
pub port: Option<u16>,
```
This automatically:
- Checks the environment variable
- Shows `[env: SOCKTOP_PORT=]` in help
- Documents it in the man page
## Testing
### Unit Tests
Both CLI modules include comprehensive unit tests:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_parsing() {
let cli = Cli::try_parse_from(&["socktop", "ws://localhost:8080/ws"]).unwrap();
assert_eq!(cli.url, Some("ws://localhost:8080/ws".to_string()));
}
#[test]
fn test_tls_options() {
let cli = Cli::try_parse_from(&[
"socktop",
"-t", "/path/to/cert.pem",
"--verify-hostname",
"wss://example.com:8443/ws",
]).unwrap();
assert_eq!(cli.tls_ca, Some("/path/to/cert.pem".to_string()));
assert!(cli.verify_hostname);
}
}
```
Run tests with:
```bash
cargo test --package socktop cli::
cargo test --package socktop_agent cli::
```
### Manual Testing
Test the help output:
```bash
cargo run --package socktop -- --help
cargo run --package socktop_agent -- --help
```
Test argument parsing:
```bash
cargo run --package socktop -- ws://localhost:8080/ws
cargo run --package socktop -- -t cert.pem wss://localhost:8443/ws
cargo run --package socktop_agent -- --port 8080
cargo run --package socktop_agent -- --enableSSL
```
Test environment variables:
```bash
SOCKTOP_PORT=9000 cargo run --package socktop_agent
SOCKTOP_ENABLE_SSL=1 cargo run --package socktop_agent
```
## Backwards Compatibility
### Command-Line Interface
✅ **Fully compatible** - All existing command-line arguments work exactly the same:
```bash
# Still works
socktop -t cert.pem wss://host:8443/ws
socktop --profile myprofile
socktop --demo
socktop_agent --port 8080
socktop_agent --enableSSL
```
### Environment Variables
✅ **Fully compatible** - All environment variables still work:
```bash
SOCKTOP_PORT=8080 socktop_agent
SOCKTOP_ENABLE_SSL=1 socktop_agent
```
### Breaking Changes
❌ **None** - This is a drop-in replacement for the old parser.
## Comparison: Manual vs Clap
| Feature | Manual Parsing | Clap |
|---------|---------------|------|
| Code lines | ~120 lines | ~50 lines |
| Man pages | Separate files | Auto-generated |
| Help text | Hardcoded strings | Auto-generated |
| Type safety | Runtime errors | Compile-time |
| Env vars | Manual `std::env::var` | Built-in `env` attribute |
| Testing | Hard to test | Easy with `try_parse_from` |
| Maintenance | High | Low |
| Consistency | Can drift | Always in sync |
| Completions | Manual | Auto-generate |
## Future Enhancements
Now that we're using clap, we can easily add:
### Shell Completions
```rust
// In build.rs
use clap_complete::{generate_to, shells::*};
let cmd = Cli::command();
generate_to(Bash, &mut cmd, "socktop", &out_dir)?;
generate_to(Zsh, &mut cmd, "socktop", &out_dir)?;
generate_to(Fish, &mut cmd, "socktop", &out_dir)?;
```
### Subcommands
```rust
#[derive(Parser)]
enum Commands {
/// Connect to an agent
Connect {
#[arg(value_name = "URL")]
url: String,
},
/// List saved profiles
Profiles,
/// Run demo mode
Demo,
}
```
### Better Validation
```rust
#[arg(value_parser = clap::value_parser!(u16).range(1..=65535))]
pub port: Option<u16>,
```
### Custom Help Sections
```rust
#[command(
after_help = "EXAMPLES:\n socktop ws://localhost:8080/ws\n socktop --demo"
)]
```
## Migration Checklist
If migrating other Rust projects to clap:
- [ ] Add clap and clap_mangen dependencies
- [ ] Create `src/cli.rs` with derive macros
- [ ] Update `main.rs` to use `Cli::parse()`
- [ ] Create/update `build.rs` for man page generation
- [ ] Write unit tests for CLI parsing
- [ ] Test all existing command-line arguments
- [ ] Test environment variables
- [ ] Update documentation
- [ ] Create installation scripts for man pages
- [ ] Consider adding shell completions
## Resources
- [Clap Documentation](https://docs.rs/clap/)
- [Clap Derive Tutorial](https://docs.rs/clap/latest/clap/_derive/index.html)
- [Clap Mangen](https://docs.rs/clap_mangen/)
- [Auto-Generated Man Pages Guide](AUTO_MAN_PAGES.md)
- [Manual Man Pages](man/README.md)
## Conclusion
The migration to clap provides:
- Better developer experience
- Auto-generated, always-in-sync documentation
- Reduced maintenance burden
- Professional-quality help output and man pages
- Foundation for future enhancements (completions, subcommands)
All with **zero breaking changes** to the existing CLI.
-200
View File
@@ -1,200 +0,0 @@
#!/usr/bin/env bash
# Install socktop binaries and man pages
# This script builds the binaries, generates man pages, and installs everything
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Color output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_header() {
echo -e "${BLUE}==>${NC} ${1}"
}
print_success() {
echo -e "${GREEN}${NC} ${1}"
}
print_error() {
echo -e "${RED}${NC} ${1}"
}
print_warning() {
echo -e "${YELLOW}!${NC} ${1}"
}
# Parse arguments
SYSTEM_INSTALL=false
MAN_ONLY=false
while [ $# -gt 0 ]; do
case "$1" in
--system)
SYSTEM_INSTALL=true
shift
;;
--man-only)
MAN_ONLY=true
shift
;;
--help|-h)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Build and install socktop binaries and man pages"
echo ""
echo "Options:"
echo " --system Install system-wide (requires sudo)"
echo " --man-only Only install man pages (skip binary build)"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Build and install for current user"
echo " sudo $0 --system # Build and install system-wide"
echo " $0 --man-only # Only install man pages"
exit 0
;;
*)
print_error "Unknown option: $1"
echo "Run '$0 --help' for usage information"
exit 1
;;
esac
done
cd "$PROJECT_ROOT"
# Step 1: Build binaries (unless --man-only)
if [ "$MAN_ONLY" = false ]; then
print_header "Building binaries..."
cargo build --release
print_success "Binaries built successfully"
fi
# Step 2: Extract generated man pages from OUT_DIR
print_header "Extracting generated man pages..."
# Find the build output directory
SOCKTOP_OUT_DIR=$(find target/release/build/socktop-*/out -type d -name "man" 2>/dev/null | head -1)
AGENT_OUT_DIR=$(find target/release/build/socktop_agent-*/out -type d -name "man" 2>/dev/null | head -1)
if [ -z "$SOCKTOP_OUT_DIR" ] || [ -z "$AGENT_OUT_DIR" ]; then
print_error "Generated man pages not found. Building to generate them..."
cargo build --release
SOCKTOP_OUT_DIR=$(find target/release/build/socktop-*/out -type d -name "man" 2>/dev/null | head -1)
AGENT_OUT_DIR=$(find target/release/build/socktop_agent-*/out -type d -name "man" 2>/dev/null | head -1)
fi
if [ -z "$SOCKTOP_OUT_DIR" ] || [ ! -f "$SOCKTOP_OUT_DIR/socktop.1" ]; then
print_error "Failed to find generated socktop.1 man page"
exit 1
fi
if [ -z "$AGENT_OUT_DIR" ] || [ ! -f "$AGENT_OUT_DIR/socktop_agent.1" ]; then
print_error "Failed to find generated socktop_agent.1 man page"
exit 1
fi
print_success "Found generated man pages"
# Step 3: Determine installation directories
if [ "$SYSTEM_INSTALL" = true ]; then
if [ "$EUID" -ne 0 ]; then
print_error "System-wide installation requires root privileges"
echo "Please run with sudo: sudo $0 --system"
exit 1
fi
BIN_DIR="/usr/local/bin"
MAN_DIR="/usr/local/share/man/man1"
else
BIN_DIR="$HOME/.cargo/bin"
MAN_DIR="$HOME/.local/share/man/man1"
fi
# Step 4: Install binaries (unless --man-only)
if [ "$MAN_ONLY" = false ]; then
print_header "Installing binaries to $BIN_DIR..."
if [ "$SYSTEM_INSTALL" = true ]; then
install -m 755 target/release/socktop "$BIN_DIR/socktop"
install -m 755 target/release/socktop_agent "$BIN_DIR/socktop_agent"
else
# For user install, cargo already puts binaries in ~/.cargo/bin
# But we can copy from release if needed
if [ ! -f "$BIN_DIR/socktop" ]; then
cp target/release/socktop "$BIN_DIR/"
chmod 755 "$BIN_DIR/socktop"
fi
if [ ! -f "$BIN_DIR/socktop_agent" ]; then
cp target/release/socktop_agent "$BIN_DIR/"
chmod 755 "$BIN_DIR/socktop_agent"
fi
fi
print_success "Binaries installed to $BIN_DIR"
fi
# Step 5: Install man pages
print_header "Installing man pages to $MAN_DIR..."
mkdir -p "$MAN_DIR"
if [ "$SYSTEM_INSTALL" = true ]; then
install -m 644 "$SOCKTOP_OUT_DIR/socktop.1" "$MAN_DIR/socktop.1"
install -m 644 "$AGENT_OUT_DIR/socktop_agent.1" "$MAN_DIR/socktop_agent.1"
else
cp "$SOCKTOP_OUT_DIR/socktop.1" "$MAN_DIR/socktop.1"
cp "$AGENT_OUT_DIR/socktop_agent.1" "$MAN_DIR/socktop_agent.1"
chmod 644 "$MAN_DIR/socktop.1"
chmod 644 "$MAN_DIR/socktop_agent.1"
fi
print_success "Man pages installed to $MAN_DIR"
# Update man database if available
if [ "$SYSTEM_INSTALL" = true ]; then
if command -v mandb &>/dev/null; then
print_header "Updating man database..."
mandb 2>/dev/null || true
fi
fi
# Final summary
echo ""
print_success "Installation complete!"
echo ""
if [ "$MAN_ONLY" = false ]; then
echo "Binaries installed:"
echo " socktop -> $BIN_DIR/socktop"
echo " socktop_agent -> $BIN_DIR/socktop_agent"
echo ""
fi
echo "Man pages installed:"
echo " socktop(1) -> $MAN_DIR/socktop.1"
echo " socktop_agent(1) -> $MAN_DIR/socktop_agent.1"
echo ""
echo "Try it out:"
if [ "$MAN_ONLY" = false ]; then
echo " socktop --help"
echo " socktop_agent --help"
fi
echo " man socktop"
echo " man socktop_agent"
echo ""
# Check if MANPATH needs updating for user install
if [ "$SYSTEM_INSTALL" = false ]; then
if ! man -w socktop &>/dev/null 2>&1; then
print_warning "If 'man socktop' doesn't work, add to your shell rc file:"
echo " export MANPATH=\"\$HOME/.local/share/man:\$MANPATH\""
fi
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Sync this repo to the 'gitea' remote as a mirror.
# - Mirrors ALL refs (branches, tags) and prunes removed ones.
# - This makes the Gitea repo match GitHub exactly.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Error: not inside a git repo" >&2
exit 1
fi
if ! git remote get-url gitea >/dev/null 2>&1; then
echo "Missing 'gitea' remote. Add it with:" >&2
echo " git remote add gitea https://gt.wittyoneoff.com/jason/socktop.git" >&2
exit 1
fi
echo "Fetching from origin (pruning)..."
git fetch origin --prune --tags
echo "Pushing mirror to gitea..."
git push gitea --mirror
echo "Done: Gitea should now match origin (GitHub)."
-7
View File
@@ -8,9 +8,6 @@ license = "MIT"
readme = "README.md"
[dependencies]
# CLI parsing and man page generation
clap = { version = "4.5", features = ["derive", "cargo", "wrap_help"] }
# socktop connector for agent communication
socktop_connector = "1.50.0"
@@ -25,10 +22,6 @@ anyhow = { workspace = true }
dirs-next = { workspace = true }
sysinfo = { workspace = true }
[build-dependencies]
clap = { version = "4.5", features = ["derive", "cargo"] }
clap_mangen = "0.2"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3"
-30
View File
@@ -1,30 +0,0 @@
use clap::CommandFactory;
use clap_mangen::Man;
use std::fs;
use std::io::Result;
use std::path::PathBuf;
include!("src/cli.rs");
fn main() -> Result<()> {
println!("cargo:rerun-if-changed=src/cli.rs");
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let man_dir = out_dir.join("man");
fs::create_dir_all(&man_dir)?;
// Generate man page for socktop
let cmd = Cli::command();
let man = Man::new(cmd);
let mut buffer = Vec::new();
man.render(&mut buffer)?;
fs::write(man_dir.join("socktop.1"), buffer)?;
println!(
"cargo:warning=Man page generated at {:?}",
man_dir.join("socktop.1")
);
Ok(())
}
+211 -125
View File
@@ -29,11 +29,15 @@ use crate::ui::cpu::{
};
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
use crate::ui::processes::{
ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection,
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
processes_handle_mouse_with_selection,
};
use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
disks::draw_disks,
gpu::draw_gpu,
header::{build_header_intervals, build_header_title, draw_header},
mem::draw_mem,
net::draw_net_spark,
swap::draw_swap,
};
@@ -46,6 +50,15 @@ use socktop_connector::{
const MIN_METRICS_INTERVAL_MS: u64 = 100;
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
/// Drop duplicate-name entries from a disks payload (the agent occasionally
/// reports a partition twice). Done once when fresh disk data arrives so the
/// per-frame draw path doesn't have to rebuild a HashSet.
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(disks.len());
disks.retain(|d| seen.insert(d.name.clone()));
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConnectionState {
Connected,
@@ -57,8 +70,9 @@ pub struct App {
// Latest metrics + histories
last_metrics: Option<Metrics>,
// CPU avg history (0..100)
// CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame
cpu_hist: VecDeque<u64>,
cpu_hist_sum: u64,
// Per-core history (0..100)
per_core_hist: PerCoreHistory,
@@ -89,6 +103,17 @@ pub struct App {
pub process_search_active: bool,
pub process_search_query: String,
// Cached filtered + sorted process indices. Refreshed lazily when any of
// (metrics, sort order, search query) changes — input handlers, the draw
// path, and auto-scroll all read from this slice so we avoid rebuilding
// an indices Vec on every event.
procs_filtered: Vec<usize>,
procs_filter_dirty: bool,
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
// parallel to `last_metrics.top_processes`.
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
procs_row_peak_cpu: f32,
last_procs_poll: Instant,
last_disks_poll: Instant,
procs_interval: Duration,
@@ -99,6 +124,7 @@ pub struct App {
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
pub journal_entries: Option<socktop_connector::JournalResponse>,
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
@@ -119,6 +145,17 @@ pub struct App {
pub is_tls: bool,
pub has_token: bool,
// Cached title strings — only rebuilt when source values change so the
// diff renderer can suppress redraws on idle frames.
header_title: String,
header_title_key: (String, bool, bool),
header_intervals_text: String,
header_intervals_key: (u128, u128),
net_dl_title: String,
net_dl_key: (u64, u64),
net_ul_title: String,
net_ul_key: (u64, u64),
// Modal system
pub modal_manager: crate::ui::modal::ModalManager,
@@ -136,6 +173,7 @@ impl App {
Self {
last_metrics: None,
cpu_hist: VecDeque::with_capacity(600),
cpu_hist_sum: 0,
per_core_hist: PerCoreHistory::new(60),
last_net_totals: None,
rx_hist: VecDeque::with_capacity(600),
@@ -154,6 +192,10 @@ impl App {
prev_selected_process_pid: None,
process_search_active: false,
process_search_query: String::new(),
procs_filtered: Vec::new(),
procs_filter_dirty: true,
procs_row_cache: Vec::new(),
procs_row_peak_cpu: 0.0,
last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop
@@ -166,6 +208,7 @@ impl App {
process_details: None,
journal_entries: None,
process_cpu_history: VecDeque::with_capacity(600),
process_cpu_history_sum: 0.0,
process_mem_history: VecDeque::with_capacity(600),
process_io_read_history: VecDeque::with_capacity(600),
process_io_write_history: VecDeque::with_capacity(600),
@@ -186,6 +229,14 @@ impl App {
verify_hostname: false,
is_tls: false,
has_token: false,
header_title: String::new(),
header_title_key: (String::new(), false, false),
header_intervals_text: String::new(),
header_intervals_key: (u128::MAX, u128::MAX),
net_dl_title: String::new(),
net_dl_key: (u64::MAX, u64::MAX),
net_ul_title: String::new(),
net_ul_key: (u64::MAX, u64::MAX),
modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(),
@@ -465,7 +516,10 @@ impl App {
_url: &str,
_tls_ca: Option<&str>,
_verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>>
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop {
// Handle input for modal
while event::poll(Duration::from_millis(10))? {
@@ -572,7 +626,10 @@ impl App {
&mut self,
terminal: &mut Terminal<B>,
mut ws: SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>>
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop {
// Main event loop
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
@@ -592,7 +649,10 @@ impl App {
&mut self,
terminal: &mut Terminal<B>,
ws: &mut SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error>>
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop {
// Input (non-blocking)
while event::poll(Duration::from_millis(10))? {
@@ -665,6 +725,7 @@ impl App {
// Exit search mode
self.process_search_active = false;
self.process_search_query.clear();
self.invalidate_procs_filter();
continue;
}
KeyCode::Enter => {
@@ -672,27 +733,24 @@ impl App {
self.process_search_active = false;
// Auto-select first filtered result
if let Some(m) = self.last_metrics.as_ref() {
let idxs = get_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
);
if !idxs.is_empty() {
let first_idx = idxs[0];
self.selected_process_index = Some(first_idx);
self.selected_process_pid =
Some(m.top_processes[first_idx].pid);
}
let first = self.procs_filter().first().copied();
if let (Some(first_idx), Some(m)) =
(first, self.last_metrics.as_ref())
{
self.selected_process_index = Some(first_idx);
self.selected_process_pid =
Some(m.top_processes[first_idx].pid);
}
continue;
}
KeyCode::Backspace => {
self.process_search_query.pop();
self.invalidate_procs_filter();
continue;
}
KeyCode::Char(c) => {
self.process_search_query.push(c);
self.invalidate_procs_filter();
continue;
}
KeyCode::Up | KeyCode::Down => {
@@ -728,6 +786,7 @@ impl App {
self.process_search_query.clear();
self.selected_process_pid = None;
self.selected_process_index = None;
self.invalidate_procs_filter();
continue;
}
@@ -760,6 +819,10 @@ impl App {
.split(rows[1]);
let content = per_core_content_area(top[1]);
// Refresh the filtered+sorted index cache once before we
// borrow individual fields of `self`.
let _ = self.procs_filter();
// First try process selection (only handles arrows if a process is selected)
let process_handled = if self.last_procs_area.is_some() {
processes_handle_key_with_selection(ProcessKeyParams {
@@ -767,8 +830,7 @@ impl App {
selected_process_index: &mut self.selected_process_index,
key: k,
metrics: self.last_metrics.as_ref(),
sort_by: self.procs_sort_by,
search_query: &self.process_search_query,
filtered_indices: &self.procs_filtered,
})
} else {
false
@@ -786,14 +848,9 @@ impl App {
// Auto-scroll to keep selected process visible
if let (Some(selected_idx), Some(p_area)) =
(self.selected_process_index, self.last_procs_area)
&& let Some(m) = self.last_metrics.as_ref()
&& self.last_metrics.is_some()
{
// Get filtered and sorted indices (same as display)
let idxs = get_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
);
let idxs = &self.procs_filtered;
// Find the display position of the selected process in filtered list
if let Some(display_pos) =
@@ -903,11 +960,17 @@ impl App {
content.height as usize,
);
// Refresh filter cache before partial borrows of self.
let _ = self.procs_filter();
let search_box_visible =
self.process_search_active || !self.process_search_query.is_empty();
// Processes table: sort by column on header click and handle row selection
if let (Some(mm), Some(p_area)) =
if let (Some(_mm), Some(p_area)) =
(self.last_metrics.as_ref(), self.last_procs_area)
{
use crate::ui::processes::ProcessMouseParams;
let total_rows = self.procs_filtered.len();
if let Some(new_sort) =
processes_handle_mouse_with_selection(ProcessMouseParams {
scroll_offset: &mut self.procs_scroll_offset,
@@ -916,13 +979,14 @@ impl App {
drag: &mut self.procs_drag,
mouse: m,
area: p_area,
total_rows: mm.top_processes.len(),
total_rows,
metrics: self.last_metrics.as_ref(),
sort_by: self.procs_sort_by,
search_query: &self.process_search_query,
search_box_visible,
filtered_indices: &self.procs_filtered,
})
{
self.procs_sort_by = new_sort;
self.invalidate_procs_filter();
}
}
@@ -959,22 +1023,36 @@ impl App {
// Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval {
let mut updated = false;
if let Ok(AgentResponse::Processes(procs)) =
ws.request(AgentRequest::Processes).await
&& let Some(mm) = self.last_metrics.as_mut()
{
mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count);
updated = true;
}
if updated {
self.invalidate_procs_filter();
// Rebuild the pre-formatted row cache for the next
// ~N frames. Done once per poll, not per frame.
if let Some(mm) = self.last_metrics.as_ref() {
self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache(
mm,
&mut self.procs_row_cache,
);
}
}
self.last_procs_poll = Instant::now();
}
// Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Ok(AgentResponse::Disks(disks)) =
if let Ok(AgentResponse::Disks(mut disks)) =
ws.request(AgentRequest::Disks).await
&& let Some(mm) = self.last_metrics.as_mut()
{
dedup_disks(&mut disks);
mm.disks = disks;
}
self.last_disks_poll = Instant::now();
@@ -1000,7 +1078,14 @@ impl App {
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
// Update history for sparklines
let cpu_usage = details.process.cpu_usage;
push_capped(&mut self.process_cpu_history, cpu_usage, 600);
let evicted_cpu = push_capped(
&mut self.process_cpu_history,
cpu_usage,
600,
);
self.process_cpu_history_sum = self.process_cpu_history_sum
+ cpu_usage
- evicted_cpu.unwrap_or(0.0);
let mem_bytes = details.process.mem_bytes;
push_capped(&mut self.process_mem_history, mem_bytes, 600);
@@ -1105,11 +1190,37 @@ impl App {
Ok(())
}
/// Mark the filtered-process cache stale. Call this whenever
/// `procs_sort_by`, `process_search_query`, or the top_processes content
/// changes — the cache is rebuilt lazily on the next read.
pub fn invalidate_procs_filter(&mut self) {
self.procs_filter_dirty = true;
}
/// Lazily refresh and return the cached filtered+sorted process indices.
/// Empty slice when there are no metrics yet.
pub fn procs_filter(&mut self) -> &[usize] {
if self.procs_filter_dirty {
self.procs_filtered.clear();
if let Some(m) = self.last_metrics.as_ref() {
crate::ui::processes::fill_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
&mut self.procs_filtered,
);
}
self.procs_filter_dirty = false;
}
&self.procs_filtered
}
/// Clear process details when modal is closed or selection changes
pub fn clear_process_details(&mut self) {
self.process_details = None;
self.journal_entries = None;
self.process_cpu_history.clear();
self.process_cpu_history_sum = 0.0;
self.process_mem_history.clear();
self.process_io_read_history.clear();
self.process_io_write_history.clear();
@@ -1120,23 +1231,24 @@ impl App {
}
fn update_with_metrics(&mut self, mut m: Metrics) {
if let Some(prev) = &self.last_metrics {
// Preserve slower fields when the fast payload omits them
if let Some(prev) = self.last_metrics.as_mut() {
// Preserve slower fields when the fast payload omits them.
// prev is about to be dropped so we can move its Vecs instead of cloning.
if m.disks.is_empty() {
m.disks = prev.disks.clone();
m.disks = std::mem::take(&mut prev.disks);
}
if m.top_processes.is_empty() {
m.top_processes = prev.top_processes.clone();
m.top_processes = std::mem::take(&mut prev.top_processes);
}
// Preserve total processes count across fast updates
if m.process_count.is_none() {
m.process_count = prev.process_count;
}
}
// CPU avg history
// CPU avg history with running sum
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.cpu_hist, v, 600);
let evicted = push_capped(&mut self.cpu_hist, v, 600);
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
// Per-core history (push current samples)
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
@@ -1179,16 +1291,31 @@ impl App {
])
.split(area);
// Header
draw_header(
f,
rows[0],
self.last_metrics.as_ref(),
self.is_tls,
self.has_token,
self.metrics_interval,
self.procs_interval,
);
// Header — refresh cached strings only when their inputs change so the
// ratatui diff renderer can suppress repaints on idle frames.
{
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
let key = (
hostname.unwrap_or("").to_string(),
self.is_tls,
self.has_token,
);
if self.header_title_key != key {
self.header_title = build_header_title(hostname, self.is_tls, self.has_token);
self.header_title_key = key;
}
let intervals_key = (
self.metrics_interval.as_millis(),
self.procs_interval.as_millis(),
);
if self.header_intervals_key != intervals_key {
self.header_intervals_text =
build_header_intervals(intervals_key.0, intervals_key.1);
self.header_intervals_key = intervals_key;
}
}
draw_header(f, rows[0], &self.header_title, &self.header_intervals_text);
// Top row: left CPU avg, right Per-core (full top-right)
let top_lr = ratatui::layout::Layout::default()
@@ -1196,12 +1323,18 @@ impl App {
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]);
draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
draw_cpu_avg_graph(
f,
top_lr[0],
&mut self.cpu_hist,
self.cpu_hist_sum,
self.last_metrics.as_ref(),
);
draw_per_core_bars(
f,
top_lr[1],
self.last_metrics.as_ref(),
&self.per_core_hist,
&mut self.per_core_hist,
self.per_core_scroll,
);
@@ -1245,26 +1378,33 @@ impl App {
.split(bottom_lr[0]);
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
// Net titles only change when the throughput or peak changes.
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
let rx_key = (rx_now, self.rx_peak);
if self.net_dl_key != rx_key {
self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak);
self.net_dl_key = rx_key;
}
draw_net_spark(
f,
left_stack[1],
&format!(
"Download (KB/s) — now: {} | peak: {}",
self.rx_hist.back().copied().unwrap_or(0),
self.rx_peak
),
&self.rx_hist,
&self.net_dl_title,
&mut self.rx_hist,
ratatui::style::Color::Green,
);
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
let tx_key = (tx_now, self.tx_peak);
if self.net_ul_key != tx_key {
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
self.net_ul_key = tx_key;
}
draw_net_spark(
f,
left_stack[2],
&format!(
"Upload (KB/s) — now: {} | peak: {}",
self.tx_hist.back().copied().unwrap_or(0),
self.tx_peak
),
&self.tx_hist,
&self.net_ul_title,
&mut self.tx_hist,
ratatui::style::Color::Blue,
);
@@ -1272,6 +1412,8 @@ impl App {
let procs_area = bottom_lr[1];
// Cache for input handlers
self.last_procs_area = Some(procs_area);
// Refresh the filter cache before partial borrows of self.
let _ = self.procs_filter();
crate::ui::processes::draw_top_processes(
f,
procs_area,
@@ -1283,6 +1425,9 @@ impl App {
selected_process_index: self.selected_process_index,
search_query: &self.process_search_query,
search_active: self.process_search_active,
filtered_indices: &self.procs_filtered,
cached_rows: &self.procs_row_cache,
peak_cpu: self.procs_row_peak_cpu,
},
);
@@ -1296,6 +1441,7 @@ impl App {
journal: self.journal_entries.as_ref(),
history: ProcessHistoryData {
cpu: &self.process_cpu_history,
cpu_sum: self.process_cpu_history_sum,
mem: &self.process_mem_history,
io_read: &self.process_io_read_history,
io_write: &self.process_io_write_history,
@@ -1310,66 +1456,6 @@ impl App {
impl Default for App {
fn default() -> Self {
Self {
last_metrics: None,
cpu_hist: VecDeque::with_capacity(600),
per_core_hist: PerCoreHistory::new(60),
last_net_totals: None,
rx_hist: VecDeque::with_capacity(600),
tx_hist: VecDeque::with_capacity(600),
rx_peak: 0,
tx_peak: 0,
should_quit: false,
per_core_scroll: 0,
per_core_drag: None,
procs_scroll_offset: 0,
procs_drag: None,
procs_sort_by: ProcSortBy::CpuDesc,
last_procs_area: None,
selected_process_pid: None,
selected_process_index: None,
prev_selected_process_pid: None,
process_search_active: false,
process_search_query: String::new(),
last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop
last_disks_poll: Instant::now()
.checked_sub(Duration::from_secs(5))
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
process_details: None,
journal_entries: None,
process_cpu_history: VecDeque::with_capacity(600),
process_mem_history: VecDeque::with_capacity(600),
process_io_read_history: VecDeque::with_capacity(600),
process_io_write_history: VecDeque::with_capacity(600),
last_io_read_bytes: None,
last_io_write_bytes: None,
max_process_mem_bytes: 0,
process_details_unsupported: false,
last_process_details_poll: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
last_journal_poll: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
process_details_interval: Duration::from_millis(500),
journal_interval: Duration::from_secs(5),
ws_url: String::new(),
tls_ca: None,
verify_hostname: false,
is_tls: false,
has_token: false,
modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(),
original_disconnect_time: None,
connection_retry_count: 0,
last_auto_retry: None,
replacement_connection: None,
}
Self::new()
}
}
-135
View File
@@ -1,135 +0,0 @@
// CLI argument definitions using clap derive macros.
// This file is also included by build.rs for man page generation.
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "socktop",
version,
author,
about = "Remote system monitor with a rich TUI over WebSocket",
long_about = "socktop is a remote system monitor with a rich terminal user interface (TUI), \
inspired by top/btop. It connects to a lightweight socktop_agent over WebSockets \
to display real-time system metrics including CPU usage, memory, swap, disk usage, \
network throughput, temperatures, GPU metrics, and a sortable process table.\n\n\
The agent is request-driven with near-zero CPU usage when idle."
)]
pub struct Cli {
/// WebSocket URL to connect to (e.g., ws://192.168.1.100:8080/ws or wss://host:8443/ws)
#[arg(value_name = "URL")]
pub url: Option<String>,
/// Path to TLS certificate PEM file for WSS connections
///
/// The certificate is pinned for security. The agent auto-generates
/// a self-signed certificate on first run.
#[arg(short = 't', long = "tls-ca", value_name = "CERT_PEM")]
pub tls_ca: Option<String>,
/// Enable hostname (SAN) verification for TLS connections
///
/// By default, hostname verification is skipped for easier home network usage,
/// but the certificate is still pinned.
#[arg(long)]
pub verify_hostname: bool,
/// Use a named connection profile
///
/// Profiles are stored in ~/.config/socktop/profiles.json and can contain
/// URL, TLS settings, and polling intervals.
#[arg(short = 'P', long = "profile", value_name = "NAME")]
pub profile: Option<String>,
/// Save the current connection as a named profile
///
/// Use with --profile to specify the profile name.
#[arg(long)]
pub save: bool,
/// Run in demo mode using mock data without connecting to an agent
///
/// Useful for testing the UI without a running agent.
#[arg(long)]
pub demo: bool,
/// Set the metrics polling interval in milliseconds
///
/// Default is typically 1000ms. Lower values increase update frequency
/// but also CPU usage.
#[arg(long, value_name = "MS")]
pub metrics_interval_ms: Option<u64>,
/// Set the process list polling interval in milliseconds
///
/// Can be different from metrics interval to reduce overhead.
#[arg(long, value_name = "MS")]
pub processes_interval_ms: Option<u64>,
/// Hidden test helper: skip connecting
#[arg(long, hide = true)]
pub dry_run: bool,
}
impl Cli {
/// Parse CLI arguments from environment
pub fn parse_args() -> Self {
Cli::parse()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_parsing() {
let cli = Cli::try_parse_from(&["socktop", "ws://localhost:8080/ws"]).unwrap();
assert_eq!(cli.url, Some("ws://localhost:8080/ws".to_string()));
assert!(!cli.demo);
assert!(!cli.save);
}
#[test]
fn test_tls_options() {
let cli = Cli::try_parse_from(&[
"socktop",
"-t",
"/path/to/cert.pem",
"--verify-hostname",
"wss://example.com:8443/ws",
])
.unwrap();
assert_eq!(cli.tls_ca, Some("/path/to/cert.pem".to_string()));
assert!(cli.verify_hostname);
assert_eq!(cli.url, Some("wss://example.com:8443/ws".to_string()));
}
#[test]
fn test_profile_options() {
let cli = Cli::try_parse_from(&["socktop", "-P", "myprofile", "--save"]).unwrap();
assert_eq!(cli.profile, Some("myprofile".to_string()));
assert!(cli.save);
}
#[test]
fn test_intervals() {
let cli = Cli::try_parse_from(&[
"socktop",
"--metrics-interval-ms",
"500",
"--processes-interval-ms",
"2000",
"ws://localhost:8080/ws",
])
.unwrap();
assert_eq!(cli.metrics_interval_ms, Some(500));
assert_eq!(cli.processes_interval_ms, Some(2000));
}
#[test]
fn test_demo_mode() {
let cli = Cli::try_parse_from(&["socktop", "--demo"]).unwrap();
assert!(cli.demo);
}
}
+16 -7
View File
@@ -2,16 +2,25 @@
use std::collections::VecDeque;
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
if dq.len() == cap {
dq.pop_front();
}
/// Push a value into a capped deque. Returns the evicted front element if any.
/// Callers maintaining a running sum can use this to update the sum without
/// re-iterating the whole deque.
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> {
let evicted = if dq.len() == cap {
dq.pop_front()
} else {
None
};
dq.push_back(v);
evicted
}
// Keeps a history deque per core with a fixed capacity
// Keeps a history deque per core with a fixed capacity.
// Storage is u64 so sparkline rendering can hand the slice directly to
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
// allocation or widening conversion.
pub struct PerCoreHistory {
pub deques: Vec<VecDeque<u16>>,
pub deques: Vec<VecDeque<u64>>,
cap: usize,
}
@@ -35,7 +44,7 @@ impl PerCoreHistory {
pub fn push_samples(&mut self, samples: &[f32]) {
self.ensure_cores(samples.len());
for (i, v) in samples.iter().enumerate() {
let val = v.clamp(0.0, 100.0).round() as u16;
let val = v.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.deques[i], val, self.cap);
}
}
+122 -4
View File
@@ -1,21 +1,139 @@
//! Entry point for the socktop TUI. Parses args and runs the App.
mod app;
mod cli;
mod history;
mod profiles;
mod retry;
mod types;
mod ui;
mod ui; // pure retry timing logic
use app::App;
use cli::Cli;
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};
use std::env;
use std::io::{self, Write};
pub(crate) struct ParsedArgs {
url: Option<String>,
tls_ca: Option<String>,
profile: Option<String>,
save: bool,
demo: bool,
dry_run: bool, // hidden test helper: skip connecting
metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>,
verify_hostname: bool,
}
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
let mut it = args.into_iter();
let prog = it.next().unwrap_or_else(|| "socktop".into());
let mut url: Option<String> = None;
let mut tls_ca: Option<String> = None;
let mut profile: Option<String> = None;
let mut save = false;
let mut demo = false;
let mut dry_run = false;
let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false;
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
return Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
));
}
"--tls-ca" | "-t" => {
tls_ca = it.next();
}
"--verify-hostname" => {
// opt-in hostname (SAN) verification
// default behavior is to skip it for easier home network usage
// (still pins the provided certificate)
verify_hostname = true;
}
"--profile" | "-P" => {
profile = it.next();
}
"--save" => {
save = true;
}
"--demo" => {
demo = true;
}
"--dry-run" => {
// intentionally undocumented
dry_run = true;
}
"--metrics-interval-ms" => {
metrics_interval_ms = it.next().and_then(|v| v.parse().ok());
}
"--processes-interval-ms" => {
processes_interval_ms = it.next().and_then(|v| v.parse().ok());
}
_ if arg.starts_with("--tls-ca=") => {
if let Some((_, v)) = arg.split_once('=')
&& !v.is_empty()
{
tls_ca = Some(v.to_string());
}
}
_ if arg.starts_with("--profile=") => {
if let Some((_, v)) = arg.split_once('=')
&& !v.is_empty()
{
profile = Some(v.to_string());
}
}
_ if arg.starts_with("--metrics-interval-ms=") => {
if let Some((_, v)) = arg.split_once('=') {
metrics_interval_ms = v.parse().ok();
}
}
_ if arg.starts_with("--processes-interval-ms=") => {
if let Some((_, v)) = arg.split_once('=') {
processes_interval_ms = v.parse().ok();
}
}
_ => {
if url.is_none() {
url = Some(arg);
} else {
return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
));
}
}
}
}
Ok(ParsedArgs {
url,
tls_ca,
profile,
save,
demo,
dry_run,
metrics_interval_ms,
processes_interval_ms,
verify_hostname,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let parsed = Cli::parse_args();
let parsed = match parse_args(env::args()) {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
return Ok(());
}
};
//support version flag (print and exit)
if env::args().any(|a| a == "--version" || a == "-V") {
println!("socktop {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref()).await;
+158 -67
View File
@@ -7,7 +7,9 @@ use ratatui::style::{Color, Style};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Sparkline},
widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
},
};
use crate::history::PerCoreHistory;
@@ -133,11 +135,9 @@ pub fn per_core_handle_scrollbar_mouse(
}
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let top_for_offset = |off: usize| -> usize {
if max_off == 0 {
0
} else {
((track - thumb_len) * off + max_off / 2) / max_off
}
((track - thumb_len) * off + max_off / 2)
.checked_div(max_off)
.unwrap_or(0)
};
let thumb_top = top_for_offset(offset);
@@ -190,11 +190,9 @@ pub fn per_core_handle_scrollbar_mouse(
// Inverse mapping top -> offset
if track > thumb_len {
let denom = track - thumb_len;
offset = if max_off == 0 {
0
} else {
(new_top * max_off + denom / 2) / denom
};
offset = (new_top * max_off + denom / 2)
.checked_div(denom)
.unwrap_or(0);
} else {
offset = 0;
}
@@ -234,18 +232,20 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
}
/// Draws the CPU average sparkline graph.
///
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
/// fold the (up to 600-element) deque on every frame.
pub fn draw_cpu_avg_graph(
f: &mut ratatui::Frame<'_>,
area: Rect,
hist: &std::collections::VecDeque<u64>,
hist: &mut std::collections::VecDeque<u64>,
hist_sum: u64,
m: Option<&Metrics>,
) {
// Calculate average CPU over the monitoring period
let avg_cpu = if !hist.is_empty() {
let sum: u64 = hist.iter().sum();
sum as f64 / hist.len() as f64
} else {
let avg_cpu = if hist.is_empty() {
0.0
} else {
hist_sum as f64 / hist.len() as f64
};
let title = if let Some(mm) = m {
@@ -272,14 +272,16 @@ pub fn draw_cpu_avg_graph(
String::new()
};
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
// us skip the per-frame Vec allocation .collect() used to do.
let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
let slice = &hist.make_contiguous()[start..];
// Render the sparkline with title on left
let spark = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(title))
.data(&data)
.data(slice)
.max(100)
.style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area);
@@ -302,7 +304,7 @@ pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
per_core_hist: &PerCoreHistory,
per_core_hist: &mut PerCoreHistory,
scroll_offset: usize,
) {
f.render_widget(
@@ -347,7 +349,7 @@ pub fn draw_per_core_bars(
let rect = vchunks[i];
let hchunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(6), Constraint::Length(12)])
.constraints([Constraint::Min(6), Constraint::Length(13)])
.split(rect);
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
@@ -358,12 +360,17 @@ pub fn draw_per_core_bars(
.map(|v| v as f32)
.unwrap_or(curr);
// Trend indicator. Various Unicode glyphs we tried for the "flat"
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
// coverage; combined with the next column being `100.0` they read as
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
// for the flat case — no character, no fallback, no confusion.
let trend = if curr > older + 0.2 {
""
} else if curr + 0.2 < older {
""
} else {
""
" "
};
let fg = match curr {
@@ -372,24 +379,24 @@ pub fn draw_per_core_bars(
_ => Color::Red,
};
let hist: Vec<u64> = per_core_hist
.deques
.get(idx)
.map(|d| {
let max_points = hchunks[0].width as usize;
let start = d.len().saturating_sub(max_points);
d.iter().skip(start).map(|&v| v as u64).collect()
})
.unwrap_or_default();
// Borrow the per-core deque mutably so we can hand a contiguous slice
// to Sparkline without allocating a fresh Vec each frame.
if let Some(d) = per_core_hist.deques.get_mut(idx) {
let max_points = hchunks[0].width as usize;
let start = d.len().saturating_sub(max_points);
let slice = &d.make_contiguous()[start..];
let spark = Sparkline::default()
.data(slice)
.max(100)
.style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]);
}
let spark = Sparkline::default()
.data(&hist)
.max(100)
.style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]);
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
// Hard space between the trend mark and the number — even if the
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
// them, this space prevents the trend mark from visually joining
// `100.0` to look like a negative value.
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
let line = Line::from(Span::styled(
label,
Style::default().fg(fg).add_modifier(Modifier::BOLD),
@@ -397,38 +404,122 @@ pub fn draw_per_core_bars(
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
}
// Custom 1-col scrollbar with arrows, track, and exact mapping
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
// content fits in the viewport, matching the previous behaviour.
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
if scroll_area.height >= 3 {
let track = (scroll_area.height - 2) as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let thumb_top = if max_off == 0 {
0
} else {
((track - thumb_len) * offset + max_off / 2) / max_off
};
// Build lines: top arrow, track (with thumb), bottom arrow
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
for i in 0..track {
if i >= thumb_top && i < thumb_top + thumb_len {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB))));
} else {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK))));
}
}
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
f.render_widget(Paragraph::new(lines), scroll_area);
let max_off = total_rows.saturating_sub(viewport_rows);
if scroll_area.height >= 3 && max_off > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""))
.thumb_symbol("")
.track_symbol(Some(""))
.thumb_style(Style::default().fg(SB_THUMB))
.track_style(Style::default().fg(SB_TRACK))
.begin_style(Style::default().fg(SB_ARROW))
.end_style(Style::default().fg(SB_ARROW));
let mut state = ScrollbarState::new(max_off).position(offset);
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
}
}
#[cfg(test)]
mod render_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use socktop_connector::Metrics;
fn fake_metrics(cores: Vec<f32>) -> Metrics {
Metrics {
cpu_total: 0.0,
cpu_per_core: cores,
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus: None,
process_count: Some(0),
}
}
fn dump(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
/// coverage. When a core sat at exactly 100% the label rendered as
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
/// literal space for the flat case AND insert a hard space between every
/// trend mark and the number, so no glyph substitution can produce a
/// "-100" substring. We assert that across flat AND transitioning cores.
#[test]
fn percore_label_never_renders_as_negative() {
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
let mut hist = PerCoreHistory::new(60);
hist.ensure_cores(4);
// First sample: history is empty, no trend on first frame.
hist.push_samples(&m.cpu_per_core);
// Second sample: identical values → flat trend (the user's complaint).
hist.push_samples(&m.cpu_per_core);
let backend = TestBackend::new(120, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
})
.unwrap();
let out = dump(&terminal);
eprintln!("---flat 100% render---\n{out}");
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
// Decreasing trend at saturation: hist was high, current drops a bit.
let mut hist2 = PerCoreHistory::new(60);
hist2.ensure_cores(4);
for _ in 0..25 {
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
}
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
hist2.push_samples(&m2.cpu_per_core);
let backend = TestBackend::new(120, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
})
.unwrap();
let out = dump(&terminal);
eprintln!("---decreasing render---\n{out}");
assert!(
!out.contains("-100"),
"found '-100' in decreasing-trend render"
);
assert!(
!out.contains("-80"),
"found '-80' in decreasing-trend render"
);
}
}
+5 -10
View File
@@ -24,16 +24,11 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
return;
}
// Filter duplicates by keeping first occurrence of each unique name
let mut seen_names = std::collections::HashSet::new();
let unique_disks: Vec<_> = mm
.disks
.iter()
.filter(|d| seen_names.insert(d.name.clone()))
.collect();
// Deduplication is performed once on the App side when fresh disk data
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
// here would rebuild a HashSet ~10x per refresh for no reason).
let per_disk_h = 3u16;
let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize;
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
let constraints: Vec<Constraint> = (0..max_cards)
.map(|_| Constraint::Length(per_disk_h))
@@ -44,7 +39,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
.split(inner);
for (i, slot) in rows.iter().enumerate() {
let d = unique_disks[i];
let d = &mm.disks[i];
let used = d.total.saturating_sub(d.available);
let ratio = if d.total > 0 {
used as f64 / d.total as f64
+16 -27
View File
@@ -1,47 +1,36 @@
//! Top header with hostname and CPU temperature indicator.
use crate::types::Metrics;
use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::time::Duration;
pub fn draw_header(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
is_tls: bool,
has_token: bool,
metrics_interval: Duration,
procs_interval: Duration,
) {
let base = if let Some(mm) = m {
format!("socktop — host: {}", mm.hostname)
} else {
"socktop — connecting...".into()
/// Build the header's left-side title from session state. Callers cache the
/// returned String and only rebuild it when one of the inputs changes.
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
let base = match hostname {
Some(h) => format!("socktop — host: {h}"),
None => "socktop — connecting...".into(),
};
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
// Token indicator
let tok_txt = if has_token { "🔑 token" } else { "" };
let mut parts = vec![base, tls_txt.into()];
if !tok_txt.is_empty() {
parts.push(tok_txt.into());
if has_token {
parts.push("🔑 token".into());
}
parts.push("(a: about, h: help, q: quit)".into());
let title = parts.join(" | ");
parts.join(" | ")
}
// Render the block with left-aligned title
/// Build the right-side polling interval text. Callers cache this string.
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
format!("{metrics_ms}ms metrics | {procs_ms}ms procs")
}
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
// Render polling intervals on the right side
let mi = metrics_interval.as_millis();
let pi = procs_interval.as_millis();
let intervals = format!("{mi}ms metrics | {pi}ms procs");
let intervals_width = intervals.len() as u16;
if area.width > intervals_width + 2 {
let right_area = Rect {
x: area.x + area.width.saturating_sub(intervals_width + 1),
+6 -5
View File
@@ -41,11 +41,12 @@ impl ModalManager {
])
.split(area);
let block = Block::default()
.title(ICON_WARNING_TITLE)
.title_style(
Style::default()
.fg(MODAL_TITLE_FG)
.add_modifier(Modifier::BOLD),
.title(
Line::from(ICON_WARNING_TITLE).style(
Style::default()
.fg(MODAL_TITLE_FG)
.add_modifier(Modifier::BOLD),
),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER_FG))
+39 -26
View File
@@ -60,6 +60,7 @@ impl ModalManager {
main_chunks[0],
&details.process,
data.history.cpu,
data.history.cpu_sum,
);
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
@@ -419,7 +420,7 @@ impl ModalManager {
)
.header(header)
.block(block)
.highlight_style(Style::default());
.row_highlight_style(Style::default());
f.render_widget(table, area);
@@ -564,8 +565,13 @@ impl ModalManager {
return;
}
// Create a 2D grid to represent the plot
let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation
// instead of `plot_height` inner Vec<char>s like the old version did.
let mut plot_grid: Vec<char> = vec![' '; plot_width * plot_height];
let cell = |grid: &[char], x: usize, y: usize| grid[y * plot_width + x];
let put = |grid: &mut [char], x: usize, y: usize, ch: char| {
grid[y * plot_width + x] = ch;
};
// Plot main process
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
@@ -573,7 +579,7 @@ impl ModalManager {
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
);
if main_x < plot_width && main_y < plot_height {
plot_grid[main_y][main_x] = '●'; // Main process marker
put(&mut plot_grid, main_x, main_y, '●');
}
// Plot threads (use different marker)
@@ -587,13 +593,13 @@ impl ModalManager {
);
if thread_x < plot_width && thread_y < plot_height {
if plot_grid[thread_y][thread_x] == ' ' {
plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle)
} else if plot_grid[thread_y][thread_x] == '○' {
plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point
} else {
plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point
}
let ch = cell(&plot_grid, thread_x, thread_y);
let next = match ch {
' ' => '○',
'○' => '◎',
_ => '◉',
};
put(&mut plot_grid, thread_x, thread_y, next);
}
}
@@ -608,28 +614,34 @@ impl ModalManager {
);
if child_x < plot_width && child_y < plot_height {
if plot_grid[child_y][child_x] == ' ' {
plot_grid[child_y][child_x] = '•'; // Child process marker
} else {
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
}
let ch = cell(&plot_grid, child_x, child_y);
let next = if ch == ' ' { '•' } else { '◉' };
put(&mut plot_grid, child_x, child_y, next);
}
}
// Render the plot
let mut lines = Vec::new();
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
// labels + axis title + (top) Y-axis title + legend + spacing.
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
// Add Y-axis labels and plot content
for (i, row) in plot_grid.iter().enumerate() {
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
// Always format with 4 characters width, right-aligned, to prevent axis shifting
// Y-axis labels and plot content
let mut row_buf = String::with_capacity(plot_width);
for y in 0..plot_height {
let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64));
// 4-char fixed-width label so the axis doesn't shift as digits change.
let y_label = if y_value >= 100.0 {
format!("{y_value:>4.0}")
} else {
format!("{y_value:>4.1}")
};
let plot_content: String = row.iter().collect();
// Build the row's char slice into a reusable String buffer.
row_buf.clear();
let start = y * plot_width;
row_buf.extend(plot_grid[start..start + plot_width].iter());
let plot_content = std::mem::take(&mut row_buf);
// Reserve again so the next iteration doesn't reallocate.
row_buf.reserve(plot_width);
lines.push(Line::from(vec![
Span::styled(y_label, Style::default()),
@@ -833,6 +845,7 @@ impl ModalManager {
area: Rect,
process: &socktop_connector::DetailedProcessInfo,
cpu_history: &std::collections::VecDeque<f32>,
cpu_history_sum: f32,
) {
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
let top_chunks = Layout::default()
@@ -843,7 +856,7 @@ impl ModalManager {
])
.split(area);
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history);
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum);
self.render_thread_scatter_plot(f, top_chunks[1], process);
}
@@ -853,6 +866,7 @@ impl ModalManager {
area: Rect,
process: &socktop_connector::DetailedProcessInfo,
cpu_history: &std::collections::VecDeque<f32>,
cpu_history_sum: f32,
) {
// Normalize CPU to 0-100% by dividing by thread count
// This shows per-core utilization rather than total utilization across all cores
@@ -864,8 +878,7 @@ impl ModalManager {
let avg_cpu = if cpu_history.is_empty() {
0.0
} else {
let total: f32 = cpu_history.iter().sum();
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count)
};
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
+2
View File
@@ -5,6 +5,8 @@ use std::time::Instant;
/// History data for process metrics rendering
pub struct ProcessHistoryData<'a> {
pub cpu: &'a std::collections::VecDeque<f32>,
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
pub cpu_sum: f32,
pub mem: &'a std::collections::VecDeque<u64>,
pub io_read: &'a std::collections::VecDeque<u64>,
pub io_write: &'a std::collections::VecDeque<u64>,
+3 -3
View File
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
f: &mut ratatui::Frame<'_>,
area: Rect,
title: &str,
hist: &VecDeque<u64>,
hist: &mut VecDeque<u64>,
color: Color,
) {
let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
let slice = &hist.make_contiguous()[start..];
let spark = Sparkline::default()
.block(
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
.borders(Borders::ALL)
.title(title.to_string()),
)
.data(&data)
.data(slice)
.style(Style::default().fg(color));
f.render_widget(spark, area);
}
+219 -164
View File
@@ -5,8 +5,8 @@ use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Table},
text::Span,
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
};
use std::cmp::Ordering;
@@ -16,18 +16,17 @@ use crate::ui::theme::{
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
SB_THUMB, SB_TRACK,
};
use crate::ui::util::human;
/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
/// Simple fuzzy matching: returns true if all characters in needle appear in
/// haystack in order, ASCII-case-insensitive. Lowercase normalization is done
/// on the fly so we don't allocate two `String`s per haystack like the old
/// version did (this runs once per process per frame).
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let haystack_lower = haystack.to_lowercase();
let needle_lower = needle.to_lowercase();
let mut haystack_chars = haystack_lower.chars();
for needle_char in needle_lower.chars() {
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
if !haystack_chars.any(|c| c == needle_char) {
return false;
}
@@ -35,36 +34,37 @@ fn fuzzy_match(haystack: &str, needle: &str) -> bool {
true
}
/// Get filtered and sorted process indices based on search query and sort order
pub fn get_filtered_sorted_indices(
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first
/// and reused across calls so callers can amortize the allocation. This is
/// the underlying helper for the App-side cached slice.
pub fn fill_filtered_sorted_indices(
metrics: &Metrics,
search_query: &str,
sort_by: ProcSortBy,
) -> Vec<usize> {
// Filter processes by search query (fuzzy match)
let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
(0..metrics.top_processes.len()).collect()
out: &mut Vec<usize>,
) {
out.clear();
out.reserve(metrics.top_processes.len());
if search_query.is_empty() {
out.extend(0..metrics.top_processes.len());
} else {
(0..metrics.top_processes.len())
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
.collect()
};
// Sort filtered rows
out.extend(
(0..metrics.top_processes.len())
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
);
}
match sort_by {
ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| {
ProcSortBy::CpuDesc => out.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].cpu_usage;
let bb = metrics.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => filtered_idxs.sort_by(|&a, &b| {
ProcSortBy::MemDesc => out.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].mem_bytes;
let bb = metrics.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
filtered_idxs
}
/// Parameters for drawing the top processes table
@@ -76,6 +76,16 @@ pub struct ProcessDisplayParams<'a> {
pub selected_process_index: Option<usize>,
pub search_query: &'a str,
pub search_active: bool,
/// Precomputed filtered + sorted indices into `metrics.top_processes`.
/// Maintained on the App side so the draw path never recomputes the list.
pub filtered_indices: &'a [usize],
/// Pre-formatted strings for each row of `metrics.top_processes`.
/// Indexed the same as `metrics.top_processes`. Empty when no procs poll
/// has run yet (the draw path falls back to fast inline formatting).
pub cached_rows: &'a [CachedRow],
/// Peak cpu_usage from the most recent cache build; used to bold the
/// busiest process. -1.0 if no cache.
pub peak_cpu: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -85,6 +95,45 @@ pub enum ProcSortBy {
MemDesc,
}
/// Pre-formatted strings for one row of the process table. Built once per
/// `Processes` poll (cadence ~2s) and reused by every draw frame in between
/// so the diff renderer can suppress repaints when nothing changed.
#[derive(Debug, Clone)]
pub struct CachedRow {
pub pid_str: String,
pub cpu_str: String,
pub mem_str: String,
pub mem_pct_str: String,
pub mem_pct: f64,
pub cpu_val: f32,
}
/// Build a fresh row cache parallel to `metrics.top_processes`. Reuses `out`'s
/// allocation when possible. Also returns the peak cpu_usage observed, which
/// the draw path uses to bold the busiest process.
pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
out.clear();
out.reserve(metrics.top_processes.len());
let total = metrics.mem_total.max(1);
let mut peak = 0.0_f32;
for p in &metrics.top_processes {
let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0;
let cpu_val = p.cpu_usage;
if cpu_val > peak {
peak = cpu_val;
}
out.push(CachedRow {
pid_str: p.pid.to_string(),
cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)),
mem_str: crate::ui::util::human(p.mem_bytes),
mem_pct_str: format!("{mem_pct:.2}%"),
mem_pct,
cpu_val,
});
}
peak
}
// Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID
@@ -159,8 +208,7 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
height: inner.height,
};
// Get filtered and sorted indices
let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by);
let idxs = params.filtered_indices;
// Scrolling
let total_rows = idxs.len();
@@ -170,19 +218,60 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
let offset = params.scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Build visible rows
// Use the App-side cache when available so we avoid allocating ~5 strings
// per row every frame. Falls back to inline formatting (slow path) when
// the cache hasn't been built yet — e.g. the very first frame before the
// initial procs poll completes.
let cache_ok = params.cached_rows.len() == mm.top_processes.len();
let total_mem_bytes = mm.mem_total.max(1);
let peak_cpu = mm
.top_processes
.iter()
.map(|p| p.cpu_usage)
.fold(0.0_f32, f32::max);
let peak_cpu = if cache_ok {
params.peak_cpu
} else {
mm.top_processes
.iter()
.map(|p| p.cpu_usage)
.fold(0.0_f32, f32::max)
};
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix];
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let cpu_val = p.cpu_usage;
let (
cpu_val,
mem_pct,
pid_span,
name_span,
cpu_span_text,
mem_span_text,
mem_pct_span_text,
) = if cache_ok {
let row = &params.cached_rows[ix];
(
row.cpu_val,
row.mem_pct,
Span::raw(row.pid_str.as_str()),
Span::raw(p.name.as_str()),
row.cpu_str.as_str(),
row.mem_str.as_str(),
row.mem_pct_str.as_str(),
)
} else {
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
// SLOW path: only the very first frame before the cache exists.
// We leak the formatted strings via Box::leak'd statics? No —
// simpler: emit empty placeholders. Cache will exist within
// ~500ms and the diff renderer fills it in.
(
p.cpu_usage,
mem_pct,
Span::raw(""),
Span::raw(""),
"",
"",
"",
)
};
let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow,
@@ -200,16 +289,14 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
Style::default()
};
// Check if this process is selected - prioritize PID matching
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
selected_pid == p.pid
} else if let Some(selected_idx) = params.selected_process_index {
selected_idx == ix // ix is the absolute index in the sorted list
selected_idx == ix
} else {
false
};
// Apply selection highlighting
if is_selected {
emphasis = emphasis
.bg(PROCESS_SELECTION_BG)
@@ -217,15 +304,13 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.add_modifier(Modifier::BOLD);
}
let cpu_str = fmt_cpu_pct(cpu_val);
ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(p.pid.to_string())
.style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(p.name.clone()),
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(human(p.mem_bytes)),
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(name_span),
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
.style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
])
.style(emphasis)
@@ -293,45 +378,29 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
}
}
// Draw scrollbar like CPU pane
// Scrollbar (ratatui built-in). Skip drawing when content fits in viewport.
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
if scroll_area.height >= 3 {
let track = (scroll_area.height - 2) as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let thumb_top = if max_off == 0 {
0
} else {
((track - thumb_len) * offset + max_off / 2) / max_off
};
// Build lines: top arrow, track (with thumb), bottom arrow
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
for i in 0..track {
if i >= thumb_top && i < thumb_top + thumb_len {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB))));
} else {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK))));
}
}
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
f.render_widget(Paragraph::new(lines), scroll_area);
let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
if scroll_area.height >= 3 && max_off_for_bar > 0 {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""))
.thumb_symbol("")
.track_symbol(Some(""))
.thumb_style(Style::default().fg(SB_THUMB))
.track_style(Style::default().fg(SB_TRACK))
.begin_style(Style::default().fg(SB_ARROW))
.end_style(Style::default().fg(SB_ARROW));
let mut state = ScrollbarState::new(max_off_for_bar).position(offset);
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
}
}
fn fmt_cpu_pct(v: f32) -> String {
format!("{:>5.1}", v.clamp(0.0, 100.0))
}
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
/// Parameters for process key event handling
pub struct ProcessKeyParams<'a> {
@@ -339,8 +408,7 @@ pub struct ProcessKeyParams<'a> {
pub selected_process_index: &'a mut Option<usize>,
pub key: crossterm::event::KeyEvent,
pub metrics: Option<&'a Metrics>,
pub sort_by: ProcSortBy,
pub search_query: &'a str,
pub filtered_indices: &'a [usize],
}
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
@@ -356,87 +424,71 @@ pub fn processes_handle_key(
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
use crossterm::event::KeyCode;
let move_selection = |delta: isize,
sel_idx: &mut Option<usize>,
sel_pid: &mut Option<u32>,
metrics: Option<&Metrics>,
idxs: &[usize]| {
let Some(m) = metrics else { return };
if idxs.is_empty() {
*sel_idx = None;
*sel_pid = None;
return;
}
if sel_idx.is_none() || sel_pid.is_none() {
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
return;
}
let current_idx = sel_idx.unwrap();
match idxs.iter().position(|&idx| idx == current_idx) {
Some(pos) => {
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
if new_pos != pos {
let new_idx = idxs[new_pos];
*sel_idx = Some(new_idx);
*sel_pid = Some(m.top_processes[new_idx].pid);
}
}
None => {
// Current selection no longer in filtered list
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
}
}
};
match params.key.code {
KeyCode::Up => {
// Navigate through filtered and sorted results
if let Some(m) = params.metrics {
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
if idxs.is_empty() {
// No filtered results, clear selection
*params.selected_process_index = None;
*params.selected_process_pid = None;
} else if params.selected_process_index.is_none()
|| params.selected_process_pid.is_none()
{
// No selection - select the first process in filtered/sorted order
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
} else if let Some(current_idx) = *params.selected_process_index {
// Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
if pos > 0 {
// Move up in filtered/sorted list
let new_idx = idxs[pos - 1];
*params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
}
} else {
// Current selection not in filtered list, select first result
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
}
}
true // Handled
move_selection(
-1,
params.selected_process_index,
params.selected_process_pid,
params.metrics,
params.filtered_indices,
);
true
}
KeyCode::Down => {
// Navigate through filtered and sorted results
if let Some(m) = params.metrics {
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
if idxs.is_empty() {
// No filtered results, clear selection
*params.selected_process_index = None;
*params.selected_process_pid = None;
} else if params.selected_process_index.is_none()
|| params.selected_process_pid.is_none()
{
// No selection - select the first process in filtered/sorted order
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
} else if let Some(current_idx) = *params.selected_process_index {
// Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
if pos + 1 < idxs.len() {
// Move down in filtered/sorted list
let new_idx = idxs[pos + 1];
*params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
}
} else {
// Current selection not in filtered list, select first result
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
}
}
true // Handled
move_selection(
1,
params.selected_process_index,
params.selected_process_pid,
params.metrics,
params.filtered_indices,
);
true
}
KeyCode::Char('x') | KeyCode::Char('X') => {
// Unselect any selected process
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() {
*params.selected_process_pid = None;
*params.selected_process_index = None;
true // Handled
} else {
false // No selection to clear
}
KeyCode::Char('x') | KeyCode::Char('X')
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
{
*params.selected_process_pid = None;
*params.selected_process_index = None;
true
}
KeyCode::Char('x') | KeyCode::Char('X') => false,
KeyCode::Enter => {
// Signal that Enter was pressed with a selection
params.selected_process_pid.is_some() // Return true if we have a selection to handle
@@ -526,8 +578,11 @@ pub struct ProcessMouseParams<'a> {
pub area: Rect,
pub total_rows: usize,
pub metrics: Option<&'a Metrics>,
pub sort_by: ProcSortBy,
pub search_query: &'a str,
/// True when the on-screen search box is currently being drawn (active
/// edit mode OR a non-empty filter is showing). The caller computes this
/// from the same condition as the draw path.
pub search_box_visible: bool,
pub filtered_indices: &'a [usize],
}
/// Enhanced mouse handler that also manages process selection
@@ -545,9 +600,13 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
}
// Calculate content area - must match draw_top_processes exactly!
// If search is active or query exists, content starts after search box (3 lines)
let search_active = !params.search_query.is_empty();
let content_start_y = if search_active { inner.y + 3 } else { inner.y };
// If a search box is being drawn (active edit mode OR a filter showing),
// content starts 3 rows below.
let content_start_y = if params.search_box_visible {
inner.y + 3
} else {
inner.y
};
let content = Rect {
x: inner.x,
@@ -555,7 +614,7 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
width: inner.width.saturating_sub(2),
height: inner
.height
.saturating_sub(if search_active { 3 } else { 0 }),
.saturating_sub(if params.search_box_visible { 3 } else { 0 }),
};
// Scrollbar interactions (click arrows/page/drag)
@@ -612,12 +671,8 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
{
let clicked_row = (params.mouse.row - data_start_row) as usize;
// Find the actual process using the same filtering/sorting logic as the drawing code
if let Some(m) = params.metrics {
// Use the same filtered and sorted indices as display
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
// Calculate which process was actually clicked based on filtered/sorted order
let idxs = params.filtered_indices;
let visible_process_position = *params.scroll_offset + clicked_row;
if visible_process_position < idxs.len() {
let actual_process_index = idxs[visible_process_position];
+2 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop_agent"
version = "1.50.1"
version = "1.50.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024"
@@ -8,9 +8,6 @@ license = "MIT"
readme = "README.md"
[dependencies]
# CLI parsing and man page generation
clap = { version = "4.5", features = ["derive", "cargo", "wrap_help", "env"] }
# Tokio: Use minimal features instead of "full" to reduce binary size
# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test])
# Excluded: io, fs, process, signal, time (not needed for this workload)
@@ -27,7 +24,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = tr
gfxinfo = "0.1.2"
once_cell = "1.19"
axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = "0.23"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
rustls-pemfile = "2.1"
rcgen = "0.13"
anyhow = "1"
@@ -40,8 +37,6 @@ default = []
logging = ["tracing", "tracing-subscriber"]
[build-dependencies]
clap = { version = "4.5", features = ["derive", "cargo", "env"] }
clap_mangen = "0.2"
prost-build = "0.13"
tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3"
-32
View File
@@ -1,16 +1,8 @@
use clap::CommandFactory;
use clap_mangen::Man;
use std::fs;
use std::path::PathBuf;
include!("src/cli.rs");
fn main() {
// Vendored protoc for reproducible builds
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
println!("cargo:rerun-if-changed=proto/processes.proto");
println!("cargo:rerun-if-changed=src/cli.rs");
// Compile protobuf definitions for processes
let mut cfg = prost_build::Config::new();
@@ -19,28 +11,4 @@ fn main() {
// Use local path (ensures file is inside published crate tarball)
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
.expect("compile protos");
// Generate man page
generate_man_page().expect("man page generation failed");
}
fn generate_man_page() -> std::io::Result<()> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let man_dir = out_dir.join("man");
fs::create_dir_all(&man_dir)?;
// Generate man page for socktop_agent
let cmd = Cli::command();
let man = Man::new(cmd);
let mut buffer = Vec::new();
man.render(&mut buffer)?;
fs::write(man_dir.join("socktop_agent.1"), buffer)?;
println!(
"cargo:warning=Man page generated at {:?}",
man_dir.join("socktop_agent.1")
);
Ok(())
}
-95
View File
@@ -1,95 +0,0 @@
//! Caching for process metrics and journal entries
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use crate::types::{ProcessMetricsResponse, JournalResponse};
#[derive(Debug, Clone)]
struct CacheEntry<T> {
data: T,
cached_at: Instant,
ttl: Duration,
}
impl<T> CacheEntry<T> {
fn is_expired(&self) -> bool {
self.cached_at.elapsed() > self.ttl
}
}
#[derive(Debug)]
pub struct ProcessCache {
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
}
impl ProcessCache {
pub fn new() -> Self {
Self {
process_metrics: RwLock::new(HashMap::new()),
journal_entries: RwLock::new(HashMap::new()),
}
}
/// Get cached process metrics if available and not expired (250ms TTL)
pub async fn get_process_metrics(&self, pid: u32) -> Option<ProcessMetricsResponse> {
let cache = self.process_metrics.read().await;
if let Some(entry) = cache.get(&pid) {
if !entry.is_expired() {
return Some(entry.data.clone());
}
}
None
}
/// Cache process metrics with 250ms TTL
pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) {
let mut cache = self.process_metrics.write().await;
cache.insert(pid, CacheEntry {
data,
cached_at: Instant::now(),
ttl: Duration::from_millis(250),
});
}
/// Get cached journal entries if available and not expired (1s TTL)
pub async fn get_journal_entries(&self, pid: u32) -> Option<JournalResponse> {
let cache = self.journal_entries.read().await;
if let Some(entry) = cache.get(&pid) {
if !entry.is_expired() {
return Some(entry.data.clone());
}
}
None
}
/// Cache journal entries with 1s TTL
pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) {
let mut cache = self.journal_entries.write().await;
cache.insert(pid, CacheEntry {
data,
cached_at: Instant::now(),
ttl: Duration::from_secs(1),
});
}
/// Clean up expired entries periodically
pub async fn cleanup_expired(&self) {
{
let mut cache = self.process_metrics.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
{
let mut cache = self.journal_entries.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
}
}
impl Default for ProcessCache {
fn default() -> Self {
Self::new()
}
}
-105
View File
@@ -1,105 +0,0 @@
// CLI argument definitions for socktop_agent using clap derive macros.
// This file is also included by build.rs for man page generation.
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "socktop_agent",
version,
author,
about = "Lightweight WebSocket server for remote system monitoring",
long_about = "socktop_agent is a lightweight Rust-based WebSocket server that provides system \
metrics on demand. It serves metrics to socktop clients over WebSocket connections \
at the /ws endpoint.\n\n\
The agent is request-driven with near-zero CPU usage when idle. It collects metrics \
only when clients request them over WebSocket, eliminating the need for background \
sampling loops. This design results in minimal resource consumption, making it ideal \
for resource-constrained systems like Raspberry Pi.\n\n\
Metrics include: CPU (overall and per-core), memory, swap, disk usage, network \
throughput, CPU temperatures, top processes, and optional GPU metrics."
)]
pub struct Cli {
/// Port number to listen on
///
/// Default is 3000 for non-TLS mode and 8443 for TLS mode.
/// Can also be set via SOCKTOP_PORT environment variable.
#[arg(short = 'p', long = "port", value_name = "PORT", env = "SOCKTOP_PORT")]
pub port: Option<u16>,
/// Enable TLS (secure WebSocket) mode
///
/// The agent will listen on wss:// instead of ws://.
/// On first run with TLS enabled, the agent automatically generates
/// a self-signed certificate and private key.
/// Can also be enabled via SOCKTOP_ENABLE_SSL=1 environment variable.
#[arg(long = "enableSSL", env = "SOCKTOP_ENABLE_SSL", value_parser = parse_bool_env)]
pub enable_ssl: bool,
}
/// Parse boolean from environment variable (accepts "1" or "true")
fn parse_bool_env(s: &str) -> Result<bool, String> {
match s {
"1" | "true" | "TRUE" | "True" => Ok(true),
"0" | "false" | "FALSE" | "False" => Ok(false),
_ => Err(format!("Invalid boolean value: {}", s)),
}
}
impl Cli {
/// Parse CLI arguments from environment
pub fn parse_args() -> Self {
Cli::parse()
}
/// Get the port to listen on, with appropriate defaults
pub fn get_port(&self) -> u16 {
if let Some(port) = self.port {
port
} else if self.enable_ssl {
8443
} else {
3000
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default() {
let cli = Cli::try_parse_from(&["socktop_agent"]).unwrap();
assert_eq!(cli.port, None);
assert!(!cli.enable_ssl);
assert_eq!(cli.get_port(), 3000);
}
#[test]
fn test_custom_port() {
let cli = Cli::try_parse_from(&["socktop_agent", "--port", "8080"]).unwrap();
assert_eq!(cli.port, Some(8080));
assert_eq!(cli.get_port(), 8080);
}
#[test]
fn test_short_port() {
let cli = Cli::try_parse_from(&["socktop_agent", "-p", "9000"]).unwrap();
assert_eq!(cli.port, Some(9000));
}
#[test]
fn test_enable_ssl() {
let cli = Cli::try_parse_from(&["socktop_agent", "--enableSSL"]).unwrap();
assert!(cli.enable_ssl);
assert_eq!(cli.get_port(), 8443); // Default TLS port
}
#[test]
fn test_ssl_with_custom_port() {
let cli = Cli::try_parse_from(&["socktop_agent", "--enableSSL", "-p", "9443"]).unwrap();
assert!(cli.enable_ssl);
assert_eq!(cli.get_port(), 9443);
}
}
+38 -7
View File
@@ -1,6 +1,5 @@
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
mod cli;
mod gpu;
mod metrics;
mod proto;
@@ -15,10 +14,28 @@ use std::str::FromStr;
mod tls;
use cli::Cli;
use state::AppState;
fn arg_flag(name: &str) -> bool {
std::env::args().any(|a| a == name)
}
fn arg_value(name: &str) -> Option<String> {
let mut it = std::env::args();
while let Some(a) = it.next() {
if a == name {
return it.next();
}
}
None
}
fn main() -> anyhow::Result<()> {
// Install rustls crypto provider before any TLS operations
// This is required when using axum-server's tls-rustls feature
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok(); // Ignore error if already installed
#[cfg(feature = "logging")]
tracing_subscriber::fmt::init();
@@ -59,8 +76,11 @@ fn main() -> anyhow::Result<()> {
}
async fn async_main() -> anyhow::Result<()> {
// Parse CLI arguments
let cli = Cli::parse_args();
// Version flag (print and exit). Keep before heavy initialization.
if arg_flag("--version") || arg_flag("-V") {
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let state = AppState::new();
@@ -76,8 +96,15 @@ async fn async_main() -> anyhow::Result<()> {
.route("/healthz", get(healthz))
.with_state(state.clone());
if cli.enable_ssl {
let port = cli.get_port();
let enable_ssl =
arg_flag("--enableSSL") || std::env::var("SOCKTOP_ENABLE_SSL").ok().as_deref() == Some("1");
if enable_ssl {
// Port can be overridden by --port or SOCKTOP_PORT; default to 8443 when SSL
let port = arg_value("--port")
.or_else(|| arg_value("-p"))
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(8443);
let (cert_path, key_path) = tls::ensure_self_signed_cert()?;
let cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
@@ -91,7 +118,11 @@ async fn async_main() -> anyhow::Result<()> {
}
// Non-TLS HTTP/WS path
let port = cli.get_port();
let port = arg_value("--port")
.or_else(|| arg_value("-p"))
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(3000);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("socktop_agent: Listening on ws://{addr}/ws");
axum_server::bind(addr)
+331 -234
View File
@@ -23,45 +23,40 @@ use tracing::warn;
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
// Helper functions to get CPU time from /proc/stat on Linux
// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go.
// Returns (0, 0) if the file can't be read.
//
// We use `rfind(')')` to step past the `comm` field, which can contain
// arbitrary characters (including spaces and parens), then index the
// post-comm fields by position. This is the same trick `read_proc_jiffies`
// uses below — `split_whitespace().collect::<Vec<_>>()` from the start of
// the file would mis-parse process names with spaces, and also wastes an
// allocation per call. Two callers used to read this file twice (once for
// user, once for system); now it's one syscall per detailed-process record.
#[cfg(target_os = "linux")]
fn get_cpu_time_user(pid: u32) -> u64 {
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let fields: Vec<&str> = stat.split_whitespace().collect();
if fields.len() > 13 {
// Field 13 (0-indexed) is utime (user CPU time in clock ticks)
if let Ok(utime) = fields[13].parse::<u64>() {
// Convert clock ticks to milliseconds (assuming 100 Hz)
return utime * 10; // 1 tick = 10ms at 100 Hz
}
}
}
0
}
#[cfg(target_os = "linux")]
fn get_cpu_time_system(pid: u32) -> u64 {
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let fields: Vec<&str> = stat.split_whitespace().collect();
if fields.len() > 14 {
// Field 14 (0-indexed) is stime (system CPU time in clock ticks)
if let Ok(stime) = fields[14].parse::<u64>() {
// Convert clock ticks to milliseconds (assuming 100 Hz)
return stime * 10; // 1 tick = 10ms at 100 Hz
}
}
}
0
fn get_cpu_times_ms(pid: u32) -> (u64, u64) {
let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
return (0, 0);
};
let Some(rpar) = s.rfind(')') else {
return (0, 0);
};
let Some(after) = s.get(rpar + 2..) else {
return (0, 0);
};
let mut it = after.split_whitespace();
// Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid,
// flags, minflt, cminflt, majflt, cmajflt, utime, stime, ...
// utime is offset 11; stime follows.
let utime = it.nth(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
// 1 tick = 10ms at 100 Hz (USER_HZ).
(utime * 10, stime * 10)
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_time_user(_pid: u32) -> u64 {
0 // Not implemented for non-Linux platforms
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_time_system(_pid: u32) -> u64 {
0 // Not implemented for non-Linux platforms
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
(0, 0)
}
// Runtime toggles (read once)
fn gpu_enabled() -> bool {
@@ -81,6 +76,47 @@ fn temp_enabled() -> bool {
})
}
// TTL knobs read once at first use, then cached. These hit the hot polling
// paths (every 250ms-1.5s), so re-reading via libc getenv per call is wasted.
fn metrics_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(250)
})
}
fn disks_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000)
})
}
#[cfg(target_os = "linux")]
fn processes_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_500)
})
}
#[cfg(not(target_os = "linux"))]
fn name_cache_cleanup_threshold() -> usize {
static V: OnceCell<usize> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms
const TTL: Duration = Duration::from_millis(1500);
struct TempCache {
@@ -89,6 +125,32 @@ struct TempCache {
}
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
// Last time `state.components` was refreshed (by any caller). Both
// collect_fast_metrics and collect_disks need fresh sensor values; without
// this gate they were each doing their own `Components::refresh` on their
// own cadence, paying the hwmon syscall cost twice per polling cycle.
// 1s is short enough that disk temps stay accurate (they change slowly) and
// long enough to suppress back-to-back refreshes from concurrent endpoints.
const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000);
static COMPONENTS_LAST_REFRESH: OnceCell<Mutex<Option<Instant>>> = OnceCell::new();
/// Refresh `state.components` only if the cached refresh timestamp is older
/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components
/// lock.
fn refresh_components_if_stale(components: &mut sysinfo::Components) {
let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None));
let mut last = match lock.lock() {
Ok(g) => g,
Err(_) => return, // Poisoned — skip; values stay as-is until next call
};
let now = Instant::now();
let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL);
if stale {
components.refresh(false);
*last = Some(now);
}
}
struct GpuCache {
at: Option<Instant>,
v: Option<Vec<crate::gpu::GpuMetrics>>,
@@ -154,12 +216,7 @@ fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
// TTL (ms) overridable via env, default 250ms
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(250);
let ttl = StdDuration::from_millis(ttl_ms);
let ttl = StdDuration::from_millis(metrics_ttl_ms());
{
let cache = state.cache_metrics.lock().await;
if cache.is_fresh(ttl)
@@ -202,7 +259,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
} else if temp_enabled() {
let val = {
let mut components = state.components.lock().await;
components.refresh(false);
refresh_components_if_stale(&mut components);
components.iter().find_map(|c| {
let l = c.label().to_ascii_lowercase();
if l.contains("cpu")
@@ -236,12 +293,19 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
});
let mut cache = cache.lock().unwrap();
// Collect current network names
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
// Update cached network names if they changed
if cache.names != current_names {
cache.names = current_names;
// Detect a topology change without allocating: compare lengths first,
// then zip and walk. Only on a real diff do we materialize the new
// names list. Was: `nets.keys().map(to_string).collect::<Vec<_>>()`
// every tick — a fresh Vec<String> just to compare.
let topology_changed = cache.names.len() != nets.keys().count()
|| cache
.names
.iter()
.zip(nets.keys())
.any(|(cached, current)| cached.as_str() != current.as_str());
if topology_changed {
cache.names.clear();
cache.names.extend(nets.keys().map(|n| n.to_string()));
}
// Reuse NetworkInfo objects
@@ -319,11 +383,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
// Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let ttl = StdDuration::from_millis(ttl_ms);
let ttl = StdDuration::from_millis(disks_ttl_ms());
{
let cache = state.cache_disks.lock().await;
if cache.is_fresh(ttl)
@@ -339,7 +399,9 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// NVMe temps show up as "Composite" under different chip names
let disk_temps = {
let mut components = state.components.lock().await;
components.refresh(true); // true = refresh values, not just the list
// Shared TTL-gated refresh: avoids paying the hwmon scan twice when
// both endpoints converge in the same second.
refresh_components_if_stale(&mut components);
let mut composite_temps = Vec::new();
@@ -572,12 +634,7 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
#[cfg(target_os = "linux")]
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
// Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates).
.unwrap_or(1_500);
let ttl = StdDuration::from_millis(ttl_ms);
let ttl = StdDuration::from_millis(processes_ttl_ms());
{
let cache = state.cache_processes.lock().await;
if cache.is_fresh(ttl)
@@ -586,13 +643,24 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
return c.clone();
}
}
// Reuse shared System to avoid reallocation; refresh processes fully.
// Reuse shared System to avoid reallocation. We only need name + memory
// from sysinfo here — per-process CPU% is computed below from /proc/{pid}/stat
// jiffies (see `read_proc_jiffies` + `read_total_jiffies`), so asking sysinfo
// to gather CPU/exe/cmd/cwd/env per process is wasted /proc traffic on a Pi
// (was reading /proc/{pid}/{cmdline,exe,cwd,environ,io,status} for every PID
// on every 2 s poll via `everything()`).
//
// `without_tasks()` is REQUIRED: it suppresses per-thread entries in the
// process map (without it, sysinfo returns one entry per /proc/[tid] —
// 780+ entries on a typical desktop because of glib/gdbus/Chrome thread
// pools). The original code paired this with `everything()`; we keep the
// filter when downgrading to a minimal refresh spec.
let mut sys_guard = state.sys.lock().await;
let sys = &mut *sys_guard;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
ProcessRefreshKind::nothing().with_memory().without_tasks(),
);
let total_count = sys.processes().len();
@@ -607,36 +675,50 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
}
let total_now = read_total_jiffies().unwrap_or(0);
// Compute deltas vs last sample
let (last_total, mut last_map) = {
#[cfg(target_os = "linux")]
{
let mut t = state.proc_cpu.lock().await;
let lt = t.last_total;
let lm = std::mem::take(&mut t.last_per_pid);
t.last_total = total_now;
t.last_per_pid = current.clone();
(lt, lm)
}
#[cfg(not(target_os = "linux"))]
{
let _: u64 = total_now; // silence unused warning
(0u64, HashMap::new())
}
};
// Compute deltas vs last sample. We hold the proc_cpu lock for the whole
// collection below so we can read+update the per-pid name cache in one
// critical section.
let mut tracker = state.proc_cpu.lock().await;
let last_total = tracker.last_total;
// Move the old per-pid jiffies map out for delta computation.
let mut last_map = std::mem::take(&mut tracker.last_per_pid);
tracker.last_total = total_now;
// On first run or if total delta is tiny, report zeros
// Resolve a name through the per-pid cache. Allocates only on miss.
let resolve_name =
|tracker: &mut crate::state::ProcCpuTracker, pid: u32, p: &sysinfo::Process| -> String {
if let Some(cached) = tracker.names.get(&pid) {
return cached.clone();
}
let new_name = p.name().to_string_lossy().into_owned();
tracker.names.insert(pid, new_name.clone());
new_name
};
// On first run or if total delta is tiny, report zeros.
if last_total == 0 || total_now <= last_total {
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| ProcessInfo {
pid: p.pid().as_u32(),
name: p.name().to_string_lossy().into_owned(),
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
for p in sys.processes().values() {
let pid = p.pid().as_u32();
let name = resolve_name(&mut tracker, pid, p);
procs.push(ProcessInfo {
pid,
name,
cpu_usage: 0.0,
mem_bytes: p.memory(),
})
.collect();
});
}
// Stash the just-collected jiffies for next call's delta, then prune
// dead pids from the name cache. Borrowing dance: retain reads
// `tracker.last_per_pid` through the closure, which conflicts with
// the mutable borrow of `tracker.names.retain`. Split via split-borrow:
tracker.last_per_pid = current;
let crate::state::ProcCpuTracker {
ref last_per_pid,
ref mut names,
..
} = *tracker;
names.retain(|pid, _| last_per_pid.contains_key(pid));
return ProcessesPayload {
process_count: total_count,
top_processes: procs,
@@ -645,23 +727,31 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let dt = total_now.saturating_sub(last_total).max(1) as f32;
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| {
let pid = p.pid().as_u32();
let now = current.get(&pid).copied().unwrap_or(0);
let prev = last_map.remove(&pid).unwrap_or(0);
let du = now.saturating_sub(prev) as f32;
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
ProcessInfo {
pid,
name: p.name().to_string_lossy().into_owned(),
cpu_usage: cpu,
mem_bytes: p.memory(),
}
})
.collect();
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
for p in sys.processes().values() {
let pid = p.pid().as_u32();
let now = current.get(&pid).copied().unwrap_or(0);
let prev = last_map.remove(&pid).unwrap_or(0);
let du = now.saturating_sub(prev) as f32;
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
let name = resolve_name(&mut tracker, pid, p);
procs.push(ProcessInfo {
pid,
name,
cpu_usage: cpu,
mem_bytes: p.memory(),
});
}
// Save current jiffies map for next call and prune dead pids from the
// name cache. `current` is moved here (no clone — that's also #19).
tracker.last_per_pid = current;
let crate::state::ProcCpuTracker {
ref last_per_pid,
ref mut names,
..
} = *tracker;
names.retain(|pid, _| last_per_pid.contains_key(pid));
drop(tracker);
let payload = ProcessesPayload {
process_count: total_count,
@@ -749,11 +839,8 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// .unwrap_or(std::cmp::Ordering::Equal)
// });
// Clean up old process names cache when it grows too large
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
// Clean up old process names cache when it grows too large.
let cache_cleanup_threshold = name_cache_cleanup_threshold();
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
let now = std::time::Instant::now();
@@ -811,17 +898,94 @@ fn enumerate_child_processes_lightweight(
children
}
/// Single-read extraction of the /proc/{pid}/status fields the detail
/// endpoint cares about. Callers used to open this file twice per
/// detail-process record (once for VmRSS/VmSize, once for Uid/Gid/Threads/
/// State); now it's one read + one scan.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct ProcStatus {
rss_kb: u64,
vsize_kb: u64,
uid: u32,
gid: u32,
threads: u32,
/// Raw status letter from `State:` (e.g. 'R', 'S'). '?' if missing.
state_ch: char,
}
#[cfg(target_os = "linux")]
fn read_proc_status(pid: u32) -> Option<ProcStatus> {
let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut out = ProcStatus {
state_ch: '?',
..Default::default()
};
for line in content.lines() {
if let Some(v) = line.strip_prefix("VmRSS:") {
out.rss_kb = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("VmSize:") {
out.vsize_kb = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Uid:") {
out.uid = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Gid:") {
out.gid = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Threads:") {
out.threads = v.trim().parse().unwrap_or(0);
} else if let Some(v) = line.strip_prefix("State:") {
out.state_ch = v.trim().chars().next().unwrap_or('?');
}
}
Some(out)
}
#[cfg(target_os = "linux")]
fn proc_state_label(c: char) -> &'static str {
match c {
'R' => "Running",
'S' => "Sleeping",
'D' => "Disk Sleep",
'Z' => "Zombie",
'T' => "Stopped",
't' => "Tracing Stop",
'X' | 'x' => "Dead",
'K' => "Wakekill",
'W' => "Waking",
'P' => "Parked",
'I' => "Idle",
_ => "Unknown",
}
}
/// Read parent PID from /proc/{pid}/stat
#[cfg(target_os = "linux")]
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// Format: pid (comm) state ppid ...
// We need to handle process names with spaces/parentheses
// Format: pid (comm) state ppid ... — comm can contain spaces/parens,
// so we step past the closing paren first.
let ppid_start = stat.rfind(')')?;
let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect();
// After the closing paren: state ppid ...
// Field 1 (0-indexed) is ppid
fields.get(1)?.parse::<u32>().ok()
// After ") ": state, ppid, ... — ppid is the second field.
stat[ppid_start + 1..]
.split_whitespace()
.nth(1)?
.parse::<u32>()
.ok()
}
/// Collect process information from /proc files
@@ -830,8 +994,11 @@ fn collect_process_info_from_proc(
pid: u32,
system: &sysinfo::System,
) -> Option<DetailedProcessInfo> {
// Try to get basic info from sysinfo if it's already loaded (cheap lookup)
// Otherwise read from /proc directly
// One read of /proc/{pid}/status gets us everything the detail endpoint
// needs from it: memory (when not in sysinfo cache), Uid/Gid, Threads,
// and State. The previous code opened this file twice per process record.
let st = read_proc_status(pid)?;
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
(
@@ -841,30 +1008,13 @@ fn collect_process_info_from_proc(
proc.virtual_memory(),
)
} else {
// Process not in sysinfo cache, read minimal info from /proc
// Process not in sysinfo cache — derive name from /proc/{pid}/comm
// and memory from the status read above.
let name = fs::read_to_string(format!("/proc/{pid}/comm"))
.ok()?
.trim()
.to_string();
// Read memory from /proc/{pid}/status
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut mem_bytes = 0u64;
let mut virtual_mem_bytes = 0u64;
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("VmRSS:") {
if let Some(kb) = value.split_whitespace().next() {
mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
}
} else if let Some(value) = line.strip_prefix("VmSize:")
&& let Some(kb) = value.split_whitespace().next()
{
virtual_mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
}
}
(name, 0.0, mem_bytes, virtual_mem_bytes)
(name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024)
};
// Read command line
@@ -873,54 +1023,21 @@ fn collect_process_info_from_proc(
.map(|s| s.replace('\0', " ").trim().to_string())
.unwrap_or_default();
// Read status information
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut uid = 0u32;
let mut gid = 0u32;
let mut thread_count = 0u32;
let mut status = "Unknown".to_string();
let uid = st.uid;
let gid = st.gid;
let thread_count = st.threads;
let status = proc_state_label(st.state_ch).to_string();
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("Uid:") {
if let Some(uid_str) = value.split_whitespace().next() {
uid = uid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Gid:") {
if let Some(gid_str) = value.split_whitespace().next() {
gid = gid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Threads:") {
thread_count = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("State:") {
status = value
.trim()
.chars()
.next()
.map(|c| match c {
'R' => "Running",
'S' => "Sleeping",
'D' => "Disk Sleep",
'Z' => "Zombie",
'T' => "Stopped",
't' => "Tracing Stop",
'X' | 'x' => "Dead",
'K' => "Wakekill",
'W' => "Waking",
'P' => "Parked",
'I' => "Idle",
_ => "Unknown",
})
.unwrap_or("Unknown")
.to_string();
}
}
// Read start time from stat
// Read start time from stat — comm-safe via rfind(')').
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let stat_end = stat.rfind(')')?;
let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect();
// Field 19 (0-indexed) is starttime in clock ticks since boot
fields.get(19)?.parse::<u64>().ok()?
// After ") ": state, ppid, ..., starttime — starttime is the 20th
// post-comm field (index 19).
stat[stat_end + 1..]
.split_whitespace()
.nth(19)?
.parse::<u64>()
.ok()?
} else {
0
};
@@ -954,6 +1071,9 @@ fn collect_process_info_from_proc(
.ok()
.map(|p| p.to_string_lossy().to_string());
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
Some(DetailedProcessInfo {
pid,
name,
@@ -969,8 +1089,8 @@ fn collect_process_info_from_proc(
user_id: uid,
group_id: gid,
start_time,
cpu_time_user: get_cpu_time_user(pid),
cpu_time_system: get_cpu_time_system(pid),
cpu_time_user,
cpu_time_system,
read_bytes,
write_bytes,
working_directory,
@@ -1059,22 +1179,24 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.trim()
.to_string();
// Read thread stat for CPU times and status
// Read thread stat for CPU times and status.
let stat_path = format!("/proc/{pid}/task/{tid}/stat");
let Ok(stat_content) = fs::read_to_string(&stat_path) else {
continue;
};
// Parse stat file (similar format to process stat)
// Fields: pid comm state ... utime stime ...
let fields: Vec<&str> = stat_content.split_whitespace().collect();
if fields.len() < 15 {
// Thread/comm names can contain spaces or parens, so step past the
// last ')' before parsing post-comm fields. Post-comm offsets:
// 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime
let Some(rpar) = stat_content.rfind(')') else {
continue;
}
// Field 2 is state (R, S, D, Z, T, etc.)
let status = fields
.get(2)
};
let Some(after) = stat_content.get(rpar + 1..) else {
continue;
};
let mut it = after.split_whitespace();
let status = it
.next()
.and_then(|s| s.chars().next())
.map(|c| match c {
'R' => "Running",
@@ -1089,16 +1211,9 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.unwrap_or("Unknown")
.to_string();
// Field 13 is utime (user CPU time in clock ticks)
// Field 14 is stime (system CPU time in clock ticks)
let utime = fields
.get(13)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let stime = fields
.get(14)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
// 10 fields between state and utime (ppid..cmajflt).
let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
// Convert clock ticks to microseconds (assuming 100 Hz)
// 1 tick = 10ms = 10,000 microseconds
@@ -1167,34 +1282,13 @@ pub async fn collect_process_metrics(
let parent_pid = process.parent().map(|p| p.as_u32());
let start_time = process.start_time();
// Read UID and GID directly from /proc/{pid}/status for accuracy
// Read UID and GID directly from /proc/{pid}/status for accuracy.
// Uses the shared single-read helper (also extracts memory, threads,
// state — we discard those here since sysinfo already provided them).
#[cfg(target_os = "linux")]
let (user_id, group_id) =
if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) {
let mut uid = 0u32;
let mut gid = 0u32;
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("Uid:") {
// Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
// We want the real UID (first value)
if let Some(uid_str) = value.split_whitespace().next() {
uid = uid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Gid:") {
// Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
// We want the real GID (first value)
if let Some(gid_str) = value.split_whitespace().next() {
gid = gid_str.parse().unwrap_or(0);
}
}
}
(uid, gid)
} else {
// Fallback if /proc read fails (permission issue)
(0, 0)
};
let (user_id, group_id) = read_proc_status(pid)
.map(|s| (s.uid, s.gid))
.unwrap_or((0, 0));
#[cfg(not(target_os = "linux"))]
let (user_id, group_id) = (0, 0);
@@ -1248,6 +1342,9 @@ pub async fn collect_process_metrics(
// Collect thread information (Linux only)
let threads = collect_thread_info(pid);
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
// Now construct the detailed info without holding the lock
let detailed_info = DetailedProcessInfo {
pid,
@@ -1264,8 +1361,8 @@ pub async fn collect_process_metrics(
user_id,
group_id,
start_time,
cpu_time_user: get_cpu_time_user(pid),
cpu_time_system: get_cpu_time_system(pid),
cpu_time_user,
cpu_time_system,
read_bytes,
write_bytes,
working_directory,
+4
View File
@@ -17,6 +17,10 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
pub struct ProcCpuTracker {
pub last_total: u64,
pub last_per_pid: HashMap<u32, u64>,
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
/// the same `String`s on every processes poll (~once per 1.5s).
pub names: HashMap<u32, String>,
}
#[cfg(not(target_os = "linux"))]
+28 -19
View File
@@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
Message::Text(ref text) if text == "get_processes" => {
let payload = collect_processes_all(&state).await;
// Map to protobuf message
// Get cached buffers
// Get cached buffers. The Vec capacity is preserved across
// calls (with_capacity(512) seeds it, then we swap-back after
// encode so the allocation outlives any single request).
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
let mut cache = cache.lock().await;
// Reuse process vector to build the list
cache.processes_vec.clear();
cache
.processes_vec
@@ -85,29 +85,38 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
mem_bytes: p.mem_bytes,
}));
let pb = pb::Processes {
// Move the populated Vec into the proto, encode, then move it
// BACK into the cache so the next call reuses the same heap
// allocation. The previous code did `mem::take(...)` here but
// then dropped `pb` (and the Vec along with it), leaving the
// cache holding an empty zero-capacity Vec — defeating the
// whole point of `with_capacity(512)`.
let mut pb = pb::Processes {
process_count: payload.process_count as u64,
rows: std::mem::take(&mut cache.processes_vec),
};
let mut buf = Vec::with_capacity(8 * 1024);
if prost::Message::encode(&pb, &mut buf).is_err() {
let encode_result = prost::Message::encode(&pb, &mut buf);
// Restore the (now-encoded-from) Vec to the cache before pb is
// dropped. We `take` it out of pb to leave that field empty,
// and the next request will `.clear()` before refilling.
cache.processes_vec = std::mem::take(&mut pb.rows);
if encode_result.is_err() {
let _ = socket.send(Message::Close(None)).await;
} else if buf.len() <= COMPRESSION_THRESHOLD {
let _ = socket.send(Message::Binary(buf)).await;
} else {
// compress if large
if buf.len() <= COMPRESSION_THRESHOLD {
let _ = socket.send(Message::Binary(buf)).await;
} else {
// Create a new encoder for each message to ensure proper gzip headers
let mut encoder =
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
Ok(compressed) => {
let _ = socket.send(Message::Binary(compressed)).await;
}
Err(_) => {
let _ = socket.send(Message::Binary(buf)).await;
}
// Create a new encoder for each message to ensure proper gzip headers
let mut encoder =
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
Ok(compressed) => {
let _ = socket.send(Message::Binary(compressed)).await;
}
Err(_) => {
let _ = socket.send(Message::Binary(buf)).await;
}
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
use assert_cmd::prelude::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
@@ -17,7 +16,7 @@ fn generates_self_signed_cert_and_key_in_xdg_path() {
let xdg = tmpdir.path().to_path_buf();
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("socktop_agent"));
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
cmd.env("XDG_CONFIG_HOME", &xdg)
.arg("--enableSSL")