Compare commits

...

71 Commits

Author SHA1 Message Date
jason 660474a6ce ci cleanup
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-20 20:36:49 -07:00
jason 93dd14967d try/fix windows again ! 2025-08-20 16:32:51 -07:00
jason 923a3872fe add logging to help debug windows problems 2025-08-20 15:47:28 -07:00
jason 5f10e34341 windows try/fix 2025-08-20 15:20:07 -07:00
jason b80d322650 cargo fmt 2025-08-20 11:29:22 -07:00
jason fff386f9d5 fixing windows build problems. i hate windows !
Agent:
Added GET /healthz that returns 200 immediately.
File: main.rs (router now includes /healthz).
CI workflow:
Start agent from target/release on both OSes.
Set SOCKTOP_ENABLE_SSL=0 explicitly.
Ubuntu: wait on curl http://127.0.0.1:3000/healthz (60s), log tail and ss/netstat on failure.
Windows: wait on Invoke-WebRequest to /healthz (60s), capture stdout/stderr, print netstat on failure.
File: .github/workflows/ci.yml.
2025-08-20 11:26:09 -07:00
jason 93f4e1feea fix windows build after ssl feature and optimize build 2025-08-20 10:24:24 -07:00
jason 97255b42fb fix windows build 2025-08-20 00:14:21 -07:00
jason 554a2c349f protobuff Process list
BREAKING: Process list over WS is now Protocol Buffers; client required.
Agent: returns all processes (no server-side top-k); large payloads gzip-compressed.
Client: decodes protobuf (gz/raw), moves sorting/pagination to TUI.
Build: add prost/prost-build with vendored protoc; enable thin LTO, panic=abort, strip symbols.
Cleanup: cfg-gate Linux-only code; fix Clippy across platforms; tests updated (ws probe TLS CA).
2025-08-19 23:24:36 -07:00
jason 10501168c5 clippy fixes. 2025-08-19 15:52:30 -07:00
jason d346c61c28 Merge pull request #1 from jasonwitty/feature/wss-selfsigned
SSL Support
2025-08-19 15:33:50 -07:00
jason 7652095109 cargo fmt
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-19 15:33:11 -07:00
jason 6b58ac67f6 Merge branch 'master' into feature/wss-selfsigned 2025-08-19 15:31:10 -07:00
jason 3ad1d52fe2 fix windows build.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Gate Linux-specific imports and fields to avoid Windows dead-code/unused warnings.
Ensure Linux-only proc CPU tracker is not referenced on non-Linux builds.
2025-08-16 17:42:18 -07:00
jason 2e8cc24e81 remove dead code and increment version 2025-08-16 17:31:32 -07:00
jason 36e73fd9ed cargo fmt 2025-08-16 01:25:03 -07:00
jason 3d14e4a370 SSL Support
Add WSS/TLS (self‑signed) with client cert pinning; auto ws→wss on --tls-ca/-t; add -p/-t flags; harden TLS test; fix clippy; update README.

feat: WSS/TLS support (self‑signed + pinning), auto ws→wss when CA provided, new -p/-t flags; tests + clippy cleanup; docs updated.

Add TLS: self‑signed certs on agent, client pin via --tls-ca/-t (auto‑upgrade to wss), CLI/tests/README updates, clippy fixes.

12 files changed
Cargo.toml
README.md
Cargo.tomlsocktop_agent
main.rssocktop_agent/src
tls.rssocktop_agent/src
cli_args.rssocktop_agent/tests
Add Context...
README.md
2025-08-16 01:23:20 -07:00
jason c6b8c9c905 patch for macbook compatibility issues. 2025-08-15 19:21:34 -07:00
jason f980b6ace9 Update README.md 2025-08-13 20:17:36 -07:00
jason 6a27280f8d Merge branch 'master' of https://github.com/jasonwitty/socktop
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-12 17:12:38 -07:00
jason 38b0cdcf0e update screenshot 2025-08-12 17:12:27 -07:00
jason 268627ed63 Update README.md 2025-08-12 17:10:12 -07:00
jason 55a663cf7a Update README.md 2025-08-12 17:09:31 -07:00
jason 8b76ccb742 Merge branch 'master' of https://github.com/jasonwitty/socktop 2025-08-12 17:07:36 -07:00
jason d0f6cb0e70 fix screenshot 2025-08-12 17:07:25 -07:00
jason 56ebe6bbab Rename tmux_4_rpis_v2jpg to tmux_4_rpis_v2.jpg 2025-08-12 17:05:40 -07:00
jason dc90de7ff1 update readme and add new screenshots 2025-08-12 17:02:01 -07:00
jason 319f47eb73 Update README.md 2025-08-12 16:48:35 -07:00
jason fd2889ccca Update cargo versions in prep for publish. 2025-08-12 16:12:08 -07:00
jason 0859f50897 multiple feature and performance improvements (see description)
Here are concise release notes you can paste into your GitHub release.

Release notes — 2025-08-12

Highlights

Agent back to near-zero CPU when idle (request-driven, no background samplers).
Accurate per-process CPU% via /proc deltas; only top-level processes (threads hidden).
TUI: processes pane gets scrollbar, click-to-sort (CPU% or Mem) with indicator, stable total count.
Network panes made taller; disks slightly reduced.
README revamped: rustup prereqs, crates.io install, update/systemd instructions.
Clippy cleanups across agent and client.
Agent

Reverted precompressed caches and background samplers; WebSocket path is request-driven again.
Ensured on-demand gzip for larger replies; no per-request overhead when small.
Processes: switched to refresh_processes_specifics with ProcessRefreshKind::everything().without_tasks() to exclude threads.
Per-process CPU% now computed from /proc jiffies deltas using a small ProcCpuTracker (fixes “always 0%”/scaling issues).
Optional metrics and light caching:
CPU temp and GPU metrics gated by env (SOCKTOP_AGENT_TEMP=0, SOCKTOP_AGENT_GPU=0).
Tiny TTL caches via once_cell to avoid rescanning sensors every tick.
Dependencies: added once_cell = "1.19".
No API changes to WS endpoints.
Client (TUI)

Processes pane:
Scrollbar (mouse wheel, drag; keyboard arrows/PageUp/PageDown/Home/End).
Click header to sort by CPU% or Mem; dot indicator on active column.
Preserves process_count across fast metrics updates to avoid flicker.
UI/theme:
Shared scrollbar colors moved to ui/theme.rs; both CPU and Processes reuse them.
Cached pane rect to fix input handling; removed unused vars.
Layout: network download/upload get more vertical space; disks shrink slightly.
Clippy fixes: derive Default for ProcSortBy; style/import cleanups.
Docs

README: added rustup install steps (with proper shell reload), install via cargo install socktop and cargo install socktop_agent, and a clear Updating section (systemd service steps included).
Features list updated; roadmap marks independent cadences as done.
Upgrade notes

