Compare commits

..

56 Commits

Author SHA1 Message Date
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 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
43 changed files with 2773 additions and 616 deletions
+98 -3
View File
@@ -2,6 +2,7 @@ name: CI
on: on:
push: push:
pull_request: pull_request:
jobs: jobs:
build: build:
strategy: strategy:
@@ -11,9 +12,103 @@ jobs:
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
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: Start agent (Ubuntu)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
# Use debug build for faster startup in CI
RUST_LOG=info cargo run -p socktop_agent -- -p 3000 &
AGENT_PID=$!
echo "AGENT_PID=$AGENT_PID" >> $GITHUB_ENV
# Wait for port 3000 to accept connections (30s max)
for i in {1..60}; do
if bash -lc "</dev/tcp/127.0.0.1/3000" &>/dev/null; then
echo "agent is ready"
break
fi
sleep 0.5
done
- name: Run WS probe test (Ubuntu)
if: matrix.os == 'ubuntu-latest'
shell: bash
env:
SOCKTOP_WS: ws://127.0.0.1:3000/ws
run: |
set -euo pipefail
cargo test -p socktop --test ws_probe -- --nocapture
- name: Stop agent (Ubuntu)
if: always() && matrix.os == 'ubuntu-latest'
shell: bash
run: |
if [ -n "${AGENT_PID:-}" ]; then kill $AGENT_PID || true; fi
- name: Start agent (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$p = Start-Process -FilePath "cargo" -ArgumentList "run -p socktop_agent -- -p 3000" -PassThru
echo "AGENT_PID=$($p.Id)" | Out-File -FilePath $env:GITHUB_ENV -Append
$ready = $false
for ($i = 0; $i -lt 60; $i++) {
if (Test-NetConnection -ComputerName 127.0.0.1 -Port 3000 -InformationLevel Quiet) { $ready = $true; break }
Start-Sleep -Milliseconds 500
}
if (-not $ready) { Write-Error "agent did not become ready" }
- name: Run WS probe test (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
cargo test -p socktop --test ws_probe -- --nocapture
- name: Stop agent (Windows)
if: always() && matrix.os == 'windows-latest'
shell: pwsh
run: |
if ($env:AGENT_PID) { Stop-Process -Id $env:AGENT_PID -Force -ErrorAction SilentlyContinue }
- name: Smoke test (client --help)
run: cargo run -p socktop -- --help
- name: Package artifacts
shell: bash
run: |
set -e
mkdir dist
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp target/release/socktop.exe dist/
cp target/release/socktop_agent.exe dist/
7z a socktop-${{ matrix.os }}.zip dist/*
else
cp target/release/socktop dist/
cp target/release/socktop_agent dist/
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
fi
- 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
+342 -27
View File
@@ -253,6 +253,16 @@ dependencies = [
"static_assertions", "static_assertions",
] ]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -268,6 +278,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.27.0" version = "0.27.0"
@@ -403,6 +422,16 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "flate2"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -531,7 +560,34 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "gfxinfo"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14f33b12fba19d158bebe0262fd2b6c49fee6f0e0e089f4f3fdc11c017548473"
dependencies = [
"core-foundation",
"io-kit-sys",
"libdrm_amdgpu_sys",
"nvml-wrapper",
"serde",
"windows 0.61.3",
"wmi",
] ]
[[package]] [[package]]
@@ -650,7 +706,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core", "windows-core 0.61.2",
] ]
[[package]] [[package]]
@@ -794,6 +850,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]] [[package]]
name = "io-uring" name = "io-uring"
version = "0.7.9" version = "0.7.9"
@@ -842,6 +908,25 @@ version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libdrm_amdgpu_sys"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7bf95f3c5d8a8ae4a5c0c46fe2271c16fdf8fd4b26978f44b082a0f8ebaaae"
dependencies = [
"libc",
]
[[package]]
name = "libloading"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
dependencies = [
"cfg-if",
"windows-targets 0.53.3",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -879,6 +964,15 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@@ -923,7 +1017,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
@@ -935,7 +1029,7 @@ checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -967,6 +1061,29 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "nvml-wrapper"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5"
dependencies = [
"bitflags",
"libloading",
"nvml-wrapper-sys",
"static_assertions",
"thiserror 1.0.69",
"wrapcenum-derive",
]
[[package]]
name = "nvml-wrapper-sys"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "698d45156f28781a4e79652b6ebe2eaa0589057d588d3aec1333f6466f13fcb5"
dependencies = [
"libloading",
]
[[package]] [[package]]
name = "objc2-core-foundation" name = "objc2-core-foundation"
version = "0.3.1" version = "0.3.1"
@@ -1090,6 +1207,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@@ -1097,8 +1220,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
] ]
[[package]] [[package]]
@@ -1108,7 +1241,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
] ]
[[package]] [[package]]
@@ -1117,7 +1260,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
] ]
[[package]] [[package]]
@@ -1366,11 +1518,12 @@ dependencies = [
[[package]] [[package]]
name = "socktop" name = "socktop"
version = "0.1.0" version = "0.1.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"crossterm 0.27.0", "crossterm 0.27.0",
"flate2",
"futures", "futures",
"futures-util", "futures-util",
"ratatui", "ratatui",
@@ -1378,23 +1531,28 @@ dependencies = [
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tungstenite", "tungstenite 0.27.0",
"url", "url",
] ]
[[package]] [[package]]
name = "socktop_agent" name = "socktop_agent"
version = "0.1.0" version = "0.1.11"
dependencies = [ dependencies = [
"axum", "axum",
"flate2",
"futures", "futures",
"futures-util", "futures-util",
"gfxinfo",
"nvml-wrapper",
"once_cell",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tungstenite 0.27.0",
] ]
[[package]] [[package]]
@@ -1467,16 +1625,16 @@ dependencies = [
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.36.1" version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753"
dependencies = [ dependencies = [
"libc", "libc",
"memchr", "memchr",
"ntapi", "ntapi",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-kit", "objc2-io-kit",
"windows", "windows 0.61.3",
] ]
[[package]] [[package]]
@@ -1485,7 +1643,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl 2.0.12",
] ]
[[package]] [[package]]
@@ -1499,6 +1666,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@@ -1558,7 +1736,7 @@ dependencies = [
"futures-util", "futures-util",
"log", "log",
"tokio", "tokio",
"tungstenite", "tungstenite 0.24.0",
] ]
[[package]] [[package]]
@@ -1663,9 +1841,26 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"rand", "rand 0.8.5",
"sha1", "sha1",
"thiserror", "thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.12",
"utf-8", "utf-8",
] ]
@@ -1745,6 +1940,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
"wit-bindgen-rt",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.100" version = "0.2.100"
@@ -1825,17 +2029,39 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529"
dependencies = [
"windows-collections 0.1.1",
"windows-core 0.60.1",
"windows-future 0.1.1",
"windows-link",
"windows-numerics 0.1.1",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [ dependencies = [
"windows-collections", "windows-collections 0.2.0",
"windows-core", "windows-core 0.61.2",
"windows-future", "windows-future 0.2.1",
"windows-link", "windows-link",
"windows-numerics", "windows-numerics 0.2.0",
]
[[package]]
name = "windows-collections"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec"
dependencies = [
"windows-core 0.60.1",
] ]
[[package]] [[package]]
@@ -1844,7 +2070,20 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247"
dependencies = [
"windows-implement 0.59.0",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.3.1",
] ]
[[package]] [[package]]
@@ -1853,11 +2092,21 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.0",
"windows-interface", "windows-interface",
"windows-link", "windows-link",
"windows-result", "windows-result",
"windows-strings", "windows-strings 0.4.2",
]
[[package]]
name = "windows-future"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0"
dependencies = [
"windows-core 0.60.1",
"windows-link",
] ]
[[package]] [[package]]
@@ -1866,11 +2115,22 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.61.2",
"windows-link", "windows-link",
"windows-threading", "windows-threading",
] ]
[[package]]
name = "windows-implement"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.0" version = "0.60.0"
@@ -1899,13 +2159,23 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-numerics"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed"
dependencies = [
"windows-core 0.60.1",
"windows-link",
]
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.61.2",
"windows-link", "windows-link",
] ]
@@ -1918,6 +2188,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.2"
@@ -2149,6 +2428,42 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags",
]
[[package]]
name = "wmi"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f902b4592b911109e7352bcfec7b754b07ec71e514d7dfa280eaef924c1cb08"
dependencies = [
"chrono",
"futures",
"log",
"serde",
"thiserror 2.0.12",
"windows 0.60.0",
"windows-core 0.60.1",
]
[[package]]
name = "wrapcenum-derive"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.1" version = "0.6.1"
+1
View File
@@ -1,4 +1,5 @@
[workspace] [workspace]
resolver = "2"
members = [ members = [
"socktop", "socktop",
"socktop_agent" "socktop_agent"
+245 -144
View File
@@ -1,201 +1,217 @@
# 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) - TUI built with ratatui
- 🔍 **Detailed CPU view** — per-core history, current load, and trends - CPU
- 📊 **Memory, Swap, Disk usage** — human-readable units, color-coded - Overall sparkline + per-core mini bars
- 🌡 **Temperatures** — CPU temperature with visual indicators - Accurate per-process CPU% (Linux /proc deltas), normalized to 0100%
- 📈 **Network throughput** — live sparkline graphs with peak tracking - Memory/Swap gauges with human units
- 🏷 **Top processes table** — PID, name, CPU%, memory, and memory% - Disks: per-device usage
- 🎨 Color-coded load, zebra striping for readability - Network: per-interface throughput with sparklines and peak markers
- **Keyboard shortcuts**: - Temperatures: CPU (optional)
- `q` / `Esc` → Quit - 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 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
``` ```
This will install the `socktop` binary into `~/.cargo/bin`.
- 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).
--- ---
## 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) ```
- **Network Download/Upload** — sparkline in KB/s, with current & peak values
**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"
```
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
- 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 +223,127 @@ 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"
```
---
## 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
cargo run -p socktop_agent -- --port 3000
``` ```
--- ---
## 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 - [ ] TLS / WSS support
- [ ] 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

+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["clippy", "rustfmt"]
+4 -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,5 @@ 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"] }
tungstenite = "0.27.0"
+314 -35
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,33 @@ 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) -> Result<(), Box<dyn std::error::Error>> {
// Connect to agent // Connect to agent
self.ws_url = url.to_string();
let mut ws = connect(url).await?; let mut ws = connect(url).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 +117,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,23 +131,157 @@ 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 {
break; break;
} }
// Fetch and update // Draw current frame first so the UI never feels blocked
terminal.draw(|f| self.draw(f))?;
// Then 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);
}
// Draw // Only poll processes every 2s
terminal.draw(|f| self.draw(f))?; 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();
}
} else {
// If we couldn't get metrics, try to reconnect once
if let Ok(new_ws) = connect(&self.ws_url).await {
*ws = new_ws;
}
}
// Tick rate // Tick rate
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
@@ -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;
+15 -8
View File
@@ -6,18 +6,25 @@ mod types;
mod ui; mod ui;
mod ws; mod ws;
use std::env;
use app::App; use app::App;
use std::env;
#[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(); let mut args = env::args();
if args.len() < 2 { let prog = args.next().unwrap_or_else(|| "socktop".into());
eprintln!("Usage: {} ws://HOST:PORT/ws", args[0]); let url = match args.next() {
std::process::exit(1); Some(flag) if flag == "-h" || flag == "--help" => {
} println!("Usage: {prog} ws://HOST:PORT/ws");
let url = args[1].clone(); return Ok(());
}
Some(url) => url,
None => {
eprintln!("Usage: {prog} ws://HOST:PORT/ws");
std::process::exit(1);
}
};
let mut app = App::new(); let mut app = App::new();
app.run(&url).await app.run(&url).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 {
"🖴"
}
}
+233 -9
View File
@@ -1,28 +1,252 @@
//! Minimal WebSocket client helpers for requesting metrics from the agent. //! Minimal WebSocket client helpers for requesting metrics from the agent.
use flate2::read::GzDecoder;
use futures_util::{SinkExt, StreamExt};
use std::io::{Cursor, Read};
use std::sync::OnceLock;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use crate::types::Metrics; use crate::types::{DiskInfo, Metrics, ProcessesPayload};
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>; pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[inline]
fn debug_on() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
std::env::var("SOCKTOP_DEBUG")
.map(|v| v != "0")
.unwrap_or(false)
})
}
fn log_msg(msg: &Message) {
match msg {
Message::Binary(b) => eprintln!("ws: Binary {} bytes", b.len()),
Message::Text(s) => eprintln!("ws: Text {} bytes", s.len()),
Message::Close(_) => eprintln!("ws: Close"),
_ => eprintln!("ws: Other frame"),
}
}
// 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(url: &str) -> Result<WsStream, Box<dyn std::error::Error>> {
if debug_on() {
eprintln!("ws: connecting to {url}");
}
let (ws, _) = connect_async(url).await?; let (ws, _) = connect_async(url).await?;
if debug_on() {
eprintln!("ws: connected");
}
Ok(ws) Ok(ws)
} }
// Send a "get_metrics" request and await a single JSON reply // Decompress a gzip-compressed binary frame into a String.
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> { fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
if ws.send(Message::Text("get_metrics".into())).await.is_err() { let cursor = Cursor::new(bytes);
return None; let mut dec = GzDecoder::new(cursor);
let mut out = String::new();
dec.read_to_string(&mut out).ok()?;
if debug_on() {
eprintln!("ws: gunzip decoded {} bytes", out.len());
} }
match ws.next().await { Some(out)
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(), }
fn message_to_json(msg: &Message) -> Option<String> {
match msg {
Message::Binary(b) => {
if debug_on() {
eprintln!("ws: <- Binary frame {} bytes", b.len());
}
if let Some(s) = gunzip_to_string(b) {
return Some(s);
}
// Fallback: try interpreting as UTF-8 JSON in a binary frame
String::from_utf8(b.clone()).ok()
}
Message::Text(s) => {
if debug_on() {
eprintln!("ws: <- Text frame {} bytes", s.len());
}
Some(s.clone())
}
_ => None, _ => None,
} }
} }
// Re-export SinkExt/StreamExt for call sites // Suppress dead_code until these are wired into the app
use futures_util::{SinkExt, StreamExt}; #[allow(dead_code)]
pub enum Payload {
Metrics(Metrics),
Disks(Vec<DiskInfo>),
Processes(ProcessesPayload),
}
fn parse_any_payload(json: &str) -> Result<Payload, serde_json::Error> {
if let Ok(m) = serde_json::from_str::<Metrics>(json) {
return Ok(Payload::Metrics(m));
}
if let Ok(d) = serde_json::from_str::<Vec<DiskInfo>>(json) {
return Ok(Payload::Disks(d));
}
if let Ok(p) = serde_json::from_str::<ProcessesPayload>(json) {
return Ok(Payload::Processes(p));
}
Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unknown payload",
)))
}
// Send a "get_metrics" request and await a single JSON reply
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
if debug_on() {
eprintln!("ws: -> get_metrics");
}
if ws.send(Message::Text("get_metrics".into())).await.is_err() {
return None;
}
// Drain a few messages until we find Metrics (handle out-of-order replies)
for _ in 0..8 {
match timeout(Duration::from_millis(800), ws.next()).await {
Ok(Some(Ok(msg))) => {
if debug_on() {
log_msg(&msg);
}
if let Some(json) = message_to_json(&msg) {
match parse_any_payload(&json) {
Ok(Payload::Metrics(m)) => return Some(m),
Ok(Payload::Disks(_)) => {
if debug_on() {
eprintln!("ws: got Disks while waiting for Metrics");
}
}
Ok(Payload::Processes(_)) => {
if debug_on() {
eprintln!("ws: got Processes while waiting for Metrics");
}
}
Err(_e) => {
if debug_on() {
eprintln!(
"ws: unknown payload while waiting for Metrics (len={})",
json.len()
);
}
}
}
} else if debug_on() {
eprintln!("ws: non-json frame while waiting for Metrics");
}
}
Ok(Some(Err(_e))) => continue,
Ok(None) => return None,
Err(_elapsed) => continue,
}
}
None
}
// Send a "get_disks" request and await a JSON Vec<DiskInfo>
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
if debug_on() {
eprintln!("ws: -> get_disks");
}
if ws.send(Message::Text("get_disks".into())).await.is_err() {
return None;
}
for _ in 0..8 {
match timeout(Duration::from_millis(800), ws.next()).await {
Ok(Some(Ok(msg))) => {
if debug_on() {
log_msg(&msg);
}
if let Some(json) = message_to_json(&msg) {
match parse_any_payload(&json) {
Ok(Payload::Disks(d)) => return Some(d),
Ok(Payload::Metrics(_)) => {
if debug_on() {
eprintln!("ws: got Metrics while waiting for Disks");
}
}
Ok(Payload::Processes(_)) => {
if debug_on() {
eprintln!("ws: got Processes while waiting for Disks");
}
}
Err(_e) => {
if debug_on() {
eprintln!(
"ws: unknown payload while waiting for Disks (len={})",
json.len()
);
}
}
}
} else if debug_on() {
eprintln!("ws: non-json frame while waiting for Disks");
}
}
Ok(Some(Err(_e))) => continue,
Ok(None) => return None,
Err(_elapsed) => continue,
}
}
None
}
// Send a "get_processes" request and await a JSON ProcessesPayload
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
if debug_on() {
eprintln!("ws: -> get_processes");
}
if ws
.send(Message::Text("get_processes".into()))
.await
.is_err()
{
return None;
}
for _ in 0..16 {
// allow a few more cycles due to gzip size
match timeout(Duration::from_millis(1200), ws.next()).await {
Ok(Some(Ok(msg))) => {
if debug_on() {
log_msg(&msg);
}
if let Some(json) = message_to_json(&msg) {
match parse_any_payload(&json) {
Ok(Payload::Processes(p)) => return Some(p),
Ok(Payload::Metrics(_)) => {
if debug_on() {
eprintln!("ws: got Metrics while waiting for Processes");
}
}
Ok(Payload::Disks(_)) => {
if debug_on() {
eprintln!("ws: got Disks while waiting for Processes");
}
}
Err(_e) => {
if debug_on() {
eprintln!(
"ws: unknown payload while waiting for Processes (len={})",
json.len()
);
}
}
}
} else if debug_on() {
eprintln!("ws: non-json frame while waiting for Processes");
}
}
Ok(Some(Err(_e))) => continue,
Ok(None) => return None,
Err(_elapsed) => continue,
}
}
None
}
+27
View File
@@ -0,0 +1,27 @@
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;
}
};
let mut ws = connect(&url).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");
}
+9 -3
View File
@@ -1,17 +1,23 @@
[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"
+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])
}
+21 -58
View File
@@ -1,80 +1,45 @@
//! 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 sampler; mod sampler;
mod state; mod state;
mod ws;
mod types; mod types;
mod ws;
use axum::{routing::get, Router}; use axum::{routing::get, Router};
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration, sync::atomic::AtomicUsize}; use std::net::SocketAddr;
use sysinfo::{
Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind, RefreshKind,
System,
};
use tokio::sync::{Mutex, RwLock, Notify};
use tracing_subscriber::EnvFilter;
use state::{AppState, SharedTotals}; use crate::sampler::{spawn_disks_sampler, spawn_process_sampler, spawn_sampler};
use sampler::spawn_sampler; use state::AppState;
use ws::ws_handler; use ws::ws_handler;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
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
let port = resolve_port(); let port = resolve_port();
let app = Router::new().route("/ws", get(ws_handler)).with_state(state); let app = Router::new()
.route("/ws", get(ws_handler))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
//output to console //output to console
println!("Remote agent running at http://{}", addr); println!("Remote agent running at http://{addr}");
println!("WebSocket endpoint: ws://{}/ws", addr); println!("WebSocket endpoint: ws://{addr}/ws");
//trace logging //trace logging
tracing::info!("Remote agent running at http://{} (ws at /ws)", addr); tracing::info!("Remote agent running at http://{} (ws at /ws)", addr);
@@ -82,7 +47,6 @@ async fn main() {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
// Resolve the listening port from CLI args/env with a 3000 default. // Resolve the listening port from CLI args/env with a 3000 default.
@@ -97,7 +61,7 @@ fn resolve_port() -> u16 {
return p; return p;
} }
} }
eprintln!("Warning: invalid SOCKTOP_PORT='{}'; using default {}", s, DEFAULT); eprintln!("Warning: invalid SOCKTOP_PORT='{s}'; using default {DEFAULT}");
} }
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
@@ -108,12 +72,12 @@ fn resolve_port() -> u16 {
match v.parse::<u16>() { match v.parse::<u16>() {
Ok(p) if p != 0 => return p, Ok(p) if p != 0 => return p,
_ => { _ => {
eprintln!("Invalid port '{}'; using default {}", v, DEFAULT); eprintln!("Invalid port '{v}'; using default {DEFAULT}");
return DEFAULT; return DEFAULT;
} }
} }
} else { } else {
eprintln!("Missing value for {} ; using default {}", arg, DEFAULT); eprintln!("Missing value for {arg} ; using default {DEFAULT}");
return DEFAULT; return DEFAULT;
} }
} }
@@ -133,4 +97,3 @@ fn resolve_port() -> u16 {
DEFAULT DEFAULT
} }
+350 -97
View File
@@ -1,128 +1,381 @@
//! 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 top processes (Linux variant): compute CPU% via /proc jiffies delta.
name: name.clone(), #[cfg(target_os = "linux")]
received: entry.0, pub async fn collect_processes_top_k(state: &AppState, k: usize) -> 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: top_k_sorted(procs, k),
};
} }
// 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;
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| {
let pid = p.pid().as_u32();
let now = current.get(&pid).copied().unwrap_or(0);
let prev = last_map.remove(&pid).unwrap_or(0);
let du = now.saturating_sub(prev) as f32;
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
ProcessInfo {
pid,
name: p.name().to_string_lossy().into_owned(),
cpu_usage: cpu,
mem_bytes: p.memory(),
}
})
.collect();
ProcessesPayload {
process_count: total_count,
top_processes: top_k_sorted(procs, k),
}
}
/// Collect top processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
#[cfg(not(target_os = "linux"))]
pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPayload {
use tokio::time::sleep;
let mut sys = state.sys.lock().await;
// 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();
// Build process list
let mut procs: Vec<ProcessInfo> = sys let mut procs: Vec<ProcessInfo> = sys
.processes() .processes()
.values() .values()
.map(|p| ProcessInfo { .map(|p| ProcessInfo {
pid: p.pid().as_u32(), pid: p.pid().as_u32(),
name: p.name().to_string_lossy().to_string(), name: p.name().to_string_lossy().into_owned(),
cpu_usage: (p.cpu_usage() / n_cpus).min(100.0), cpu_usage: p.cpu_usage(),
mem_bytes: p.memory(), mem_bytes: p.memory(),
}) })
.collect(); .collect();
// Partial select: get the top 20 by CPU without fully sorting the vector procs = top_k_sorted(procs, k);
const TOP_N: usize = 20; ProcessesPayload {
if procs.len() > TOP_N { process_count: total_count,
// 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) // Small helper to select and sort top-k by cpu
pub fn best_cpu_temp(components: &Components) -> Option<f32> { fn top_k_sorted(mut v: Vec<ProcessInfo>, k: usize) -> Vec<ProcessInfo> {
components if v.len() > k {
.iter() v.select_nth_unstable_by(k, |a, b| {
.filter(|c| { b.cpu_usage
let label = c.label().to_lowercase(); .partial_cmp(&a.cpu_usage)
label.contains("cpu") || label.contains("package") || label.contains("tctl") || label.contains("tdie") .unwrap_or(std::cmp::Ordering::Equal)
}) });
.filter_map(|c| c.temperature()) v.truncate(k);
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) }
} v.sort_by(|a, b| {
b.cpu_usage
.partial_cmp(&a.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal)
});
v
}
+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()),
}
}
}
+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>,
}
+45 -42
View File
@@ -1,66 +1,69 @@
//! 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_top_k};
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" => {
} else { let d = collect_disks(&state).await;
let metrics = collect_metrics(&state).await; let _ = send_json(&mut socket, &d).await;
if let Ok(js) = serde_json::to_string(&metrics) { }
let _ = socket.send(Message::Text(js)).await; Message::Text(ref text) if text == "get_processes" => {
} let p = collect_processes_top_k(&state, 50).await;
} let _ = send_json(&mut socket, &p).await;
} }
Message::Close(_) => break, Message::Close(_) => break,
_ => {} _ => {}
} }
} }
} 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
}