Agent: cargo install socktop_agent --force, then restart your systemd service; if unit changed, systemctl daemon-reload.
TUI: cargo install socktop --force.
Optional envs to trim overhead: SOCKTOP_AGENT_GPU=0, SOCKTOP_AGENT_TEMP=0.
No config or API breaking changes.
2025-08-12 15:52:46 -07:00
jason 5c002f0b2b Update README.md 2025-08-12 00:01:21 -07:00
jason 5a824c2098 only run apt command if build box is linux 2025-08-11 23:49:45 -07:00
jason ffb381e40e install deps libdrm-dev libdrm-amdgpu1 on build box 2025-08-11 23:46:08 -07:00
jason 0a70d7fd39 rustfmt 2025-08-11 23:41:23 -07:00
jason 8d81ee1f7e clippy clean ups 2025-08-11 23:37:50 -07:00
jason 1e248306a6 clippy clean up multiple files 2025-08-11 23:27:18 -07:00
jason 7cd6a6e0a1 gpu clippy cleanup 2025-08-11 23:08:35 -07:00
jason 8f58feffbe clippy clean up app.rs 2025-08-11 22:59:36 -07:00
jason 5790ef753b clippy cleanup 2025-08-11 22:56:35 -07:00
jason 9a49fd6b24 clippy cleanup main 2025-08-11 22:50:15 -07:00
jason b35e431200 clippy clean up 2025-08-11 22:46:58 -07:00
jason 4b52382326 another clippy cleanup 2025-08-11 22:45:24 -07:00
jason 11506699e3 clippy clean up 2025-08-11 22:44:43 -07:00
jason 4c6c707dd0 struct cleanup 2025-08-11 22:41:12 -07:00
jason d69a4104fc performance improvements and formatting cleanup 2025-08-11 22:37:46 -07:00
jason c3f81eef25 clippy code clean up
clippy code clean up
2025-08-11 20:47:21 -07:00
jason 05276f9eea fix clippy issues causing workflow failure. 2025-08-11 14:39:19 -07:00
jason 0105b29bfc fix additional clippy warnings. 2025-08-11 14:34:45 -07:00
jason 0cbba6b290 fix clippy format warnings 2025-08-11 14:30:41 -07:00
jason 250f7bf93a remove unused vendor field. 2025-08-11 14:25:58 -07:00
jason 4efeb3b60f update screenshots 2025-08-11 14:17:04 -07:00
jason d20061614c update default screenshot 2025-08-11 13:47:23 -07:00
jason 289c9f7ebe add cargo install documentation and enable release artifacts in actions
add cargo install documentation and enable release artifacts in actions
2025-08-11 13:38:26 -07:00
jason bdfa74be54 add license for socktop_agent 2025-08-11 12:10:40 -07:00
jason 6efdc35b19 add license so we can publish to cargo 2025-08-11 12:09:14 -07:00
jason 20278d67f1 new feature: gpu support 2025-08-11 12:04:55 -07:00
jason a4f69a5f7d Add scrollbar to CPU per core area. 2025-08-10 23:32:44 -07:00
jason 9b1643afac add screenshot and information abotu running as service. 2025-08-09 22:40:44 -07:00
jason a9086eac84 Create 14900KS_arch_alacritty.jpg 2025-08-08 21:27:27 -07:00
jason 4bd6744df4 fix clippy command 2025-08-08 17:33:30 -07:00
jason 274a485f8d fmt: apply rustfmt 2025-08-08 17:25:15 -07:00
jason 747aef0005 Merge branch 'master' of https://github.com/jasonwitty/socktop 2025-08-08 16:51:22 -07:00
jason a0e17c6e22 fix build warning and add ghostty screenshot 2025-08-08 16:46:46 -07:00
jason 19973c24d8 Update README.md 2025-08-08 14:15:28 -07:00
jason 9a4c8b703e Update README.md 2025-08-08 13:24:32 -07:00
jason 968a25eaf1 Update README.md 2025-08-08 13:22:57 -07:00
jason 13fb22c7ee add rustup installer line for lazy people 2025-08-08 13:14:33 -07:00
jason 6c867774f7 correct path in docs 2025-08-08 13:09:53 -07:00
jason 7c3e4a6e39 fix executable in documentation readme (socktop_agent) 2025-08-08 13:06:15 -07:00
jason 466a32a90a add tmux instructions to readme 2025-08-08 13:00:36 -07:00
jason cb4882e983 patch for ci failure. 2025-08-08 12:55:48 -07:00
52 changed files with 4049 additions and 703 deletions
+114 -4
View File
@@ -2,18 +2,128 @@ name: CI
on: on:
push: push:
pull_request: pull_request:
jobs: jobs:
build: build:
runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest] os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install system dependencies (Linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y libdrm-dev libdrm-amdgpu1
- name: Cargo fmt - name: Cargo fmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Clippy - name: Clippy
run: cargo clippy --all-targets --all-features -D warnings run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build - name: Build (release)
run: cargo build --release --workspace run: cargo build --release --workspace
- name: "Linux: start agent and run WS probe"
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
RUST_LOG=info SOCKTOP_ENABLE_SSL=0 SOCKTOP_AGENT_GPU=0 SOCKTOP_AGENT_TEMP=0 ./target/release/socktop_agent -p 3000 > agent.log 2>&1 &
AGENT_PID=$!
for i in {1..60}; do
if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then break; fi
sleep 1
done
if ! curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
echo "--- agent.log (tail) ---"
tail -n 200 agent.log || true
(command -v ss >/dev/null && ss -ltnp || netstat -ltnp) || true
kill $AGENT_PID || true
exit 1
fi
SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop --test ws_probe -- --nocapture
kill $AGENT_PID || true
- name: "Windows: start agent and run WS probe"
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$env:SOCKTOP_ENABLE_SSL = "0"
$env:SOCKTOP_AGENT_GPU = "0"
$env:SOCKTOP_AGENT_TEMP = "0"
$out = Join-Path $PWD "agent.out.txt"
$err = Join-Path $PWD "agent.err.txt"
$p = Start-Process -FilePath "${PWD}\target\release\socktop_agent.exe" -ArgumentList "-p 3000" -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -NoNewWindow
$ready = $false
for ($i = 0; $i -lt 60; $i++) {
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = "curl.exe"
$pinfo.Arguments = "-fsS http://127.0.0.1:3000/healthz"
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$proc = [System.Diagnostics.Process]::Start($pinfo)
$proc.WaitForExit()
if ($proc.ExitCode -eq 0) { $ready = $true; break }
Start-Sleep -Seconds 1
}
if (-not $ready) {
Write-Warning "TCP connect to (127.0.0.1 : 3000) failed"
if (Test-Path $out) { Write-Host "--- agent.out (full) ---"; Get-Content $out }
if (Test-Path $err) { Write-Host "--- agent.err (full) ---"; Get-Content $err }
Write-Host "--- netstat ---"
netstat -ano | Select-String ":3000" | ForEach-Object { $_.Line }
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
throw "agent did not become ready"
}
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
try {
cargo test -p socktop --test ws_probe -- --nocapture
} finally {
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
}
- name: Smoke test (client --help)
run: cargo run -p socktop -- --help
- name: Package artifacts (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -e
mkdir -p dist
cp target/release/socktop dist/
cp target/release/socktop_agent dist/
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
- name: Package artifacts (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item target\release\socktop.exe dist\
Copy-Item target\release\socktop_agent.exe dist\
Compress-Archive -Path dist\* -DestinationPath socktop-${{ matrix.os }}.zip -Force
- name: Upload build artifacts (ephemeral)
uses: actions/upload-artifact@v4
with:
name: socktop-${{ matrix.os }}
path: |
*.tar.gz
*.zip
- name: Upload to rolling GitHub Release (main only)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
tag_name: latest
name: Latest build
prerelease: true
draft: false
files: |
*.tar.gz
*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+83
View File
@@ -0,0 +1,83 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'socktop'",
"cargo": {
"args": [
"build",
"--bin=socktop",
"--package=socktop"
],
"filter": {
"name": "socktop",
"kind": "bin"
}
},
"args": ["ws://127.0.0.1:3000/ws"],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'socktop'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=socktop",
"--package=socktop"
],
"filter": {
"name": "socktop",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'socktop_agent'",
"cargo": {
"args": [
"build",
"--bin=socktop_agent",
"--package=socktop_agent"
],
"filter": {
"name": "socktop_agent",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'socktop_agent'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=socktop_agent",
"--package=socktop_agent"
],
"filter": {
"name": "socktop_agent",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}
Generated
+1163 -32
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,4 +1,5 @@
[workspace] [workspace]
resolver = "2"
members = [ members = [
"socktop", "socktop",
"socktop_agent" "socktop_agent"
@@ -12,7 +13,7 @@ futures-util = "0.3"
anyhow = "1.0" anyhow = "1.0"
# websocket # websocket
tokio-tungstenite = "0.24" tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] }
tungstenite = "0.24" tungstenite = "0.24"
url = "2.5" url = "2.5"
@@ -33,3 +34,16 @@ chrono = { version = "0.4", features = ["serde"] }
# web server (remote-agent) # web server (remote-agent)
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws"] }
# protobuf
prost = "0.13"
prost-types = "0.13"
bytes = "1"
[profile.release]
# Favor smaller, simpler binaries with good runtime perf
lto = "thin"
codegen-units = 1
panic = "abort"
opt-level = 3
strip = "symbols"
+307 -145
View File
@@ -1,201 +1,256 @@
# socktop # socktop
**socktop** is a remote system monitor with a rich TUI interface, inspired by `top` and `btop`, that communicates with a lightweight remote agent over WebSockets. socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
It lets you watch CPU, memory, disks, network, temperatures, and processes on another machine in real-time — from the comfort of your terminal. - Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
![socktop screenshot](./docs/socktop-screenshot.png) ![socktop screenshot](./docs/14900ks_arch_alacritty_gpu_active_v2.jpg)
--- ---
## Features ## Features
- 📡 **Remote monitoring** via WebSocket — lightweight agent sends JSON metrics - Remote monitoring via WebSocket (JSON over WS)
- 🖥 **Rich TUI** built with [ratatui](https://github.com/ratatui-org/ratatui) - Optional WSS (TLS): agent autogenerates a selfsigned cert on first run; client pins the cert via --tls-ca/-t
- 🔍 **Detailed CPU view** — per-core history, current load, and trends - TUI built with ratatui
- 📊 **Memory, Swap, Disk usage** — human-readable units, color-coded - CPU
- 🌡 **Temperatures** — CPU temperature with visual indicators - Overall sparkline + per-core mini bars
- 📈 **Network throughput** — live sparkline graphs with peak tracking - Accurate per-process CPU% (Linux /proc deltas), normalized to 0100%
- 🏷 **Top processes table** — PID, name, CPU%, memory, and memory% - Memory/Swap gauges with human units
- 🎨 Color-coded load, zebra striping for readability - Disks: per-device usage
- **Keyboard shortcuts**: - Network: per-interface throughput with sparklines and peak markers
- `q` / `Esc` → Quit - Temperatures: CPU (optional)
- Top processes (top 50)
- PID, name, CPU%, memory, and memory%
- Click-to-sort by CPU% or Mem (descending)
- Scrollbar and mouse/keyboard scrolling
- Total process count shown in the header
- Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled)
- Optional auth token for the agent
---
## Prerequisites: Install Rust (rustup)
Rust is fast, safe, and crossplatform. Installing it will make your machine better. Consider yourself privileged.
Linux/macOS:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# load cargo for this shell
source "$HOME/.cargo/env"
# ensure stable is up to date
rustup update stable
rustc --version
cargo --version
# after install you may need to reload your shell, e.g.:
exec bash # or: exec zsh / exec fish
```
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, youll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
### Raspberry Pi / Ubuntu / PopOS (required)
Install GPU support with apt command below
```bash
sudo apt-get update
sudo apt-get install libdrm-dev libdrm-amdgpu1
```
--- ---
## Architecture ## Architecture
`socktop` has **two components**: Two components:
1. **Agent** (remote side) 1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects on demand when the client asks (fast metrics ~500 ms, processes ~2 s, disks ~5 s). No background loop when nobody is connected.
A small Rust WebSocket server that runs on the target machine and gathers metrics via [sysinfo](https://crates.io/crates/sysinfo).
2. **Client** (local side) 2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
The TUI app (`socktop`) that connects to the agents `/ws` endpoint, receives JSON metrics, and renders them.
The two communicate over a persistent WebSocket connection.
--- ---
## Adaptive (idle-aware) sampling ## Quick start
The socktop agent now samples system metrics only when at least one WebSocket client is connected. When idle (no clients), the sampler sleeps and CPU usage drops to ~0%. - Build both binaries:
How it works
- The WebSocket handler increments/decrements a client counter in `AppState` on connect/disconnect.
- A background sampler wakes when the counter transitions from 0 → >0 and sleeps when it returns to 0.
- The most recent metrics snapshot is cached as JSON for fast responses.
Cold start behavior
- If a client requests metrics while the cache is empty (e.g., just started or after a long idle), the agent performs a one-off synchronous collection to respond immediately.
Tuning
- Sampling interval (active): update `spawn_sampler(state, Duration::from_millis(500))` in `socktop_agent/src/main.rs`.
- Always-on or low-frequency idle sampling: replace the “sleep when idle” logic in `socktop_agent/src/sampler.rs` with a low-frequency interval. Example sketch:
```rust
// In sampler.rs (sketch): sample every 10s when idle, 500ms when active
let idle_period = Duration::from_secs(10);
loop {
let active = state.client_count.load(Ordering::Relaxed) > 0;
let period = if active { Duration::from_millis(500) } else { idle_period };
let mut ticker = tokio::time::interval(period);
ticker.tick().await;
if !active {
// wake early if a client connects
tokio::select! {
_ = ticker.tick() => {},
_ = state.wake_sampler.notified() => continue,
}
}
let m = collect_metrics(&state).await;
if let Ok(js) = serde_json::to_string(&m) {
*state.last_json.write().await = js;
}
}
```
---
## Installation
### Prerequisites
- Rust 1.75+ (recommended latest stable)
- Cargo package manager
### Build from source
```bash ```bash
git clone https://github.com/YOURNAME/socktop.git git clone https://github.com/jasonwitty/socktop.git
cd socktop cd socktop
cargo build --release cargo build --release
``` ```
### Install as a cargo binary - Start the agent on the target machine (default port 3000):
```bash ```bash
cargo install --path . ./target/release/socktop_agent --port 3000
```
- Connect with the TUI from your local machine:
```bash
./target/release/socktop ws://REMOTE_HOST:3000/ws
```
Tip: Add ?token=... if you enable auth (see Security).
TLS quick start (optional, recommended on untrusted networks):
- Start the agent with TLS enabled (default TLS port 8443). On first run it will generate a selfsigned certificate and key under your config directory.
```bash
./target/release/socktop_agent --enableSSL --port 8443 # or: -p 8443
# First run prints the cert and key paths, e.g.:
# socktop_agent: generated self-signed TLS certificate at /home/you/.config/socktop_agent/tls/cert.pem
# socktop_agent: private key at /home/you/.config/socktop_agent/tls/key.pem
```
- Copy the certificate file to the client machine (keep the key private on the server):
```bash
scp /home/you/.config/socktop_agent/tls/cert.pem you@client:/tmp/socktop-agent-ca.pem
```
- Connect with the TUI, pinning the server cert:
```bash
./target/release/socktop --tls-ca /tmp/socktop-agent-ca.pem wss://REMOTE_HOST:8443/ws
# Note: if you pass --tls-ca but use ws://, the client auto-upgrades to wss://
``` ```
This will install the `socktop` binary into `~/.cargo/bin`.
--- ---
## Running ## Install (from crates.io)
### 1. Start the agent on the remote machine You dont need to clone this repo to use socktop. Install the published binaries with cargo:
The agent binary listens on a TCP port and serves `/ws`:
```bash ```bash
remote_agent 0.0.0.0:8080 # TUI (client)
cargo install socktop
# Agent (server)
cargo install socktop_agent
``` ```
> **Tip:** You can run the agent under `systemd`, inside a Docker container, or just in a tmux/screen session. This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
### 2. Connect with the client Notes:
From your local machine: - After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
```bash - Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldnt. Be brave.
socktop ws://REMOTE_HOST:8080/ws
```
Example: Option B: System-wide agent (Linux)
```bash ```bash
socktop ws://192.168.1.50:8080/ws # If you installed with cargo, binaries are in ~/.cargo/bin
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
# Install and enable the systemd service (example unit in docs/)
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now socktop-agent
``` ```
--- ---
## Usage ## Usage
When connected, `socktop` displays: Agent (server):
**Left column:** ```bash
- **CPU avg graph** — sparkline of recent overall CPU usage socktop_agent --port 3000
- **Memory gauge** — total and used RAM # or env: SOCKTOP_PORT=3000 socktop_agent
- **Swap gauge** — total and used swap # optional auth: SOCKTOP_TOKEN=changeme socktop_agent
- **Disks** — usage per device (only devices with available space > 0) # enable TLS (selfsigned cert, default port 8443; you can also use -p):
- **Network Download/Upload** — sparkline in KB/s, with current & peak values socktop_agent --enableSSL --port 8443
```
**Right column:** Client (TUI):
- **Per-core history & trends** — each cores recent load, current %, and trend arrow
- **Top processes table** — top 20 processes with PID, name, CPU%, memory usage, and memory% ```bash
socktop ws://HOST:3000/ws
# with token:
socktop "ws://HOST:3000/ws?token=changeme"
# TLS with pinned server certificate (recommended over the internet):
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
```
Intervals (client-driven):
- Fast metrics: ~500 ms
- Processes: ~2 s (top 50)
- Disks: ~5 s
The agent stays idle unless queried. When queried, it collects just whats needed.
--- ---
## Configuring the agent port ## Updating
The agent listens on TCP port 3000 by default. You can override this via a CLI flag, a positional port argument, or an environment variable: Update the agent (systemd):
- CLI flag: ```bash
- socktop_agent --port 8080 # on the server running the agent
- socktop_agent -p 8080 cargo install socktop_agent --force
- Positional: sudo systemctl stop socktop-agent
- socktop_agent 8080 sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
- Environment variable: # if you changed the unit file:
- SOCKTOP_PORT=8080 socktop_agent # sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
# sudo systemctl daemon-reload
sudo systemctl start socktop-agent
sudo systemctl status socktop-agent --no-pager
# logs:
# journalctl -u socktop-agent -f
```
Help: Update the TUI (client):
- socktop_agent --help ```bash
cargo install socktop --force
socktop ws://HOST:3000/ws
```
The TUI should point to ws://HOST:PORT/ws, e.g.: Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
- cargo run -p socktop -- ws://127.0.0.1:8080/ws
--- ---
## Keyboard Shortcuts ## Configuration (agent)
| Key | Action | - Port:
|-------------|------------| - Flag: --port 8080 or -p 8080
| `q` or `Esc`| Quit | - Positional: socktop_agent 8080
- Env: SOCKTOP_PORT=8080
- TLS (selfsigned):
- Enable: --enableSSL
- Default TLS port: 8443 (override with --port/-p)
- Certificate/Key location (created on first TLS run):
- Linux (XDG): $XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem} (defaults to ~/.config)
- The agent prints these paths on creation.
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
- Auth token (optional): SOCKTOP_TOKEN=changeme
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
--- ---
## Security (optional token) ## Keyboard & Mouse
By default, the agent exposes metrics over an unauthenticated WebSocket. For untrusted networks, set an auth token and pass it in the client URL:
- Server: - Quit: q or Esc
- SOCKTOP_TOKEN=changeme socktop_agent --port 3000 - Processes pane:
- Client: - Click “CPU %” to sort by CPU descending
- socktop ws://HOST:3000/ws?token=changeme - Click “Mem” to sort by memory descending
- Mouse wheel: scroll
--- - Drag scrollbar: scroll
- Arrow/PageUp/PageDown/Home/End: scroll
## Platform notes
- Linux x86_64/AMD/Intel: fully supported.
- Raspberry Pi:
- 64-bit: rustup target add aarch64-unknown-linux-gnu; build on-device for simplicity.
- 32-bit: rustup target add armv7-unknown-linux-gnueabihf.
- Windows:
- TUI and agent build/run with stable Rust. Use PowerShell:
- cargo run -p socktop_agent -- --port 3000
- cargo run -p socktop -- ws://127.0.0.1:3000/ws
- CPU temperature may be unavailable; display will show N/A.
--- ---
## Example agent JSON ## Example agent JSON
`socktop` expects the agent to send metrics in this shape:
```json ```json
{ {
"cpu_total": 12.4, "cpu_total": 12.4,
"cpu_per_core": [11.2, 15.7, ...], "cpu_per_core": [11.2, 15.7],
"mem_total": 33554432, "mem_total": 33554432,
"mem_used": 18321408, "mem_used": 18321408,
"swap_total": 0, "swap_total": 0,
@@ -207,42 +262,149 @@ By default, the agent exposes metrics over an unauthenticated WebSocket. For unt
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}], "networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
"top_processes": [ "top_processes": [
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678} {"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
] ],
"gpus": null
} }
``` ```
Notes:
- process_count is merged into the main metrics on the client when processes are polled.
- top_processes are the current top 50 (sorting in the TUI is client-side).
---
## Security
Set a token on the agent and pass it as a query param from the client:
Server:
```bash
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
```
Client:
```bash
socktop "ws://HOST:3000/ws?token=changeme"
```
### TLS / WSS
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
Server (generates selfsigned cert and key on first run):
```bash
socktop_agent --enableSSL --port 8443
```
Client (trust/pin the server cert; copy cert.pem from the agent):
```bash
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
```
Notes:
- Do not copy the private key off the server; only the cert.pem is needed by clients.
- When --tls-ca/-t is supplied, the client autoupgrades ws:// to wss:// to avoid protocol mismatch.
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
---
## Using tmux to monitor multiple hosts
You can use tmux to show multiple socktop instances in a single terminal.
![socktop screenshot](./docs/tmux_4_rpis_v3.jpg)
monitoring 4 Raspberry Pis using Tmux
Prerequisites:
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
Key bindings (defaults):
- Split left/right: Ctrl-b %
- Split top/bottom: Ctrl-b "
- Move between panes: Ctrl-b + Arrow keys
- Show pane numbers: Ctrl-b q
- Close a pane: Ctrl-b x
- Detach from session: Ctrl-b d
Two panes (left/right)
- This creates a session named "socktop", splits it horizontally, and starts two socktops.
```bash
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
split-window -h 'socktop ws://HOST2:3000/ws' \; \
select-layout even-horizontal \; \
attach
```
Four panes (top-left, top-right, bottom-left, bottom-right)
- This creates a 2x2 grid with one socktop per pane.
```bash
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
split-window -h 'socktop ws://HOST2:3000/ws' \; \
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
select-layout tiled \; \
attach
```
Tips:
- Replace HOST1..HOST4 (and ports) with your targets.
- Reattach later: `tmux attach -t socktop`
---
## Platform notes
- Linux: fully supported (agent and client).
- Raspberry Pi:
- 64-bit: aarch64-unknown-linux-gnu
- 32-bit: armv7-unknown-linux-gnueabihf
- Windows:
- TUI + agent can build with stable Rust; bring your own MSVC. Youre on Windows; you know the drill.
- CPU temperature may be unavailable.
- binary exe for both available in build artifacts under actions.
- macOS:
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
--- ---
## Development ## Development
### Run in debug mode:
```bash
cargo run -- ws://127.0.0.1:8080/ws
```
### Code formatting & lint:
```bash ```bash
cargo fmt cargo fmt
cargo clippy cargo clippy --all-targets --all-features
cargo run -p socktop -- ws://127.0.0.1:3000/ws
# TLS (dev): first run will create certs under ~/.config/socktop_agent/tls/
cargo run -p socktop_agent -- --enableSSL --port 8443
``` ```
--- ---
## Roadmap ## Roadmap
- [ ] Configurable refresh interval
- [ ] Filter/sort top processes in the TUI - [x] Agent authentication (token)
- [x] Hide per-thread entries; only show processes
- [x] Sort top processes in the TUI
- [ ] Configurable refresh intervals (client)
- [ ] Export metrics to file - [ ] Export metrics to file
- [ ] TLS / WSS support - [x] TLS / WSS support (selfsigned server cert + client pinning)
- [ ] Agent authentication - [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
--- ---
## License ## License
MIT License — see [LICENSE](LICENSE).
MIT — see LICENSE.
--- ---
## Acknowledgements ## Acknowledgements
- [`ratatui`](https://github.com/ratatui-org/ratatui) for terminal UI rendering
- [`sysinfo`](https://crates.io/crates/sysinfo) for system metrics - ratatui for the TUI
- [`tokio-tungstenite`](https://crates.io/crates/tokio-tungstenite) for WebSocket client/server - sysinfo for system metrics
- tokio-tungstenite for WebSockets
Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+8
View File
@@ -0,0 +1,8 @@
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
Error: Address already in use (os error 98)
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
Error: Address already in use (os error 98)
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
package socktop;
// All running processes. Sorting is done client-side.
message Processes {
uint64 process_count = 1; // total processes in the system
repeated Process rows = 2; // all processes
}
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3; // 0..100
uint64 mem_bytes = 4; // RSS bytes
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["clippy", "rustfmt"]
+14 -2
View File
@@ -1,14 +1,14 @@
[package] [package]
name = "socktop" name = "socktop"
version = "0.1.0" version = "0.1.11"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"] authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top" description = "Remote system monitor over WebSocket, TUI like top"
edition = "2021" edition = "2021"
license = "MIT"
[dependencies] [dependencies]
tokio = { workspace = true } tokio = { workspace = true }
tokio-tungstenite = { workspace = true } tokio-tungstenite = { workspace = true }
tungstenite = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -18,3 +18,15 @@ ratatui = { workspace = true }
crossterm = { workspace = true } crossterm = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
rustls = "0.23"
rustls-pemfile = "2.1"
prost = { workspace = true }
bytes = { workspace = true }
[dev-dependencies]
assert_cmd = "2.0"
[build-dependencies]
prost-build = "0.13"
protoc-bin-vendored = "3"
+8
View File
@@ -0,0 +1,8 @@
fn main() {
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
std::env::set_var("PROTOC", protoc);
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
cfg.compile_protos(&["../proto/processes.proto"], &["../proto"])
.expect("compile protos");
}
+312 -33
View File
@@ -1,23 +1,36 @@
//! App state and main loop: input handling, fetching metrics, updating history, and drawing. //! App state and main loop: input handling, fetching metrics, updating history, and drawing.
use std::{collections::VecDeque, io, time::{Duration, Instant}}; use std::{
collections::VecDeque,
io,
time::{Duration, Instant},
};
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode}, event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use ratatui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Direction}, layout::{Constraint, Direction, Rect},
//style::Color, // + add Color
Terminal, Terminal,
}; };
use tokio::time::sleep; use tokio::time::sleep;
use crate::history::{push_capped, PerCoreHistory}; use crate::history::{push_capped, PerCoreHistory};
use crate::types::Metrics; use crate::types::Metrics;
use crate::ui::{header::draw_header, cpu::{draw_cpu_avg_graph, draw_per_core_bars}, mem::draw_mem, swap::draw_swap, disks::draw_disks, net::draw_net_spark, processes::draw_top_processes}; use crate::ui::cpu::{
use crate::ws::{connect, request_metrics}; draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp, per_core_content_area,
per_core_handle_key, per_core_handle_mouse, per_core_handle_scrollbar_mouse, PerCoreScrollDrag,
};
use crate::ui::processes::{processes_handle_key, processes_handle_mouse, ProcSortBy};
use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
swap::draw_swap,
};
use crate::ws::{connect, request_disks, request_metrics, request_processes};
pub struct App { pub struct App {
// Latest metrics + histories // Latest metrics + histories
@@ -38,6 +51,21 @@ pub struct App {
// Quit flag // Quit flag
should_quit: bool, should_quit: bool,
pub per_core_scroll: usize,
pub per_core_drag: Option<PerCoreScrollDrag>, // new: drag state
pub procs_scroll_offset: usize,
pub procs_drag: Option<PerCoreScrollDrag>,
pub procs_sort_by: ProcSortBy,
last_procs_area: Option<ratatui::layout::Rect>,
last_procs_poll: Instant,
last_disks_poll: Instant,
procs_interval: Duration,
disks_interval: Duration,
// For reconnects
ws_url: String,
} }
impl App { impl App {
@@ -52,17 +80,38 @@ impl App {
rx_peak: 0, rx_peak: 0,
tx_peak: 0, tx_peak: 0,
should_quit: false, 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,
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),
ws_url: String::new(),
} }
} }
pub async fn run(&mut self, url: &str) -> Result<(), Box<dyn std::error::Error>> { pub async fn run(
&mut self,
url: &str,
tls_ca: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
// Connect to agent // Connect to agent
let mut ws = connect(url).await?; //let mut ws = connect(url, tls_ca).await?;
self.ws_url = url.to_string();
let mut ws = connect(url, tls_ca).await?;
// Terminal setup // Terminal setup
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@@ -73,7 +122,7 @@ impl App {
// Teardown // Teardown
disable_raw_mode()?; disable_raw_mode()?;
let backend = terminal.backend_mut(); let backend = terminal.backend_mut();
execute!(backend, LeaveAlternateScreen)?; execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?;
terminal.show_cursor()?; terminal.show_cursor()?;
res res
@@ -87,10 +136,118 @@ impl App {
loop { loop {
// Input (non-blocking) // Input (non-blocking)
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
if let Event::Key(k) = event::read()? { match event::read()? {
if matches!(k.code, KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc) { Event::Key(k) => {
self.should_quit = true; if matches!(
k.code,
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
) {
self.should_quit = true;
}
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(10),
])
.split(area);
let top = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]);
let content = per_core_content_area(top[1]);
per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize);
let total_rows = self
.last_metrics
.as_ref()
.map(|mm| mm.cpu_per_core.len())
.unwrap_or(0);
per_core_clamp(
&mut self.per_core_scroll,
total_rows,
content.height as usize,
);
if let Some(p_area) = self.last_procs_area {
// page size = visible rows (inner height minus header = 1)
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
processes_handle_key(&mut self.procs_scroll_offset, k, page);
}
} }
Event::Mouse(m) => {
// Layout to get areas
let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(10),
])
.split(area);
let top = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]);
// Content wheel scrolling
let content = per_core_content_area(top[1]);
per_core_handle_mouse(
&mut self.per_core_scroll,
m,
content,
content.height as usize,
);
// Scrollbar clicks/drag
let total_rows = self
.last_metrics
.as_ref()
.map(|mm| mm.cpu_per_core.len())
.unwrap_or(0);
per_core_handle_scrollbar_mouse(
&mut self.per_core_scroll,
&mut self.per_core_drag,
m,
top[1],
total_rows,
);
// Clamp to bounds
per_core_clamp(
&mut self.per_core_scroll,
total_rows,
content.height as usize,
);
// Processes table: sort by column on header click
if let (Some(mm), Some(p_area)) =
(self.last_metrics.as_ref(), self.last_procs_area)
{
if let Some(new_sort) = processes_handle_mouse(
&mut self.procs_scroll_offset,
&mut self.procs_drag,
m,
p_area,
mm.top_processes.len(),
) {
self.procs_sort_by = new_sort;
}
}
}
Event::Resize(_, _) => {}
_ => {}
} }
} }
if self.should_quit { if self.should_quit {
@@ -100,6 +257,27 @@ impl App {
// Fetch and update // Fetch and update
if let Some(m) = request_metrics(ws).await { if let Some(m) = request_metrics(ws).await {
self.update_with_metrics(m); self.update_with_metrics(m);
// Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval {
if let Some(procs) = request_processes(ws).await {
if let Some(mm) = self.last_metrics.as_mut() {
mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count);
}
}
self.last_procs_poll = Instant::now();
}
// Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Some(disks) = request_disks(ws).await {
if let Some(mm) = self.last_metrics.as_mut() {
mm.disks = disks;
}
}
self.last_disks_poll = Instant::now();
}
} }
// Draw // Draw
@@ -112,7 +290,21 @@ impl App {
Ok(()) Ok(())
} }
fn update_with_metrics(&mut self, m: Metrics) { 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 m.disks.is_empty() {
m.disks = prev.disks.clone();
}
if m.top_processes.is_empty() {
m.top_processes = prev.top_processes.clone();
}
// 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
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64; let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.cpu_hist, v, 600); push_capped(&mut self.cpu_hist, v, 600);
@@ -130,69 +322,156 @@ impl App {
let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64; let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64;
let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64; let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64;
(rx, tx) (rx, tx)
} else { (0, 0) }; } else {
(0, 0)
};
self.last_net_totals = Some((rx_total, tx_total, now)); self.last_net_totals = Some((rx_total, tx_total, now));
push_capped(&mut self.rx_hist, rx_kb, 600); push_capped(&mut self.rx_hist, rx_kb, 600);
push_capped(&mut self.tx_hist, tx_kb, 600); push_capped(&mut self.tx_hist, tx_kb, 600);
self.rx_peak = self.rx_peak.max(rx_kb); self.rx_peak = self.rx_peak.max(rx_kb);
self.tx_peak = self.tx_peak.max(tx_kb); self.tx_peak = self.tx_peak.max(tx_kb);
// Store merged snapshot
self.last_metrics = Some(m); self.last_metrics = Some(m);
} }
fn draw(&mut self, f: &mut ratatui::Frame<'_>) { pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
let area = f.area(); let area = f.area();
// Root rows: header, top (cpu avg + per-core), memory, swap, bottom
let rows = ratatui::layout::Layout::default() let rows = ratatui::layout::Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(1), Constraint::Length(1), // header
Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), // top row
Constraint::Length(3), Constraint::Length(3), // memory (left) + GPU (right, part 1)
Constraint::Length(3), Constraint::Length(3), // swap (left) + GPU (right, part 2)
Constraint::Min(10), Constraint::Min(10), // bottom: disks + net (left), top procs (right)
]) ])
.split(area); .split(area);
// Header
draw_header(f, rows[0], self.last_metrics.as_ref()); draw_header(f, rows[0], self.last_metrics.as_ref());
let top = ratatui::layout::Layout::default() // Top row: left CPU avg, right Per-core (full top-right)
let top_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) .constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]); .split(rows[1]);
draw_cpu_avg_graph(f, top[0], &self.cpu_hist, self.last_metrics.as_ref()); draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
draw_per_core_bars(f, top[1], self.last_metrics.as_ref(), &self.per_core_hist); draw_per_core_bars(
f,
top_lr[1],
self.last_metrics.as_ref(),
&self.per_core_hist,
self.per_core_scroll,
);
draw_mem(f, rows[2], self.last_metrics.as_ref()); // Memory + Swap rows split into left/right columns
draw_swap(f, rows[3], self.last_metrics.as_ref()); let mem_lr = ratatui::layout::Layout::default()
let bottom = ratatui::layout::Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) .constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[2]);
let swap_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[3]);
// Left: Memory + Swap
draw_mem(f, mem_lr[0], self.last_metrics.as_ref());
draw_swap(f, swap_lr[0], self.last_metrics.as_ref());
// Right: GPU spans the same vertical space as Memory + Swap
let gpu_area = ratatui::layout::Rect {
x: mem_lr[1].x,
y: mem_lr[1].y,
width: mem_lr[1].width,
height: mem_lr[1].height + swap_lr[1].height,
};
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
// Bottom area: left = Disks + Network, right = Top Processes
let bottom_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(rows[4]); .split(rows[4]);
// Left bottom: Disks + Net stacked (make net panes slightly taller)
let left_stack = ratatui::layout::Layout::default() let left_stack = ratatui::layout::Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(6), Constraint::Length(4), Constraint::Length(4)]) .constraints([
.split(bottom[0]); Constraint::Min(4), // Disks shrink slightly
Constraint::Length(5), // Download taller
Constraint::Length(5), // Upload taller
])
.split(bottom_lr[0]);
draw_disks(f, left_stack[0], self.last_metrics.as_ref()); draw_disks(f, left_stack[0], self.last_metrics.as_ref());
draw_net_spark( draw_net_spark(
f, f,
left_stack[1], left_stack[1],
&format!("Download (KB/s) — now: {} | peak: {}", self.rx_hist.back().copied().unwrap_or(0), self.rx_peak), &format!(
"Download (KB/s) — now: {} | peak: {}",
self.rx_hist.back().copied().unwrap_or(0),
self.rx_peak
),
&self.rx_hist, &self.rx_hist,
ratatui::style::Color::Green, ratatui::style::Color::Green,
); );
draw_net_spark( draw_net_spark(
f, f,
left_stack[2], left_stack[2],
&format!("Upload (KB/s) — now: {} | peak: {}", self.tx_hist.back().copied().unwrap_or(0), self.tx_peak), &format!(
"Upload (KB/s) — now: {} | peak: {}",
self.tx_hist.back().copied().unwrap_or(0),
self.tx_peak
),
&self.tx_hist, &self.tx_hist,
ratatui::style::Color::Blue, ratatui::style::Color::Blue,
); );
draw_top_processes(f, bottom[1], self.last_metrics.as_ref()); // Right bottom: Top Processes fills the column
let procs_area = bottom_lr[1];
// Cache for input handlers
self.last_procs_area = Some(procs_area);
crate::ui::processes::draw_top_processes(
f,
procs_area,
self.last_metrics.as_ref(),
self.procs_scroll_offset,
self.procs_sort_by,
);
} }
} }
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,
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),
ws_url: String::new(),
}
}
}
+5 -2
View File
@@ -17,7 +17,10 @@ pub struct PerCoreHistory {
impl PerCoreHistory { impl PerCoreHistory {
pub fn new(cap: usize) -> Self { pub fn new(cap: usize) -> Self {
Self { deques: Vec::new(), cap } Self {
deques: Vec::new(),
cap,
}
} }
// Ensure we have one deque per core; resize on CPU topology changes // Ensure we have one deque per core; resize on CPU topology changes
@@ -36,4 +39,4 @@ impl PerCoreHistory {
push_capped(&mut self.deques[i], val, self.cap); push_capped(&mut self.deques[i], val, self.cap);
} }
} }
} }
+4
View File
@@ -0,0 +1,4 @@
//! Library surface for integration tests and reuse.
pub mod types;
pub mod ws;
+54 -9
View File
@@ -6,18 +6,63 @@ mod types;
mod ui; mod ui;
mod ws; mod ws;
use std::env;
use app::App; use app::App;
use std::env;
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option<String>), 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;
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
return Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
));
}
"--tls-ca" | "-t" => {
tls_ca = it.next();
}
_ if arg.starts_with("--tls-ca=") => {
if let Some((_, v)) = arg.split_once('=') {
if !v.is_empty() {
tls_ca = Some(v.to_string());
}
}
}
_ => {
if url.is_none() {
url = Some(arg);
} else {
return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
));
}
}
}
}
match url {
Some(u) => Ok((u, tls_ca)),
None => Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
)),
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect(); // Reuse the same parsing logic for testability
if args.len() < 2 { let (url, tls_ca) = match parse_args(env::args()) {
eprintln!("Usage: {} ws://HOST:PORT/ws", args[0]); Ok(v) => v,
std::process::exit(1); Err(msg) => {
} eprintln!("{msg}");
let url = args[1].clone(); return Ok(());
}
};
let mut app = App::new(); let mut app = App::new();
app.run(&url).await app.run(&url, tls_ca.as_deref()).await
} }
+57 -20
View File
@@ -2,21 +2,7 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Clone, Deserialize)]
pub struct Disk {
pub name: String,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Network {
// cumulative totals; client diffs to compute rates
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ProcessInfo { pub struct ProcessInfo {
pub pid: u32, pub pid: u32,
pub name: String, pub name: String,
@@ -24,7 +10,48 @@ pub struct ProcessInfo {
pub mem_bytes: u64, pub mem_bytes: u64,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Clone, Deserialize)]
pub struct DiskInfo {
pub name: String,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkInfo {
#[allow(dead_code)]
pub name: String,
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GpuInfo {
pub name: Option<String>,
#[allow(dead_code)]
pub vendor: Option<String>,
// Accept both the new and legacy keys
#[serde(
default,
alias = "utilization_gpu_pct",
alias = "gpu_util_pct",
alias = "gpu_utilization"
)]
pub utilization: Option<f32>,
#[serde(default, alias = "mem_used_bytes", alias = "vram_used_bytes")]
pub mem_used: Option<u64>,
#[serde(default, alias = "mem_total_bytes", alias = "vram_total_bytes")]
pub mem_total: Option<u64>,
#[allow(dead_code)]
#[serde(default, alias = "temp_c", alias = "temperature_c")]
pub temperature: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Metrics { pub struct Metrics {
pub cpu_total: f32, pub cpu_total: f32,
pub cpu_per_core: Vec<f32>, pub cpu_per_core: Vec<f32>,
@@ -32,10 +59,20 @@ pub struct Metrics {
pub mem_used: u64, pub mem_used: u64,
pub swap_total: u64, pub swap_total: u64,
pub swap_used: u64, pub swap_used: u64,
pub process_count: usize,
pub hostname: String, pub hostname: String,
pub cpu_temp_c: Option<f32>, pub cpu_temp_c: Option<f32>,
pub disks: Vec<Disk>, pub disks: Vec<DiskInfo>,
pub networks: Vec<Network>, pub networks: Vec<NetworkInfo>,
pub top_processes: Vec<ProcessInfo>, pub top_processes: Vec<ProcessInfo>,
} pub gpus: Option<Vec<GpuInfo>>,
// New: keep the last reported total process count
#[serde(default)]
pub process_count: Option<usize>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct ProcessesPayload {
pub process_count: usize,
pub top_processes: Vec<ProcessInfo>,
}
+321 -19
View File
@@ -1,23 +1,250 @@
//! CPU average sparkline + per-core mini bars. //! CPU average sparkline + per-core mini bars.
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::style::Modifier;
use ratatui::style::{Color, Style};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Sparkline}, widgets::{Block, Borders, Paragraph, Sparkline},
}; };
use ratatui::style::Modifier;
use crate::history::PerCoreHistory; use crate::history::PerCoreHistory;
use crate::types::Metrics; use crate::types::Metrics;
/// State for dragging the scrollbar thumb
#[derive(Clone, Copy, Debug, Default)]
pub struct PerCoreScrollDrag {
pub active: bool,
pub start_y: u16, // mouse row where drag started
pub start_top: usize, // thumb top (in track rows) at drag start
}
/// Returns the content area for per-core CPU bars, excluding borders and reserving space for scrollbar.
pub fn per_core_content_area(area: Rect) -> Rect {
// Inner minus borders
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
// Reserve 1 column on the right for a gutter and 1 for the scrollbar
Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
}
}
/// Handles key events for per-core CPU bars.
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
match key.code {
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
KeyCode::PageUp => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_sub(step);
}
KeyCode::PageDown => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_add(step);
}
KeyCode::Home => *scroll_offset = 0,
KeyCode::End => *scroll_offset = usize::MAX, // draw() clamps to max
_ => {}
}
}
/// Handles mouse wheel over the content.
pub fn per_core_handle_mouse(
scroll_offset: &mut usize,
mouse: MouseEvent,
content_area: Rect,
page_size: usize,
) {
let inside = mouse.column >= content_area.x
&& mouse.column < content_area.x + content_area.width
&& mouse.row >= content_area.y
&& mouse.row < content_area.y + content_area.height;
if !inside {
return;
}
match mouse.kind {
MouseEventKind::ScrollUp => *scroll_offset = scroll_offset.saturating_sub(1),
MouseEventKind::ScrollDown => *scroll_offset = scroll_offset.saturating_add(1),
// Optional paging via horizontal wheel
MouseEventKind::ScrollLeft => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_sub(step);
}
MouseEventKind::ScrollRight => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_add(step);
}
_ => {}
}
}
/// Handles mouse interaction with the scrollbar itself (click arrows/page/drag).
pub fn per_core_handle_scrollbar_mouse(
scroll_offset: &mut usize,
drag: &mut Option<PerCoreScrollDrag>,
mouse: MouseEvent,
per_core_area: Rect,
total_rows: usize,
) {
// Geometry
let inner = Rect {
x: per_core_area.x + 1,
y: per_core_area.y + 1,
width: per_core_area.width.saturating_sub(2),
height: per_core_area.height.saturating_sub(2),
};
if inner.height < 3 || inner.width < 1 {
return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
let viewport_rows = content.height as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let mut offset = (*scroll_offset).min(max_off);
// Track and current thumb
let track = (scroll_area.height - 2) as usize;
if track == 0 {
return;
}
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
}
};
let thumb_top = top_for_offset(offset);
let inside_scrollbar = mouse.column == scroll_area.x
&& mouse.row >= scroll_area.y
&& mouse.row < scroll_area.y + scroll_area.height;
// Helper to page
let page_up = || offset.saturating_sub(view.max(1));
let page_down = || offset.saturating_add(view.max(1));
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) if inside_scrollbar => {
// Where within the track?
let row = mouse.row;
if row == scroll_area.y {
// Top arrow
offset = offset.saturating_sub(1);
} else if row + 1 == scroll_area.y + scroll_area.height {
// Bottom arrow
offset = offset.saturating_add(1);
} else {
// Inside track
let rel = (row - (scroll_area.y + 1)) as usize;
let thumb_end = thumb_top + thumb_len;
if rel < thumb_top {
// Page up
offset = page_up();
} else if rel >= thumb_end {
// Page down
offset = page_down();
} else {
// Start dragging
*drag = Some(PerCoreScrollDrag {
active: true,
start_y: row,
start_top: thumb_top,
});
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(mut d) = drag.take() {
if d.active {
let dy = (mouse.row as i32) - (d.start_y as i32);
let new_top = (d.start_top as i32 + dy)
.clamp(0, (track.saturating_sub(thumb_len)) as i32)
as usize;
// 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
};
} else {
offset = 0;
}
// Keep dragging
d.start_top = new_top;
d.start_y = mouse.row;
*drag = Some(d);
}
}
}
MouseEventKind::Up(MouseButton::Left) => {
// End drag
*drag = None;
}
// Also allow wheel scrolling when cursor is over the scrollbar
MouseEventKind::ScrollUp if inside_scrollbar => {
offset = offset.saturating_sub(1);
}
MouseEventKind::ScrollDown if inside_scrollbar => {
offset = offset.saturating_add(1);
}
_ => {}
}
// Clamp and write back
if offset > max_off {
offset = max_off;
}
*scroll_offset = offset;
}
/// Clamp scroll offset to the valid range given content and viewport.
pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_rows: usize) {
let max_offset = total_rows.saturating_sub(viewport_rows);
if *scroll_offset > max_offset {
*scroll_offset = max_offset;
}
}
/// Draws the CPU average sparkline graph.
pub fn draw_cpu_avg_graph( pub fn draw_cpu_avg_graph(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
hist: &std::collections::VecDeque<u64>, hist: &std::collections::VecDeque<u64>,
m: Option<&Metrics>, m: Option<&Metrics>,
) { ) {
let title = if let Some(mm) = m { format!("CPU avg (now: {:>5.1}%)", mm.cpu_total) } else { "CPU avg".into() }; let title = if let Some(mm) = m {
format!("CPU avg (now: {:>5.1}%)", mm.cpu_total)
} else {
"CPU avg".into()
};
let max_points = area.width.saturating_sub(2) as usize; let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points); let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect(); let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
@@ -29,38 +256,74 @@ pub fn draw_cpu_avg_graph(
f.render_widget(spark, area); f.render_widget(spark, area);
} }
/// Draws the per-core CPU bars with sparklines and trends.
pub fn draw_per_core_bars( pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
m: Option<&Metrics>, m: Option<&Metrics>,
per_core_hist: &PerCoreHistory, per_core_hist: &PerCoreHistory,
scroll_offset: usize,
) { ) {
f.render_widget(Block::default().borders(Borders::ALL).title("Per-core"), area); f.render_widget(
let Some(mm) = m else { return; }; Block::default().borders(Borders::ALL).title("Per-core"),
area,
);
let Some(mm) = m else {
return;
};
let inner = Rect { x: area.x + 1, y: area.y + 1, width: area.width.saturating_sub(2), height: area.height.saturating_sub(2) }; // Compute inner rect and content area
if inner.height == 0 { return; } let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height == 0 || inner.width <= 2 {
return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let total_rows = mm.cpu_per_core.len();
let viewport_rows = content.height as usize;
let max_offset = total_rows.saturating_sub(viewport_rows);
let offset = scroll_offset.min(max_offset);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
let rows = inner.height as usize;
let show_n = rows.min(mm.cpu_per_core.len());
let constraints: Vec<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect(); let constraints: Vec<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect();
let vchunks = Layout::default().direction(Direction::Vertical).constraints(constraints).split(inner); let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(content);
for i in 0..show_n { for i in 0..show_n {
let idx = offset + i;
let rect = vchunks[i]; let rect = vchunks[i];
let hchunks = Layout::default() let hchunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Min(6), Constraint::Length(12)]) .constraints([Constraint::Min(6), Constraint::Length(12)])
.split(rect); .split(rect);
let curr = mm.cpu_per_core[i].clamp(0.0, 100.0); let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
let older = per_core_hist.deques.get(i) let older = per_core_hist
.deques
.get(idx)
.and_then(|d| d.iter().rev().nth(20).copied()) .and_then(|d| d.iter().rev().nth(20).copied())
.map(|v| v as f32) .map(|v| v as f32)
.unwrap_or(curr); .unwrap_or(curr);
let trend = if curr > older + 0.2 { "" }
else if curr + 0.2 < older { "" } let trend = if curr > older + 0.2 {
else { "" }; ""
} else if curr + 0.2 < older {
""
} else {
""
};
let fg = match curr { let fg = match curr {
x if x < 25.0 => Color::Green, x if x < 25.0 => Color::Green,
@@ -70,7 +333,7 @@ pub fn draw_per_core_bars(
let hist: Vec<u64> = per_core_hist let hist: Vec<u64> = per_core_hist
.deques .deques
.get(i) .get(idx)
.map(|d| { .map(|d| {
let max_points = hchunks[0].width as usize; let max_points = hchunks[0].width as usize;
let start = d.len().saturating_sub(max_points); let start = d.len().saturating_sub(max_points);
@@ -82,10 +345,49 @@ pub fn draw_per_core_bars(
.data(&hist) .data(&hist)
.max(100) .max(100)
.style(Style::default().fg(fg)); .style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]); f.render_widget(spark, hchunks[0]);
let label = format!("cpu{:<2}{}{:>5.1}%", i, trend, curr); 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))); let line = Line::from(Span::styled(
label,
Style::default().fg(fg).add_modifier(Modifier::BOLD),
));
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]); f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
} }
}
// Custom 1-col scrollbar with arrows, track, and exact mapping
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);
}
}
+27 -9
View File
@@ -1,16 +1,18 @@
//! Disk cards with per-device gauge and title line. //! Disk cards with per-device gauge and title line.
use crate::types::Metrics;
use crate::ui::util::{disk_icon, human, truncate_middle};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::Style, style::Style,
widgets::{Block, Borders, Gauge}, widgets::{Block, Borders, Gauge},
}; };
use crate::types::Metrics;
use crate::ui::util::{human, truncate_middle, disk_icon};
pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
f.render_widget(Block::default().borders(Borders::ALL).title("Disks"), area); f.render_widget(Block::default().borders(Borders::ALL).title("Disks"), area);
let Some(mm) = m else { return; }; let Some(mm) = m else {
return;
};
let inner = Rect { let inner = Rect {
x: area.x + 1, x: area.x + 1,
@@ -18,12 +20,16 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
width: area.width.saturating_sub(2), width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2), height: area.height.saturating_sub(2),
}; };
if inner.height < 3 { return; } if inner.height < 3 {
return;
}
let per_disk_h = 3u16; let per_disk_h = 3u16;
let max_cards = (inner.height / per_disk_h).min(mm.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)).collect(); let constraints: Vec<Constraint> = (0..max_cards)
.map(|_| Constraint::Length(per_disk_h))
.collect();
let rows = Layout::default() let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(constraints)
@@ -32,10 +38,20 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
for (i, slot) in rows.iter().enumerate() { for (i, slot) in rows.iter().enumerate() {
let d = &mm.disks[i]; let d = &mm.disks[i];
let used = d.total.saturating_sub(d.available); let used = d.total.saturating_sub(d.available);
let ratio = if d.total > 0 { used as f64 / d.total as f64 } else { 0.0 }; let ratio = if d.total > 0 {
used as f64 / d.total as f64
} else {
0.0
};
let pct = (ratio * 100.0).round() as u16; let pct = (ratio * 100.0).round() as u16;
let color = if pct < 70 { ratatui::style::Color::Green } else if pct < 90 { ratatui::style::Color::Yellow } else { ratatui::style::Color::Red }; let color = if pct < 70 {
ratatui::style::Color::Green
} else if pct < 90 {
ratatui::style::Color::Yellow
} else {
ratatui::style::Color::Red
};
let title = format!( let title = format!(
"{} {} {} / {} ({}%)", "{} {} {} / {} ({}%)",
@@ -55,7 +71,9 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
width: slot.width.saturating_sub(2), width: slot.width.saturating_sub(2),
height: slot.height.saturating_sub(2), height: slot.height.saturating_sub(2),
}; };
if inner_card.height == 0 { continue; } if inner_card.height == 0 {
continue;
}
let gauge_rect = Rect { let gauge_rect = Rect {
x: inner_card.x, x: inner_card.x,
@@ -70,4 +88,4 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
f.render_widget(g, gauge_rect); f.render_widget(g, gauge_rect);
} }
} }
+123
View File
@@ -0,0 +1,123 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::Span,
widgets::{Block, Borders, Gauge, Paragraph},
};
use crate::types::Metrics;
fn fmt_bytes(b: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let fb = b as f64;
if fb >= GB {
format!("{:.1}G", fb / GB)
} else if fb >= MB {
format!("{:.1}M", fb / MB)
} else if fb >= KB {
format!("{:.1}K", fb / KB)
} else {
format!("{b}B")
}
}
pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let mut area = area;
let block = Block::default().borders(Borders::ALL).title("GPU");
f.render_widget(block, area);
// Guard: need some space inside the block
if area.height <= 2 || area.width <= 2 {
return;
}
// Inner padding consistent with the rest of the app
area.y += 1;
area.height = area.height.saturating_sub(2);
area.x += 1;
area.width = area.width.saturating_sub(2);
let Some(metrics) = m else {
return;
};
let Some(gpus) = metrics.gpus.as_ref() else {
f.render_widget(Paragraph::new("No GPUs"), area);
return;
};
if gpus.is_empty() {
f.render_widget(Paragraph::new("No GPUs"), area);
return;
}
// Show 3 rows per GPU: name, util bar, vram bar.
if area.height < 3 {
return;
}
let per_gpu_rows: u16 = 3;
let max_gpus = (area.height / per_gpu_rows) as usize;
let count = gpus.len().min(max_gpus);
let constraints = vec![Constraint::Length(1); count * per_gpu_rows as usize];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
// Per bar horizontal layout: [gauge] [value]
let split_bar = |r: Rect| {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(8), // gauge column
Constraint::Length(24), // value column
])
.split(r)
};
for i in 0..count {
let g = &gpus[i];
// Row 1: GPU name
let name_text = g.name.as_deref().unwrap_or("GPU");
let name_p = Paragraph::new(Span::raw(name_text)).style(Style::default().fg(Color::Gray));
f.render_widget(name_p, rows[i * 3]);
// Row 2: Utilization bar + right label
let util_cols = split_bar(rows[i * 3 + 1]);
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let util_gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
.ratio(util as f64 / 100.0);
f.render_widget(util_gauge, util_cols[0]);
f.render_widget(
Paragraph::new(Span::raw(format!("util: {util}%")))
.style(Style::default().fg(Color::Gray)),
util_cols[1],
);
// Row 3: VRAM bar + right label
let mem_cols = split_bar(rows[i * 3 + 2]);
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = used as f64 / total as f64;
let mem_pct = (mem_ratio * 100.0).round() as u16;
let mem_gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::LightMagenta))
.label(Span::raw(""))
.ratio(mem_ratio);
f.render_widget(mem_gauge, mem_cols[0]);
let used_s = fmt_bytes(used);
let total_s = fmt_bytes(total);
f.render_widget(
Paragraph::new(Span::raw(format!("vram: {used_s}/{total_s} ({mem_pct}%)")))
.style(Style::default().fg(Color::Gray)),
mem_cols[1],
);
}
}
+19 -7
View File
@@ -1,20 +1,32 @@
//! Top header with hostname and CPU temperature indicator. //! Top header with hostname and CPU temperature indicator.
use crate::types::Metrics;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
widgets::{Block, Borders}, widgets::{Block, Borders},
}; };
use crate::types::Metrics;
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let title = if let Some(mm) = m { let title = if let Some(mm) = m {
let temp = mm.cpu_temp_c.map(|t| { let temp = mm
let icon = if t < 50.0 { "😎" } else if t < 85.0 { "⚠️" } else { "🔥" }; .cpu_temp_c
format!("CPU Temp: {:.1}°C {}", t, icon) .map(|t| {
}).unwrap_or_else(|| "CPU Temp: N/A".into()); let icon = if t < 50.0 {
format!("socktop — host: {} | {} (press 'q' to quit)", mm.hostname, temp) "😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into());
format!(
"socktop — host: {} | {} (press 'q' to quit)",
mm.hostname, temp
)
} else { } else {
"socktop — connecting... (press 'q' to quit)".into() "socktop — connecting... (press 'q' to quit)".into()
}; };
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
} }
+11 -5
View File
@@ -1,18 +1,24 @@
//! Memory gauge. //! Memory gauge.
use crate::types::Metrics;
use crate::ui::util::human;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
widgets::{Block, Borders, Gauge}, widgets::{Block, Borders, Gauge},
}; };
use crate::types::Metrics;
use crate::ui::util::human;
pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let (used, total, pct) = if let Some(mm) = m { let (used, total, pct) = if let Some(mm) = m {
let pct = if mm.mem_total > 0 { (mm.mem_used as f64 / mm.mem_total as f64 * 100.0) as u16 } else { 0 }; let pct = if mm.mem_total > 0 {
(mm.mem_used as f64 / mm.mem_total as f64 * 100.0) as u16
} else {
0
};
(mm.mem_used, mm.mem_total, pct) (mm.mem_used, mm.mem_total, pct)
} else { (0, 0, 0) }; } else {
(0, 0, 0)
};
let g = Gauge::default() let g = Gauge::default()
.block(Block::default().borders(Borders::ALL).title("Memory")) .block(Block::default().borders(Borders::ALL).title("Memory"))
@@ -20,4 +26,4 @@ pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
.percent(pct) .percent(pct)
.label(format!("{} / {}", human(used), human(total))); .label(format!("{} / {}", human(used), human(total)));
f.render_widget(g, area); f.render_widget(g, area);
} }
+6 -4
View File
@@ -1,10 +1,12 @@
//! UI module root: exposes drawing functions for individual panels. //! UI module root: exposes drawing functions for individual panels.
pub mod header;
pub mod cpu; pub mod cpu;
pub mod mem;
pub mod swap;
pub mod disks; pub mod disks;
pub mod gpu;
pub mod header;
pub mod mem;
pub mod net; pub mod net;
pub mod processes; pub mod processes;
pub mod util; pub mod swap;
pub mod theme;
pub mod util;
+7 -3
View File
@@ -1,11 +1,11 @@
//! Network sparklines (download/upload). //! Network sparklines (download/upload).
use std::collections::VecDeque;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
widgets::{Block, Borders, Sparkline}, widgets::{Block, Borders, Sparkline},
}; };
use std::collections::VecDeque;
pub fn draw_net_spark( pub fn draw_net_spark(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
@@ -19,8 +19,12 @@ pub fn draw_net_spark(
let data: Vec<u64> = hist.iter().skip(start).cloned().collect(); let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
let spark = Sparkline::default() let spark = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(title.to_string())) .block(
Block::default()
.borders(Borders::ALL)
.title(title.to_string()),
)
.data(&data) .data(&data)
.style(Style::default().fg(color)); .style(Style::default().fg(color));
f.render_widget(spark, area); f.render_widget(spark, area);
} }
+237 -42
View File
@@ -1,29 +1,105 @@
//! Top processes table with per-cell coloring and zebra striping. //! Top processes table with per-cell coloring, zebra striping, sorting, and a scrollbar.
use ratatui::{ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
layout::{Constraint, Rect},
style::{Color, Style},
widgets::{Block, Borders, Cell, Row, Table},
};
use ratatui::style::Modifier; use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Table},
};
use std::cmp::Ordering;
use crate::types::Metrics; use crate::types::Metrics;
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
use crate::ui::util::human; use crate::ui::util::human;
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
let Some(mm) = m else { pub enum ProcSortBy {
f.render_widget(Block::default().borders(Borders::ALL).title("Top Processes"), area); #[default]
CpuDesc,
MemDesc,
}
// Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID
Constraint::Percentage(40), // Name
Constraint::Length(8), // CPU %
Constraint::Length(12), // Mem
Constraint::Length(8), // Mem %
];
pub fn draw_top_processes(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
scroll_offset: usize,
sort_by: ProcSortBy,
) {
// Draw outer block and title
let Some(mm) = m else { return };
let total = mm.process_count.unwrap_or(mm.top_processes.len());
let block = Block::default()
.borders(Borders::ALL)
.title(format!("Top Processes ({total} total)"));
f.render_widget(block, area);
// Inner area and content area (reserve 2 columns for scrollbar)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height < 1 || inner.width < 3 {
return; return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
}; };
let total_mem_bytes = mm.mem_total.max(1); // Sort rows (by CPU% or Mem bytes), descending.
let title = format!("Top Processes ({} total)", mm.process_count); let mut idxs: Vec<usize> = (0..mm.top_processes.len()).collect();
let peak_cpu = mm.top_processes.iter().map(|p| p.cpu_usage).fold(0.0_f32, f32::max); match sort_by {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].cpu_usage;
let bb = mm.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].mem_bytes;
let bb = mm.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
let rows: Vec<Row> = mm.top_processes.iter().enumerate().map(|(i, p)| { // Scrolling
let total_rows = idxs.len();
let header_rows = 1usize;
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
let max_off = total_rows.saturating_sub(viewport_rows);
let offset = scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Build visible rows
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 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 mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let cpu_fg = match p.cpu_usage { let cpu_val = p.cpu_usage;
let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green, x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow, x if x < 60.0 => Color::Yellow,
_ => Color::Red, _ => Color::Red,
@@ -34,38 +110,157 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Met
_ => Color::Red, _ => Color::Red,
}; };
let zebra = if i % 2 == 0 { Style::default().fg(Color::Gray) } else { Style::default() }; let emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON {
let emphasis = if (p.cpu_usage - peak_cpu).abs() < f32::EPSILON {
Style::default().add_modifier(Modifier::BOLD) Style::default().add_modifier(Modifier::BOLD)
} else { Style::default() }; } else {
Style::default()
};
Row::new(vec![ let cpu_str = fmt_cpu_pct(cpu_val);
Cell::from(p.pid.to_string()).style(Style::default().fg(Color::DarkGray)),
Cell::from(p.name.clone()), ratatui::widgets::Row::new(vec![
Cell::from(format!("{:.1}%", p.cpu_usage)).style(Style::default().fg(cpu_fg)), ratatui::widgets::Cell::from(p.pid.to_string())
Cell::from(human(p.mem_bytes)), .style(Style::default().fg(Color::DarkGray)),
Cell::from(format!("{:.2}%", mem_pct)).style(Style::default().fg(mem_fg)), 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}%"))
.style(Style::default().fg(mem_fg)),
]) ])
.style(zebra.patch(emphasis)) .style(emphasis)
}).collect(); });
let header = Row::new(vec!["PID", "Name", "CPU %", "Mem", "Mem %"]) // Header with sort indicator
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); let cpu_hdr = match sort_by {
ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %",
};
let mem_hdr = match sort_by {
ProcSortBy::MemDesc => "Mem •",
_ => "Mem",
};
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let table = Table::new( // Render table inside content area (no borders here; outer block already drawn)
rows, let table = Table::new(rows_iter, COLS.to_vec())
vec![
Constraint::Length(8),
Constraint::Percentage(40),
Constraint::Length(8),
Constraint::Length(12),
Constraint::Length(8),
],
)
.header(header) .header(header)
.column_spacing(1) .column_spacing(1);
.block(Block::default().borders(Borders::ALL).title(title)); f.render_widget(table, content);
f.render_widget(table, area); // Draw scrollbar like CPU pane
} 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);
}
}
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)
pub fn processes_handle_key(
scroll_offset: &mut usize,
key: crossterm::event::KeyEvent,
page_size: usize,
) {
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
}
/// Handle mouse for content scrolling and scrollbar dragging.
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
pub fn processes_handle_mouse(
scroll_offset: &mut usize,
drag: &mut Option<crate::ui::cpu::PerCoreScrollDrag>,
mouse: MouseEvent,
area: Rect,
total_rows: usize,
) -> Option<ProcSortBy> {
// Inner and content areas (match draw_top_processes)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height == 0 || inner.width <= 2 {
return None;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
// Scrollbar interactions (click arrows/page/drag)
per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows);
// Wheel scrolling when inside the content
crate::ui::cpu::per_core_handle_mouse(scroll_offset, mouse, content, content.height as usize);
// Header click to change sort
let header_area = Rect {
x: content.x,
y: content.y,
width: content.width,
height: 1,
};
let inside_header = mouse.row == header_area.y
&& mouse.column >= header_area.x
&& mouse.column < header_area.x + header_area.width;
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split header into the same columns
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(COLS.to_vec())
.split(header_area);
if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
return Some(ProcSortBy::CpuDesc);
}
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
return Some(ProcSortBy::MemDesc);
}
}
// Clamp to valid range
per_core_clamp(
scroll_offset,
total_rows,
(content.height.saturating_sub(1)) as usize,
);
None
}
+11 -5
View File
@@ -1,18 +1,24 @@
//! Swap gauge. //! Swap gauge.
use crate::types::Metrics;
use crate::ui::util::human;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
widgets::{Block, Borders, Gauge}, widgets::{Block, Borders, Gauge},
}; };
use crate::types::Metrics;
use crate::ui::util::human;
pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) { pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let (used, total, pct) = if let Some(mm) = m { let (used, total, pct) = if let Some(mm) = m {
let pct = if mm.swap_total > 0 { (mm.swap_used as f64 / mm.swap_total as f64 * 100.0) as u16 } else { 0 }; let pct = if mm.swap_total > 0 {
(mm.swap_used as f64 / mm.swap_total as f64 * 100.0) as u16
} else {
0
};
(mm.swap_used, mm.swap_total, pct) (mm.swap_used, mm.swap_total, pct)
} else { (0, 0, 0) }; } else {
(0, 0, 0)
};
let g = Gauge::default() let g = Gauge::default()
.block(Block::default().borders(Borders::ALL).title("Swap")) .block(Block::default().borders(Borders::ALL).title("Swap"))
@@ -20,4 +26,4 @@ pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
.percent(pct) .percent(pct)
.label(format!("{} / {}", human(used), human(total))); .label(format!("{} / {}", human(used), human(total)));
f.render_widget(g, area); f.render_widget(g, area);
} }
+8
View File
@@ -0,0 +1,8 @@
//! Shared UI theme constants.
use ratatui::style::Color;
// Scrollbar colors (same look as before)
pub const SB_ARROW: Color = Color::Rgb(170, 170, 180);
pub const SB_TRACK: Color = Color::Rgb(170, 170, 180);
pub const SB_THUMB: Color = Color::Rgb(170, 170, 180);
+31 -13
View File
@@ -3,31 +3,49 @@
pub fn human(b: u64) -> String { pub fn human(b: u64) -> String {
const K: f64 = 1024.0; const K: f64 = 1024.0;
let b = b as f64; let b = b as f64;
if b < K { return format!("{b:.0}B"); } if b < K {
return format!("{b:.0}B");
}
let kb = b / K; let kb = b / K;
if kb < K { return format!("{kb:.1}KB"); } if kb < K {
return format!("{kb:.1}KB");
}
let mb = kb / K; let mb = kb / K;
if mb < K { return format!("{mb:.1}MB"); } if mb < K {
return format!("{mb:.1}MB");
}
let gb = mb / K; let gb = mb / K;
if gb < K { return format!("{gb:.1}GB"); } if gb < K {
return format!("{gb:.1}GB");
}
let tb = gb / K; let tb = gb / K;
format!("{tb:.2}TB") format!("{tb:.2}TB")
} }
pub fn truncate_middle(s: &str, max: usize) -> String { pub fn truncate_middle(s: &str, max: usize) -> String {
if s.len() <= max { return s.to_string(); } if s.len() <= max {
if max <= 3 { return "...".into(); } return s.to_string();
}
if max <= 3 {
return "...".into();
}
let keep = max - 3; let keep = max - 3;
let left = keep / 2; let left = keep / 2;
let right = keep - left; let right = keep - left;
format!("{}...{}", &s[..left], &s[s.len()-right..]) format!("{}...{}", &s[..left], &s[s.len() - right..])
} }
pub fn disk_icon(name: &str) -> &'static str { pub fn disk_icon(name: &str) -> &'static str {
let n = name.to_ascii_lowercase(); let n = name.to_ascii_lowercase();
if n.contains(':') { "🗄️" } if n.contains(':') {
else if n.contains("nvme") { "" } "🗄️"
else if n.starts_with("sd") { "💽" } } else if n.contains("nvme") {
else if n.contains("overlay") { "📦" } ""
else { "🖴" } } else if n.starts_with("sd") {
} "💽"
} else if n.contains("overlay") {
"📦"
} else {
"🖴"
}
}
+141 -6
View File
@@ -1,15 +1,63 @@
//! Minimal WebSocket client helpers for requesting metrics from the agent. //! Minimal WebSocket client helpers for requesting metrics from the agent.
use flate2::bufread::GzDecoder;
use futures_util::{SinkExt, StreamExt};
use prost::Message as _;
use rustls::{ClientConfig, RootCertStore};
use rustls_pemfile::Item;
use std::io::Read;
use std::{fs::File, io::BufReader, sync::Arc};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{
connect_async, connect_async_tls_with_config, tungstenite::client::IntoClientRequest,
tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream,
};
use url::Url;
use crate::types::Metrics; use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
mod pb {
// generated by build.rs
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
}
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>; pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
// Connect to the agent and return the WS stream // Connect to the agent and return the WS stream
pub async fn connect(url: &str) -> Result<WsStream, Box<dyn std::error::Error>> { pub async fn connect(
let (ws, _) = connect_async(url).await?; url: &str,
tls_ca: Option<&str>,
) -> Result<WsStream, Box<dyn std::error::Error>> {
let mut u = Url::parse(url)?;
if let Some(ca_path) = tls_ca {
if u.scheme() == "ws" {
let _ = u.set_scheme("wss");
}
return connect_with_ca(u.as_str(), ca_path).await;
}
let (ws, _) = connect_async(u.as_str()).await?;
Ok(ws)
}
async fn connect_with_ca(url: &str, ca_path: &str) -> Result<WsStream, Box<dyn std::error::Error>> {
let mut root = RootCertStore::empty();
let mut reader = BufReader::new(File::open(ca_path)?);
let mut der_certs = Vec::new();
while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) {
if let Item::X509Certificate(der) = item {
der_certs.push(der);
}
}
root.add_parsable_certificates(der_certs);
let cfg = ClientConfig::builder()
.with_root_certificates(root)
.with_no_client_auth();
let cfg = Arc::new(cfg);
let req = url.into_client_request()?;
let (ws, _) =
connect_async_tls_with_config(req, None, true, Some(Connector::Rustls(cfg))).await?;
Ok(ws) Ok(ws)
} }
@@ -19,10 +67,97 @@ pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
return None; return None;
} }
match ws.next().await { match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Metrics>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(), Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
_ => None, _ => None,
} }
} }
// Re-export SinkExt/StreamExt for call sites // Decompress a gzip-compressed binary frame into a String.
use futures_util::{SinkExt, StreamExt}; fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
let mut dec = GzDecoder::new(bytes);
let mut out = String::new();
dec.read_to_string(&mut out).ok()?;
Some(out)
}
fn gunzip_to_vec(bytes: &[u8]) -> Option<Vec<u8>> {
let mut dec = GzDecoder::new(bytes);
let mut out = Vec::new();
dec.read_to_end(&mut out).ok()?;
Some(out)
}
fn is_gzip(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
}
// Suppress dead_code until these are wired into the app
#[allow(dead_code)]
pub enum Payload {
Metrics(Metrics),
Disks(Vec<DiskInfo>),
Processes(ProcessesPayload),
}
// Send a "get_disks" request and await a JSON Vec<DiskInfo>
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
if ws.send(Message::Text("get_disks".into())).await.is_err() {
return None;
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Vec<DiskInfo>>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<Vec<DiskInfo>>(&json).ok(),
_ => None,
}
}
// Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped)
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
if ws
.send(Message::Text("get_processes".into()))
.await
.is_err()
{
return None;
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
let gz = is_gzip(&b);
let data = if gz { gunzip_to_vec(&b)? } else { b };
match pb::Processes::decode(data.as_slice()) {
Ok(pb) => {
let rows: Vec<ProcessInfo> = pb
.rows
.into_iter()
.map(|p: pb::Process| ProcessInfo {
pid: p.pid,
name: p.name,
cpu_usage: p.cpu_usage,
mem_bytes: p.mem_bytes,
})
.collect();
Some(ProcessesPayload {
process_count: pb.process_count as usize,
top_processes: rows,
})
}
Err(e) => {
if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") {
eprintln!("protobuf decode failed: {e}");
}
// Fallback: maybe it's JSON (bytes already decompressed if gz)
match String::from_utf8(data) {
Ok(s) => serde_json::from_str::<ProcessesPayload>(&s).ok(),
Err(_) => None,
}
}
}
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
_ => None,
}
}
+56
View File
@@ -0,0 +1,56 @@
//! CLI arg parsing tests for socktop (client)
use std::process::Command;
// We test the parsing by invoking the binary with --help and ensuring the help mentions short and long flags.
// Also directly test the parse_args function via a tiny helper in a doctest-like fashion using a small
// reimplementation here kept in sync with main (compile-time test).
#[test]
fn test_help_mentions_short_and_long_flags() {
let output = Command::new(env!("CARGO_BIN_EXE_socktop"))
.arg("--help")
.output()
.expect("run socktop --help");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
text.contains("--tls-ca") && text.contains("-t"),
"help text missing --tls-ca/-t\n{text}"
);
}
#[test]
fn test_tlc_ca_arg_long_and_short_parsed() {
// Use --help combined with flags to avoid network and still exercise arg acceptance
let exe = env!("CARGO_BIN_EXE_socktop");
// Long form with help
let out = Command::new(exe)
.args(["--tls-ca", "/tmp/cert.pem", "--help"])
.output()
.expect("run socktop");
assert!(
out.status.success(),
"socktop --tls-ca … --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(text.contains("Usage:"));
// Short form with help
let out2 = Command::new(exe)
.args(["-t", "/tmp/cert.pem", "--help"])
.output()
.expect("run socktop");
assert!(out2.status.success(), "socktop -t … --help did not succeed");
let text2 = format!(
"{}{}",
String::from_utf8_lossy(&out2.stdout),
String::from_utf8_lossy(&out2.stderr)
);
assert!(text2.contains("Usage:"));
}
+29
View File
@@ -0,0 +1,29 @@
use socktop::ws::{connect, request_metrics, request_processes};
// Integration probe: only runs when SOCKTOP_WS is set to an agent WebSocket URL.
// Example: SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop --test ws_probe -- --nocapture
#[tokio::test]
async fn probe_ws_endpoints() {
// Gate the test to avoid CI failures when no agent is running.
let url = match std::env::var("SOCKTOP_WS") {
Ok(v) if !v.is_empty() => v,
_ => {
eprintln!(
"skipping ws_probe: set SOCKTOP_WS=ws://host:port/ws to run this integration test"
);
return;
}
};
// Optional pinned CA for WSS/self-signed setups
let tls_ca = std::env::var("SOCKTOP_TLS_CA").ok();
let mut ws = connect(&url, tls_ca.as_deref()).await.expect("connect ws");
// Should get fast metrics quickly
let m = request_metrics(&mut ws).await;
assert!(m.is_some(), "expected Metrics payload within timeout");
// Processes may be gzipped and a bit slower, but should arrive
let p = request_processes(&mut ws).await;
assert!(p.is_some(), "expected Processes payload within timeout");
}
+26 -3
View File
@@ -1,17 +1,40 @@
[package] [package]
name = "socktop_agent" name = "socktop_agent"
version = "0.1.0" version = "0.1.11"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"] authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top" description = "Remote system monitor over WebSocket, TUI like top"
edition = "2021" edition = "2021"
license = "MIT"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["ws", "macros"] } axum = { version = "0.7", features = ["ws", "macros"] }
sysinfo = "0.36.1" sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
futures = "0.3" futures = "0.3"
futures-util = "0.3.31" futures-util = "0.3.31"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nvml-wrapper = "0.10"
gfxinfo = "0.1.2"
tungstenite = "0.27.0"
once_cell = "1.19"
axum-server = { version = "0.6", features = ["tls-rustls"] }
rustls = "0.23"
rustls-pemfile = "2.1"
openssl = { version = "0.10", features = ["vendored"] } # for crossplatform selfsigned generation
anyhow = "1"
hostname = "0.3"
bytes = { workspace = true }
prost = { workspace = true }
[build-dependencies]
prost-build = "0.13"
prost-types = { workspace = true }
tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"
+11
View File
@@ -0,0 +1,11 @@
fn main() {
// Ensure protoc exists (vendored for reproducible builds)
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
std::env::set_var("PROTOC", protoc);
// Compile protobuf definitions for processes
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
cfg.compile_protos(&["../proto/processes.proto"], &["../proto"])
.expect("compile protos");
}
+24
View File
@@ -0,0 +1,24 @@
// gpu.rs
use gfxinfo::active_gpu;
#[derive(Debug, Clone, serde::Serialize)]
pub struct GpuMetrics {
pub name: String,
pub utilization_gpu_pct: u32, // 0..100
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
}
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
let gpu = active_gpu()?; // Use ? to unwrap Result
let info = gpu.info();
let metrics = GpuMetrics {
name: gpu.model().to_string(),
utilization_gpu_pct: info.load_pct() as u32,
mem_used_bytes: info.used_vram(),
mem_total_bytes: info.total_vram(),
};
Ok(vec![metrics])
}
+110 -110
View File
@@ -1,136 +1,136 @@
//! socktop agent entrypoint: sets up sysinfo handles, launches a sampler, //! socktop agent entrypoint: sets up sysinfo handles, launches a sampler,
//! and serves a WebSocket endpoint at /ws. //! and serves a WebSocket endpoint at /ws.
mod gpu;
mod metrics; mod metrics;
mod proto;
mod sampler; mod sampler;
mod state; mod state;
mod ws;
mod types; mod types;
mod ws;
use axum::{routing::get, Router}; use axum::{http::StatusCode, routing::get, Router};
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration, sync::atomic::AtomicUsize}; use std::net::SocketAddr;
use sysinfo::{ use std::str::FromStr;
Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind, RefreshKind,
System,
};
use tokio::sync::{Mutex, RwLock, Notify};
use tracing_subscriber::EnvFilter;
use state::{AppState, SharedTotals}; mod tls;
use sampler::spawn_sampler;
use ws::ws_handler; use crate::sampler::{spawn_disks_sampler, spawn_process_sampler, spawn_sampler};
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
}
// (tests moved to end of file to satisfy clippy::items_after_test_module)
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
// Init logging; configure with RUST_LOG (e.g., RUST_LOG=info). let state = AppState::new();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_target(false)
.compact()
.init();
// sysinfo build specifics (scopes what refresh_all() will touch internally)
let refresh_kind = RefreshKind::nothing()
.with_cpu(CpuRefreshKind::everything())
.with_memory(MemoryRefreshKind::everything())
.with_processes(ProcessRefreshKind::everything());
// Initialize sysinfo handles once and keep them alive
let mut sys = System::new_with_specifics(refresh_kind);
sys.refresh_all();
let mut nets = Networks::new();
nets.refresh(true);
let mut components = Components::new();
components.refresh(true);
let mut disks = Disks::new();
disks.refresh(true);
// Shared state across requests
let state = AppState {
sys: Arc::new(Mutex::new(sys)),
nets: Arc::new(Mutex::new(nets)),
net_totals: Arc::new(Mutex::new(HashMap::<String, (u64, u64)>::new())) as SharedTotals,
components: Arc::new(Mutex::new(components)),
disks: Arc::new(Mutex::new(disks)),
last_json: Arc::new(RwLock::new(String::new())),
// new: adaptive sampling controls
client_count: Arc::new(AtomicUsize::new(0)),
wake_sampler: Arc::new(Notify::new()),
auth_token: std::env::var("SOCKTOP_TOKEN").ok().filter(|s| !s.is_empty()),
};
// Start background sampler (adjust cadence as needed) // Start background sampler (adjust cadence as needed)
let _sampler = spawn_sampler(state.clone(), Duration::from_millis(500)); // 500ms fast metrics
let _h_fast = spawn_sampler(state.clone(), std::time::Duration::from_millis(500));
// 2s processes (top 50)
let _h_procs = spawn_process_sampler(state.clone(), std::time::Duration::from_secs(2), 50);
// 5s disks
let _h_disks = spawn_disks_sampler(state.clone(), std::time::Duration::from_secs(5));
// Web app // Web app: route /ws to the websocket handler
let port = resolve_port(); async fn healthz() -> StatusCode {
let app = Router::new().route("/ws", get(ws_handler)).with_state(state); println!("/healthz request");
StatusCode::OK
}
let app = Router::new()
.route("/ws", get(ws::ws_handler))
.route("/healthz", get(healthz))
.with_state(state.clone());
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?;
let addr = SocketAddr::from_str(&format!("0.0.0.0:{port}"))?;
println!("socktop_agent: TLS enabled. Listening on wss://{addr}/ws");
axum_server::bind_rustls(addr, cfg)
.serve(app.into_make_service())
.await?;
return Ok(());
}
// Non-TLS HTTP/WS path
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)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("socktop_agent: Listening on ws://{addr}/ws");
//output to console axum_server::bind(addr)
println!("Remote agent running at http://{}", addr); .serve(app.into_make_service())
println!("WebSocket endpoint: ws://{}/ws", addr); .await?;
Ok(())
//trace logging
tracing::info!("Remote agent running at http://{} (ws at /ws)", addr);
tracing::info!("WebSocket endpoint: ws://{}/ws", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
} }
// Resolve the listening port from CLI args/env with a 3000 default. #[cfg(test)]
// Supports: --port <PORT>, -p <PORT>, a bare numeric positional arg, or SOCKTOP_PORT. mod tests_cli_agent {
fn resolve_port() -> u16 { // Local helper for testing port parsing
const DEFAULT: u16 = 3000; fn parse_port<I: IntoIterator<Item = String>>(args: I, default_port: u16) -> u16 {
let mut it = args.into_iter();
// Env takes precedence over positional, but is overridden by explicit flags if present. let _ = it.next(); // prog
if let Ok(s) = std::env::var("SOCKTOP_PORT") { let mut long: Option<String> = None;
if let Ok(p) = s.parse::<u16>() { let mut short: Option<String> = None;
if p != 0 { while let Some(a) = it.next() {
return p; match a.as_str() {
} "--port" => long = it.next(),
} "-p" => short = it.next(),
eprintln!("Warning: invalid SOCKTOP_PORT='{}'; using default {}", s, DEFAULT); _ if a.starts_with("--port=") => {
} if let Some((_, v)) = a.split_once('=') {
long = Some(v.to_string());
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--port" | "-p" => {
if let Some(v) = args.next() {
match v.parse::<u16>() {
Ok(p) if p != 0 => return p,
_ => {
eprintln!("Invalid port '{}'; using default {}", v, DEFAULT);
return DEFAULT;
}
}
} else {
eprintln!("Missing value for {} ; using default {}", arg, DEFAULT);
return DEFAULT;
}
}
"--help" | "-h" => {
println!("Usage: socktop_agent [--port <PORT>] [PORT]\n SOCKTOP_PORT=<PORT> socktop_agent");
std::process::exit(0);
}
s => {
if let Ok(p) = s.parse::<u16>() {
if p != 0 {
return p;
} }
} }
_ => {}
} }
} }
long.or(short)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(default_port)
} }
DEFAULT #[test]
fn port_long_short_and_assign() {
assert_eq!(
parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443),
9001
);
assert_eq!(
parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443),
9002
);
assert_eq!(
parse_port(vec!["agent".into(), "--port=9003".into()], 8443),
9003
);
assert_eq!(parse_port(vec!["agent".into()], 8443), 8443);
}
} }
+336 -100
View File
@@ -1,128 +1,364 @@
//! Metrics collection using sysinfo. Keeps sysinfo handles in AppState to //! Metrics collection using sysinfo for socktop_agent.
//! avoid repeated allocations and allow efficient refreshes.
use crate::gpu::collect_all_gpus;
use crate::state::AppState; use crate::state::AppState;
use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo}; use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload};
use sysinfo::{Components, System}; use once_cell::sync::OnceCell;
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(target_os = "linux")]
use std::fs;
#[cfg(target_os = "linux")]
use std::io;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
use tracing::warn;
pub async fn collect_metrics(state: &AppState) -> Metrics { // Runtime toggles (read once)
// System (CPU/mem/proc) fn gpu_enabled() -> bool {
static ON: OnceCell<bool> = OnceCell::new();
*ON.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_GPU")
.map(|v| v != "0")
.unwrap_or(true)
})
}
fn temp_enabled() -> bool {
static ON: OnceCell<bool> = OnceCell::new();
*ON.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_TEMP")
.map(|v| v != "0")
.unwrap_or(true)
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms
const TTL: Duration = Duration::from_millis(1500);
struct TempCache {
at: Option<Instant>,
v: Option<f32>,
}
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
struct GpuCache {
at: Option<Instant>,
v: Option<Vec<crate::gpu::GpuMetrics>>,
}
static GPUC: OnceCell<Mutex<GpuCache>> = OnceCell::new();
fn cached_temp() -> Option<f32> {
if !temp_enabled() {
return None;
}
let now = Instant::now();
let lock = TEMP.get_or_init(|| Mutex::new(TempCache { at: None, v: None }));
let mut c = lock.lock().ok()?;
if c.at.is_none_or(|t| now.duration_since(t) >= TTL) {
c.at = Some(now);
// caller will fill this; we just hold a slot
c.v = None;
}
c.v
}
fn set_temp(v: Option<f32>) {
if let Some(lock) = TEMP.get() {
if let Ok(mut c) = lock.lock() {
c.v = v;
c.at = Some(Instant::now());
}
}
}
fn cached_gpus() -> Option<Vec<crate::gpu::GpuMetrics>> {
if !gpu_enabled() {
return None;
}
let now = Instant::now();
let lock = GPUC.get_or_init(|| Mutex::new(GpuCache { at: None, v: None }));
let mut c = lock.lock().ok()?;
if c.at.is_none_or(|t| now.duration_since(t) >= TTL) {
// mark stale; caller will refresh
c.at = Some(now);
c.v = None;
}
c.v.clone()
}
fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
if let Some(lock) = GPUC.get() {
if let Ok(mut c) = lock.lock() {
c.v = v.clone();
c.at = Some(Instant::now());
}
}
}
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let mut sys = state.sys.lock().await; let mut sys = state.sys.lock().await;
// Simple and safe — can be replaced by more granular refresh if desired: if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// sys.refresh_cpu(); sys.refresh_memory(); sys.refresh_processes_specifics(...); sys.refresh_cpu_usage();
//sys.refresh_all(); sys.refresh_memory();
//refresh all was found to use 2X CPU rather than individual refreshes })) {
sys.refresh_cpu_all(); warn!("sysinfo selective refresh panicked: {e:?}");
sys.refresh_memory(); }
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let hostname = System::host_name().unwrap_or_else(|| "unknown".into()); let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
let cpu_total = sys.global_cpu_usage();
let cpu_per_core: Vec<f32> = sys.cpus().iter().map(|c| c.cpu_usage()).collect();
let mem_total = sys.total_memory();
let mem_used = mem_total.saturating_sub(sys.available_memory());
let swap_total = sys.total_swap();
let swap_used = sys.used_swap();
drop(sys);
// Temps via a persistent Components handle // CPU temperature: only refresh sensors if cache is stale
let mut components = state.components.lock().await; let cpu_temp_c = if cached_temp().is_some() {
components.refresh(true); cached_temp()
let cpu_temp_c = best_cpu_temp(&components); } else if temp_enabled() {
let val = {
let mut components = state.components.lock().await;
components.refresh(false);
components.iter().find_map(|c| {
let l = c.label().to_ascii_lowercase();
if l.contains("cpu")
|| l.contains("package")
|| l.contains("tctl")
|| l.contains("tdie")
{
c.temperature()
} else {
None
}
})
};
set_temp(val);
val
} else {
None
};
// Disks via a persistent Disks handle // Networks
let mut disks_struct = state.disks.lock().await; let networks: Vec<NetworkInfo> = {
disks_struct.refresh(true); let mut nets = state.networks.lock().await;
// Filter anything with available == 0 (e.g., overlay/virtual) nets.refresh(false);
let disks: Vec<DiskInfo> = disks_struct nets.iter()
.list() .map(|(name, data)| NetworkInfo {
name: name.to_string(),
received: data.total_received(),
transmitted: data.total_transmitted(),
})
.collect()
};
// GPUs: refresh only when cache is stale
let gpus = if cached_gpus().is_some() {
cached_gpus()
} else if gpu_enabled() {
let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None,
Err(e) => {
warn!("gpu collection failed: {e}");
None
}
};
set_gpus(v.clone());
v
} else {
None
};
Metrics {
cpu_total,
cpu_per_core,
mem_total,
mem_used,
swap_total,
swap_used,
hostname,
cpu_temp_c,
disks: Vec::new(),
networks,
top_processes: Vec::new(),
gpus,
}
}
// Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let mut disks_list = state.disks.lock().await;
disks_list.refresh(false); // don't drop missing disks
disks_list
.iter() .iter()
.filter(|d| d.available_space() > 0)
.map(|d| DiskInfo { .map(|d| DiskInfo {
name: d.name().to_string_lossy().to_string(), name: d.name().to_string_lossy().into_owned(),
total: d.total_space(), total: d.total_space(),
available: d.available_space(), available: d.available_space(),
}) })
.collect(); .collect()
}
// Networks: use a persistent Networks + rolling totals // Linux-only helpers and implementation using /proc deltas for accurate CPU%.
let mut nets = state.nets.lock().await; #[cfg(target_os = "linux")]
nets.refresh(true); #[inline]
let mut totals = state.net_totals.lock().await; fn read_total_jiffies() -> io::Result<u64> {
let mut networks: Vec<NetworkInfo> = Vec::new(); // /proc/stat first line: "cpu user nice system idle iowait irq softirq steal ..."
for (name, data) in nets.iter() { let s = fs::read_to_string("/proc/stat")?;
// sysinfo: received()/transmitted() are deltas since last refresh if let Some(line) = s.lines().next() {
let delta_rx = data.received(); let mut it = line.split_whitespace();
let delta_tx = data.transmitted(); let _cpu = it.next(); // "cpu"
let mut sum: u64 = 0;
for tok in it.take(8) {
if let Ok(v) = tok.parse::<u64>() {
sum = sum.saturating_add(v);
}
}
return Ok(sum);
}
Err(io::Error::other("no cpu line"))
}
let entry = totals.entry(name.clone()).or_insert((0, 0)); #[cfg(target_os = "linux")]
entry.0 = entry.0.saturating_add(delta_rx); #[inline]
entry.1 = entry.1.saturating_add(delta_tx); fn read_proc_jiffies(pid: u32) -> Option<u64> {
let path = format!("/proc/{pid}/stat");
let s = fs::read_to_string(path).ok()?;
// Find the right parenthesis that terminates comm; everything after is space-separated fields starting at "state"
let rpar = s.rfind(')')?;
let after = s.get(rpar + 2..)?; // skip ") "
let mut it = after.split_whitespace();
// utime (14th field) is offset 11 from "state", stime (15th) is next
let utime = it.nth(11)?.parse::<u64>().ok()?;
let stime = it.next()?.parse::<u64>().ok()?;
Some(utime.saturating_add(stime))
}
networks.push(NetworkInfo { /// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
name: name.clone(), #[cfg(target_os = "linux")]
received: entry.0, pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
transmitted: entry.1, // Fresh view to avoid lingering entries and select "no tasks" (no per-thread rows).
}); let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
let total_count = sys.processes().len();
// Snapshot current per-pid jiffies
let mut current: HashMap<u32, u64> = HashMap::with_capacity(total_count);
for p in sys.processes().values() {
let pid = p.pid().as_u32();
if let Some(j) = read_proc_jiffies(pid) {
current.insert(pid, j);
}
}
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())
}
};
// 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(),
cpu_usage: 0.0,
mem_bytes: p.memory(),
})
.collect();
return ProcessesPayload {
process_count: total_count,
top_processes: procs,
};
} }
// Normalize process CPU to 0..100 across all cores let dt = total_now.saturating_sub(last_total).max(1) as f32;
let n_cpus = sys.cpus().len().max(1) as f32;
// Build process list let procs: Vec<ProcessInfo> = sys
let mut procs: Vec<ProcessInfo> = sys
.processes() .processes()
.values() .values()
.map(|p| ProcessInfo { .map(|p| {
pid: p.pid().as_u32(), let pid = p.pid().as_u32();
name: p.name().to_string_lossy().to_string(), let now = current.get(&pid).copied().unwrap_or(0);
cpu_usage: (p.cpu_usage() / n_cpus).min(100.0), let prev = last_map.remove(&pid).unwrap_or(0);
mem_bytes: p.memory(), 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(); .collect();
// Partial select: get the top 20 by CPU without fully sorting the vector ProcessesPayload {
const TOP_N: usize = 20; process_count: total_count,
if procs.len() > TOP_N {
// nth index is TOP_N-1 (0-based)
let nth = TOP_N - 1;
procs.select_nth_unstable_by(nth, |a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
procs.truncate(TOP_N);
// Order those 20 nicely for display
procs.sort_by(|a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
} else {
procs.sort_by(|a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
Metrics {
cpu_total: sys.global_cpu_usage(),
cpu_per_core: sys.cpus().iter().map(|c| c.cpu_usage()).collect(),
mem_total: sys.total_memory(),
mem_used: sys.used_memory(),
swap_total: sys.total_swap(),
swap_used: sys.used_swap(),
process_count: sys.processes().len(),
hostname,
cpu_temp_c,
disks,
networks,
top_processes: procs, top_processes: procs,
} }
} }
// Pick the hottest CPU-like sensor (labels vary by platform) /// Collect all processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
pub fn best_cpu_temp(components: &Components) -> Option<f32> { #[cfg(not(target_os = "linux"))]
components pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
.iter() use tokio::time::sleep;
.filter(|c| {
let label = c.label().to_lowercase(); let mut sys = state.sys.lock().await;
label.contains("cpu") || label.contains("package") || label.contains("tctl") || label.contains("tdie")
// First refresh to set baseline
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
// Small delay so sysinfo can compute CPU deltas on next refresh
sleep(Duration::from_millis(250)).await;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
let total_count = sys.processes().len();
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| ProcessInfo {
pid: p.pid().as_u32(),
name: p.name().to_string_lossy().into_owned(),
cpu_usage: p.cpu_usage(),
mem_bytes: p.memory(),
}) })
.filter_map(|c| c.temperature()) .collect();
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) ProcessesPayload {
} process_count: total_count,
top_processes: procs,
}
}
// Small helper to select and sort top-k by cpu
// Client now handles sorting/pagination.
+4 -31
View File
@@ -1,32 +1,5 @@
use serde::{Deserialize, Serialize}; // Generated protobuf modules live under OUT_DIR; include them here.
// This module will expose socktop::Processes and socktop::Process types.
#[derive(Debug, Clone, Serialize, Deserialize)] pub mod pb {
pub struct Metrics { include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
pub ts_unix_ms: i64,
pub host: String,
pub uptime_secs: u64,
pub cpu_overall: f32,
pub cpu_per_core: Vec<f32>,
pub load_avg: (f64, f64, f64),
pub mem_total_mb: u64,
pub mem_used_mb: u64,
pub swap_total_mb: u64,
pub swap_used_mb: u64,
pub net_aggregate: NetTotals,
pub top_processes: Vec<Proc>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetTotals {
pub rx_bytes: u64,
pub tx_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proc {
pub pid: i32,
pub name: String,
pub cpu: f32,
pub mem_mb: u64,
pub status: String,
}
+26 -28
View File
@@ -1,36 +1,34 @@
//! Background sampler: periodically collects metrics and updates a JSON cache, //! Background sampler: periodically collects metrics and updates precompressed caches,
//! so WS replies are just a read of the cached string. //! so WS replies just read and send cached bytes.
use crate::metrics::collect_metrics;
use crate::state::AppState; use crate::state::AppState;
//use serde_json::to_string;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::{Duration, interval, MissedTickBehavior}; use tokio::time::{sleep, Duration};
pub fn spawn_sampler(state: AppState, period: Duration) -> JoinHandle<()> { // 500ms: fast path (cpu/mem/net/temp/gpu)
pub fn spawn_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> {
tokio::spawn(async move { tokio::spawn(async move {
let idle_period = Duration::from_secs(10); // no-op background sampler (request-driven collection elsewhere)
loop { loop {
let active = state.client_count.load(std::sync::atomic::Ordering::Relaxed) > 0; sleep(Duration::from_secs(3600)).await;
let mut ticker = interval(if active { period } else { idle_period });
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
ticker.tick().await;
if !active {
tokio::select! {
_ = ticker.tick() => {},
_ = state.wake_sampler.notified() => continue,
}
}
if let Ok(json) = async {
let m = collect_metrics(&state).await;
serde_json::to_string(&m)
}
.await
{
*state.last_json.write().await = json;
}
} }
}) })
} }
// 2s: processes top-k
pub fn spawn_process_sampler(_state: AppState, _period: Duration, _top_k: usize) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(3600)).await;
}
})
}
// 5s: disks
pub fn spawn_disks_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(3600)).await;
}
})
}
+41 -12
View File
@@ -1,30 +1,59 @@
//! Shared agent state: sysinfo handles and hot JSON cache. //! Shared agent state: sysinfo handles and hot JSON cache.
use std::{collections::HashMap, sync::Arc}; #[cfg(target_os = "linux")]
use std::collections::HashMap;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use sysinfo::{Components, Disks, Networks, System}; use sysinfo::{Components, Disks, Networks, System};
use tokio::sync::{Mutex, RwLock, Notify}; use tokio::sync::Mutex;
pub type SharedSystem = Arc<Mutex<System>>; pub type SharedSystem = Arc<Mutex<System>>;
pub type SharedNetworks = Arc<Mutex<Networks>>;
pub type SharedTotals = Arc<Mutex<HashMap<String, (u64, u64)>>>;
pub type SharedComponents = Arc<Mutex<Components>>; pub type SharedComponents = Arc<Mutex<Components>>;
pub type SharedDisks = Arc<Mutex<Disks>>; pub type SharedDisks = Arc<Mutex<Disks>>;
pub type SharedNetworks = Arc<Mutex<Networks>>;
#[cfg(target_os = "linux")]
#[derive(Default)]
pub struct ProcCpuTracker {
pub last_total: u64,
pub last_per_pid: HashMap<u32, u64>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
// Persistent sysinfo handles
pub sys: SharedSystem, pub sys: SharedSystem,
pub nets: SharedNetworks,
pub net_totals: SharedTotals, // iface -> (rx_total, tx_total)
pub components: SharedComponents, pub components: SharedComponents,
pub disks: SharedDisks, pub disks: SharedDisks,
pub networks: SharedNetworks,
// Last serialized JSON snapshot for fast WS responses // For correct per-process CPU% using /proc deltas (Linux only path uses this tracker)
pub last_json: Arc<RwLock<String>>, #[cfg(target_os = "linux")]
pub proc_cpu: Arc<Mutex<ProcCpuTracker>>,
// Adaptive sampling controls // Connection tracking (to allow future idle sleeps if desired)
pub client_count: Arc<AtomicUsize>, pub client_count: Arc<AtomicUsize>,
pub wake_sampler: Arc<Notify>,
pub auth_token: Option<String>, pub auth_token: Option<String>,
} }
impl AppState {
pub fn new() -> Self {
let sys = System::new();
let components = Components::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let networks = Networks::new_with_refreshed_list();
Self {
sys: Arc::new(Mutex::new(sys)),
components: Arc::new(Mutex::new(components)),
disks: Arc::new(Mutex::new(disks)),
networks: Arc::new(Mutex::new(networks)),
#[cfg(target_os = "linux")]
proc_cpu: Arc::new(Mutex::new(ProcCpuTracker::default())),
client_count: Arc::new(AtomicUsize::new(0)),
auth_token: std::env::var("SOCKTOP_TOKEN")
.ok()
.filter(|s| !s.is_empty()),
}
}
}
+96
View File
@@ -0,0 +1,96 @@
use openssl::asn1::Asn1Time;
use openssl::hash::MessageDigest;
use openssl::nid::Nid;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::x509::extension::{
BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName,
};
use openssl::x509::{X509NameBuilder, X509};
use std::{
fs,
io::Write,
net::{IpAddr, Ipv4Addr},
path::{Path, PathBuf},
};
fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("socktop_agent")
.join("tls")
}
pub fn cert_paths() -> (PathBuf, PathBuf) {
let dir = config_dir();
(dir.join("cert.pem"), dir.join("key.pem"))
}
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
let (cert_path, key_path) = cert_paths();
if cert_path.exists() && key_path.exists() {
return Ok((cert_path, key_path));
}
fs::create_dir_all(cert_path.parent().unwrap())?;
// Key
let rsa = Rsa::generate(4096)?;
let pkey = PKey::from_rsa(rsa)?;
// Subject/issuer
let hostname = hostname::get()
.ok()
.and_then(|s| s.into_string().ok())
.unwrap_or_else(|| "localhost".to_string());
let mut name = X509NameBuilder::new()?;
name.append_entry_by_nid(Nid::COMMONNAME, &hostname)?;
let name = name.build();
// Cert builder
let mut builder = X509::builder()?;
builder.set_version(2)?;
builder.set_subject_name(&name)?;
builder.set_issuer_name(&name)?;
builder.set_pubkey(&pkey)?;
builder.set_not_before(Asn1Time::days_from_now(0)?.as_ref())?;
builder.set_not_after(Asn1Time::days_from_now(397)?.as_ref())?;
// SANs: hostname + localhost loopbacks
let mut san = SubjectAlternativeName::new();
san.dns(&hostname)
.dns("localhost")
.ip("127.0.0.1")
.ip("::1");
// Add a generic 0.0.0.0 for convenience; some TLS libs ignore this, but harmless.
let _ = san.ip(&IpAddr::V4(Ipv4Addr::UNSPECIFIED).to_string());
let san = san.build(&builder.x509v3_context(None, None))?;
// End-entity cert: not a CA
builder.append_extension(BasicConstraints::new().critical().build()?)?;
builder.append_extension(
KeyUsage::new()
.digital_signature()
.key_encipherment()
.build()?,
)?;
// TLS server usage
builder.append_extension(ExtendedKeyUsage::new().server_auth().build()?)?;
builder.append_extension(san)?;
builder.sign(&pkey, MessageDigest::sha256())?;
let cert: X509 = builder.build();
let mut f = fs::File::create(&cert_path)?;
f.write_all(&cert.to_pem()?)?;
let mut k = fs::File::create(&key_path)?;
k.write_all(&pkey.private_key_to_pem_pkcs8()?)?;
println!(
"socktop_agent: generated self-signed TLS certificate at {}",
cert_path.display()
);
println!("socktop_agent: private key at {}", key_path.display());
Ok((cert_path, key_path))
}
+25 -19
View File
@@ -1,9 +1,24 @@
//! Data types sent to the client over WebSocket. //! Data types sent to the client over WebSocket.
//! Keep this module minimal and stable — it defines the wire format. //! Keep this module minimal and stable — it defines the wire format.
use crate::gpu::GpuMetrics;
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct DiskInfo {
pub name: String,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct NetworkInfo {
pub name: String,
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProcessInfo { pub struct ProcessInfo {
pub pid: u32, pub pid: u32,
pub name: String, pub name: String,
@@ -11,22 +26,7 @@ pub struct ProcessInfo {
pub mem_bytes: u64, pub mem_bytes: u64,
} }
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct DiskInfo {
pub name: String,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Serialize, Clone)]
pub struct NetworkInfo {
pub name: String,
// cumulative totals since the agent started (client should diff to get rates)
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Serialize, Clone)]
pub struct Metrics { pub struct Metrics {
pub cpu_total: f32, pub cpu_total: f32,
pub cpu_per_core: Vec<f32>, pub cpu_per_core: Vec<f32>,
@@ -34,10 +34,16 @@ pub struct Metrics {
pub mem_used: u64, pub mem_used: u64,
pub swap_total: u64, pub swap_total: u64,
pub swap_used: u64, pub swap_used: u64,
pub process_count: usize,
pub hostname: String, pub hostname: String,
pub cpu_temp_c: Option<f32>, pub cpu_temp_c: Option<f32>,
pub disks: Vec<DiskInfo>, pub disks: Vec<DiskInfo>,
pub networks: Vec<NetworkInfo>, pub networks: Vec<NetworkInfo>,
pub top_processes: Vec<ProcessInfo>, pub top_processes: Vec<ProcessInfo>,
} pub gpus: Option<Vec<GpuMetrics>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProcessesPayload {
pub process_count: usize,
pub top_processes: Vec<ProcessInfo>,
}
+74 -39
View File
@@ -1,61 +1,81 @@
//! WebSocket upgrade and per-connection handler. Serves cached JSON quickly. //! WebSocket upgrade and per-connection handler (request-driven).
use axum::{ use axum::{
extract::{ extract::ws::{Message, WebSocket},
ws::{Message, WebSocket, WebSocketUpgrade}, extract::{Query, State, WebSocketUpgrade},
Query, State, response::Response,
},
http::StatusCode,
response::{IntoResponse, Response},
}; };
use futures_util::stream::StreamExt; use flate2::{write::GzEncoder, Compression};
use futures_util::StreamExt;
use crate::metrics::collect_metrics;
use crate::state::AppState;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::Ordering; use std::io::Write;
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all};
use crate::proto::pb;
use crate::state::AppState;
pub async fn ws_handler( pub async fn ws_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>, Query(q): Query<HashMap<String, String>>,
) -> Response { ) -> Response {
// optional auth
if let Some(expected) = state.auth_token.as_ref() { if let Some(expected) = state.auth_token.as_ref() {
match q.get("token") { if q.get("token") != Some(expected) {
Some(t) if t == expected => {} return ws.on_upgrade(|socket| async move {
_ => return StatusCode::UNAUTHORIZED.into_response(), let _ = socket.close().await;
});
} }
} }
ws.on_upgrade(move |socket| handle_socket(socket, state)) ws.on_upgrade(move |socket| handle_socket(socket, state))
} }
async fn handle_socket(mut socket: WebSocket, state: AppState) { async fn handle_socket(mut socket: WebSocket, state: AppState) {
// Bump client count on connect and wake the sampler. state
state.client_count.fetch_add(1, Ordering::Relaxed); .client_count
state.wake_sampler.notify_waiters(); .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// Ensure we decrement on disconnect (drop).
struct ClientGuard(AppState);
impl Drop for ClientGuard {
fn drop(&mut self) {
self.0.client_count.fetch_sub(1, Ordering::Relaxed);
self.0.wake_sampler.notify_waiters();
}
}
let _guard = ClientGuard(state.clone());
while let Some(Ok(msg)) = socket.next().await { while let Some(Ok(msg)) = socket.next().await {
match msg { match msg {
Message::Text(text) if text == "get_metrics" => { Message::Text(ref text) if text == "get_metrics" => {
// Serve the cached JSON quickly; if empty (cold start), collect once. let m = collect_fast_metrics(&state).await;
let cached = state.last_json.read().await.clone(); let _ = send_json(&mut socket, &m).await;
if !cached.is_empty() { }
let _ = socket.send(Message::Text(cached)).await; Message::Text(ref text) if text == "get_disks" => {
let d = collect_disks(&state).await;
let _ = send_json(&mut socket, &d).await;
}
Message::Text(ref text) if text == "get_processes" => {
let payload = collect_processes_all(&state).await;
// Map to protobuf message
let rows: Vec<pb::Process> = payload
.top_processes
.into_iter()
.map(|p| pb::Process {
pid: p.pid,
name: p.name,
cpu_usage: p.cpu_usage,
mem_bytes: p.mem_bytes,
})
.collect();
let pb = pb::Processes {
process_count: payload.process_count as u64,
rows,
};
let mut buf = Vec::with_capacity(8 * 1024);
if prost::Message::encode(&pb, &mut buf).is_err() {
let _ = socket.send(Message::Close(None)).await;
} else { } else {
let metrics = collect_metrics(&state).await; // compress if large
if let Ok(js) = serde_json::to_string(&metrics) { if buf.len() <= 768 {
let _ = socket.send(Message::Text(js)).await; let _ = socket.send(Message::Binary(buf)).await;
} else {
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
if enc.write_all(&buf).is_ok() {
let bin = enc.finish().unwrap_or(buf);
let _ = socket.send(Message::Binary(bin)).await;
} else {
let _ = socket.send(Message::Binary(buf)).await;
}
} }
} }
} }
@@ -63,4 +83,19 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
_ => {} _ => {}
} }
} }
} state
.client_count
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
// Small, cheap gzip for larger payloads; send text for small.
async fn send_json<T: serde::Serialize>(ws: &mut WebSocket, value: &T) -> Result<(), axum::Error> {
let json = serde_json::to_string(value).expect("serialize");
if json.len() <= 768 {
return ws.send(Message::Text(json)).await;
}
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
enc.write_all(json.as_bytes()).ok();
let bin = enc.finish().unwrap_or_else(|_| json.into_bytes());
ws.send(Message::Binary(bin)).await
}
+28
View File
@@ -0,0 +1,28 @@
//! CLI arg parsing tests for socktop_agent (server)
use std::process::Command;
#[test]
fn test_help_and_port_short_long() {
// We verify port flags are accepted by ensuring the process starts (then we kill quickly).
// Use an unlikely port to avoid conflicts.
let exe = env!("CARGO_BIN_EXE_socktop_agent");
// TLS enabled with long --port
let mut child = Command::new(exe)
.args(["--enableSSL", "--port", "9555"])
.spawn()
.expect("spawn agent");
// Give it a moment to bind
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = child.kill();
let _ = child.wait();
// TLS enabled with short -p
let mut child2 = Command::new(exe)
.args(["--enableSSL", "-p", "9556"])
.spawn()
.expect("spawn agent");
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = child2.kill();
let _ = child2.wait();
}
+59
View File
@@ -0,0 +1,59 @@
use assert_cmd::prelude::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
fn expected_paths(config_home: &std::path::Path) -> (PathBuf, PathBuf) {
let base = config_home.join("socktop_agent").join("tls");
(base.join("cert.pem"), base.join("key.pem"))
}
#[test]
fn generates_self_signed_cert_and_key_in_xdg_path() {
// Create an isolated fake XDG_CONFIG_HOME
let tmpdir = tempfile::tempdir().expect("tempdir");
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");
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
cmd.env("XDG_CONFIG_HOME", &xdg)
.arg("--enableSSL")
.arg("-p")
.arg("0");
// Spawn the process and poll for cert generation
let mut child = cmd.spawn().expect("spawn agent");
// Poll up to ~3s for files to appear to avoid timing flakes
let (cert_path, key_path) = expected_paths(&xdg);
let start = Instant::now();
let timeout = Duration::from_millis(3000);
let interval = Duration::from_millis(50);
while start.elapsed() < timeout {
if cert_path.exists() && key_path.exists() {
break;
}
std::thread::sleep(interval);
}
// Terminate the process regardless
let _ = child.kill();
let _ = child.wait();
// Verify files exist at expected paths
assert!(
cert_path.exists(),
"cert not found at {}",
cert_path.display()
);
assert!(key_path.exists(), "key not found at {}", key_path.display());
// Also ensure they are non-empty
let cert_md = fs::metadata(&cert_path).expect("cert metadata");
let key_md = fs::metadata(&key_path).expect("key metadata");
assert!(cert_md.len() > 0, "cert is empty");
assert!(key_md.len() > 0, "key is empty");
}