Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4ed036357 | |||
| ec0e409488 | |||
| 08f248c696 | |||
| cea133b7da | |||
| e679896ca0 | |||
| 5e5fde190a | |||
| 8286d21a2a | |||
| b91fc7b016 | |||
| f936767835 | |||
| 5f2777cdb2 | |||
| 49164da105 | |||
| 22c1f80e70 | |||
| a486225008 | |||
| d97f7507e8 | |||
| e4186a7ec0 | |||
| f59c28d966 | |||
| 06cd6d0c82 | |||
| ffc246b705 | |||
| cd2816915d | |||
| 7cd5941434 | |||
| 76c7fe1d6f | |||
| eed04f1d5c | |||
| 764c25846f | |||
| a9bf4208ab | |||
| 9c1416eabf | |||
| e7350f8908 | |||
| 2647b611d2 | |||
| a359f17367 | |||
| d93b7aca5a | |||
| e51054811c | |||
| 622767a605 | |||
| 0c5a1d7553 | |||
| 0bd709d2a7 | |||
| 31f5f9ce76 | |||
| df2308e6e9 | |||
| 7592709a43 | |||
| 61fe1cc38e | |||
| eed346abb6 | |||
| ab3bb33711 | |||
| 7caf2f4bfb | |||
| b249c7ba99 | |||
| f0858525e8 | |||
| 2fe005ed90 | |||
| ca6a5cbdfa | |||
| 56301d61fd | |||
| 55e5c708fe | |||
| 2d17cf1598 | |||
| 353c08c35e | |||
| f13ea45360 | |||
| 8ce00a5dad | |||
| f37b8d9ff4 | |||
| 322981ada7 | |||
| 3394beab67 | |||
| c9ebea92f5 | |||
| e2dc5e8ac9 | |||
| beddba0072 | |||
| cacc4cba9f | |||
| 66270c16b7 | |||
| 00d5777d05 | |||
| f62b5274d2 | |||
| bbbe35111a | |||
| a4356b5ece | |||
| b6e656738b | |||
| f83cb07d57 | |||
| 7697c7dc2b | |||
| 1043fffc8d | |||
| ce59dd9dfe | |||
| 8d48fa4c3b | |||
| 51e702368e | |||
| 85f9a44e46 | |||
| b2468a5936 | |||
| 8de5943f34 | |||
| e624751f56 | |||
| 8bd1af7a27 | |||
| 5c32d15156 | |||
| 471d547b5d | |||
| d3aff590bc | |||
| 47910725a8 | |||
| a8e3f4ef26 | |||
| fab1e5a104 | |||
| d0455611d5 | |||
| 4c45b85c98 | |||
| d9fdc31e8f | |||
| dc9aa4c026 | |||
| c2e91bd20c | |||
| 25229d6b03 | |||
| 290e2a8fb2 | |||
| 30d263c71e | |||
| 9b177f3206 | |||
| 8a6ae3fcd7 | |||
| 5b8ec7efc1 | |||
| 155c420a1a | |||
| d3fa55e572 | |||
| faf2861b29 | |||
| 59432ab1d3 | |||
| d1c8a64418 | |||
| 8def4b2d06 | |||
| a42ca71a9f | |||
| 9f675fa804 | |||
| 3ac03c07ba | |||
| e53d0ab98d | |||
| 2ca51adc61 | |||
| 67ecf36883 | |||
| 9a35306340 | |||
| a4bb6f170a | |||
| 384953d5d5 | |||
| f9114426cc | |||
| 8ee2a03a2c | |||
| 0275b1871d | |||
| 9491dc50a8 | |||
| e7eb3e6557 | |||
| a596acfb72 | |||
| b727e54589 | |||
| 2af08c455a | |||
| d049846564 | |||
| 97308f9d15 | |||
| 4cef273e57 | |||
| 660474a6ce | |||
| 93dd14967d | |||
| 923a3872fe | |||
| 5f10e34341 | |||
| b80d322650 | |||
| fff386f9d5 | |||
| 93f4e1feea | |||
| 97255b42fb | |||
| 554a2c349f | |||
| 10501168c5 | |||
| d346c61c28 | |||
| 7652095109 | |||
| 6b58ac67f6 | |||
| 3ad1d52fe2 | |||
| 2e8cc24e81 | |||
| 36e73fd9ed | |||
| 3d14e4a370 | |||
| c6b8c9c905 | |||
| f980b6ace9 |
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# This repository uses a custom hooks directory (.githooks). To enable this pre-commit hook run:
|
||||
# git config core.hooksPath .githooks
|
||||
# Ensure this file is executable: chmod +x .githooks/pre-commit
|
||||
set -euo pipefail
|
||||
|
||||
echo "[pre-commit] Running cargo fmt --all" >&2
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
# Try loading rustup environment (common install path)
|
||||
if [ -f "$HOME/.cargo/env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$HOME/.cargo/env"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[pre-commit] cargo not found in PATH; skipping fmt (install Rust or adjust PATH)." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cargo fmt --all
|
||||
|
||||
# Stage any Rust files that were reformatted
|
||||
changed=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
|
||||
if [ -n "$changed" ]; then
|
||||
echo "$changed" | xargs git add
|
||||
echo "[pre-commit] Added formatted files" >&2
|
||||
fi
|
||||
|
||||
# Fail if further diffs remain (shouldn't happen normally)
|
||||
unfmt=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
|
||||
if [ -n "$unfmt" ]; then
|
||||
echo "[pre-commit] Some Rust files still differ after formatting:" >&2
|
||||
echo "$unfmt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+81
-13
@@ -5,16 +5,16 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- name: Install system dependencies
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdrm-dev libdrm-amdgpu1
|
||||
- name: Cargo fmt
|
||||
@@ -23,22 +23,89 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
- name: Build (release)
|
||||
run: cargo build --release --workspace
|
||||
|
||||
- name: "Linux: start agent and run WS probe"
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUST_LOG=info SOCKTOP_ENABLE_SSL=0 SOCKTOP_AGENT_GPU=0 SOCKTOP_AGENT_TEMP=0 ./target/release/socktop_agent -p 3000 > agent.log 2>&1 &
|
||||
AGENT_PID=$!
|
||||
for i in {1..60}; do
|
||||
if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if ! curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
|
||||
echo "--- agent.log (tail) ---"
|
||||
tail -n 200 agent.log || true
|
||||
(command -v ss >/dev/null && ss -ltnp || netstat -ltnp) || true
|
||||
kill $AGENT_PID || true
|
||||
exit 1
|
||||
fi
|
||||
SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop_connector --test integration_test -- --nocapture
|
||||
kill $AGENT_PID || true
|
||||
|
||||
- name: "Windows: start agent and run WS probe"
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SOCKTOP_ENABLE_SSL = "0"
|
||||
$env:SOCKTOP_AGENT_GPU = "0"
|
||||
$env:SOCKTOP_AGENT_TEMP = "0"
|
||||
$out = Join-Path $PWD "agent.out.txt"
|
||||
$err = Join-Path $PWD "agent.err.txt"
|
||||
$p = Start-Process -FilePath "${PWD}\target\release\socktop_agent.exe" -ArgumentList "-p 3000" -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -NoNewWindow
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 60; $i++) {
|
||||
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$pinfo.FileName = "curl.exe"
|
||||
$pinfo.Arguments = "-fsS http://127.0.0.1:3000/healthz"
|
||||
$pinfo.RedirectStandardOutput = $true
|
||||
$pinfo.RedirectStandardError = $true
|
||||
$pinfo.UseShellExecute = $false
|
||||
$proc = [System.Diagnostics.Process]::Start($pinfo)
|
||||
$proc.WaitForExit()
|
||||
if ($proc.ExitCode -eq 0) { $ready = $true; break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $ready) {
|
||||
Write-Warning "TCP connect to (127.0.0.1 : 3000) failed"
|
||||
if (Test-Path $out) { Write-Host "--- agent.out (full) ---"; Get-Content $out }
|
||||
if (Test-Path $err) { Write-Host "--- agent.err (full) ---"; Get-Content $err }
|
||||
Write-Host "--- netstat ---"
|
||||
netstat -ano | Select-String ":3000" | ForEach-Object { $_.Line }
|
||||
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
throw "agent did not become ready"
|
||||
}
|
||||
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
|
||||
try {
|
||||
cargo test -p socktop_connector --test integration_test -- --nocapture
|
||||
} finally {
|
||||
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
- name: Smoke test (client --help)
|
||||
run: cargo run -p socktop -- --help
|
||||
- name: Package artifacts
|
||||
|
||||
- name: Package artifacts (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
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
|
||||
mkdir -p dist
|
||||
cp target/release/socktop dist/
|
||||
cp target/release/socktop_agent dist/
|
||||
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
|
||||
|
||||
- name: Package artifacts (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
Copy-Item target\release\socktop.exe dist\
|
||||
Copy-Item target\release\socktop_agent.exe dist\
|
||||
Compress-Archive -Path dist\* -DestinationPath socktop-${{ matrix.os }}.zip -Force
|
||||
|
||||
- name: Upload build artifacts (ephemeral)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -46,6 +113,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
/target
|
||||
.vscode/
|
||||
/socktop-wasm-test/target
|
||||
|
||||
Vendored
-83
@@ -1,83 +0,0 @@
|
||||
{
|
||||
// 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
+956
-68
File diff suppressed because it is too large
Load Diff
+24
-10
@@ -2,35 +2,49 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"socktop",
|
||||
"socktop_agent"
|
||||
"socktop_agent",
|
||||
"socktop_connector"
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
# async + streams
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
anyhow = "1.0"
|
||||
|
||||
# websocket
|
||||
tokio-tungstenite = "0.24"
|
||||
tungstenite = "0.24"
|
||||
tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] }
|
||||
url = "2.5"
|
||||
|
||||
# JSON + error handling
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# system stats
|
||||
sysinfo = "0.32"
|
||||
# system stats (align across crates)
|
||||
sysinfo = "0.37"
|
||||
|
||||
# CLI UI
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.27"
|
||||
|
||||
# date/time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# web server (remote-agent)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
# protobuf
|
||||
prost = "0.13"
|
||||
dirs-next = "2"
|
||||
|
||||
# compression
|
||||
flate2 = "1.0"
|
||||
|
||||
# TLS
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
rustls-pemfile = "2.1"
|
||||
|
||||
[profile.release]
|
||||
# Favor smaller, simpler binaries with good runtime perf
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
opt-level = 3
|
||||
strip = "symbols"
|
||||
@@ -5,13 +5,14 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
|
||||
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||

|
||||
<img src="./docs/socktop_demo.apng" width="100%">
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Remote monitoring via WebSocket (JSON over WS)
|
||||
- Optional WSS (TLS): agent auto‑generates a self‑signed cert on first run; client pins the cert via --tls-ca/-t
|
||||
- TUI built with ratatui
|
||||
- CPU
|
||||
- Overall sparkline + per-core mini bars
|
||||
@@ -50,7 +51,7 @@ exec bash # or: exec zsh / exec fish
|
||||
|
||||
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, you’ll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
|
||||
|
||||
### Raspberry Pi (required)
|
||||
### Raspberry Pi / Ubuntu / PopOS (required)
|
||||
|
||||
Install GPU support with apt command below
|
||||
|
||||
@@ -59,15 +60,17 @@ sudo apt-get update
|
||||
sudo apt-get install libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Two components:
|
||||
|
||||
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.
|
||||
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
|
||||
|
||||
2) Client (local): TUI that connects to ws://HOST:PORT/ws and renders updates.
|
||||
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
|
||||
|
||||
---
|
||||
|
||||
@@ -93,7 +96,24 @@ cargo build --release
|
||||
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
||||
```
|
||||
|
||||
Tip: Add ?token=... if you enable auth (see Security).
|
||||
### Cross-compiling for Raspberry Pi
|
||||
|
||||
For Raspberry Pi and other ARM devices, you can cross-compile the agent from a more powerful machine:
|
||||
|
||||
- [Cross-compilation guide](./docs/cross-compiling.md) - Instructions for cross-compiling from Linux, macOS, or Windows hosts
|
||||
|
||||
### Quick demo (no agent setup)
|
||||
|
||||
Spin up a temporary local agent on port 3231 and connect automatically:
|
||||
|
||||
```bash
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
Or just run `socktop` with no arguments and pick the built‑in `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
|
||||
|
||||
- Runs locally (`ws://127.0.0.1:3231/ws`)
|
||||
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
|
||||
|
||||
---
|
||||
|
||||
@@ -114,7 +134,8 @@ Notes:
|
||||
- After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
|
||||
- Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldn’t. Be brave.
|
||||
|
||||
Option B: System-wide agent (Linux)
|
||||
System-wide agent (Linux)
|
||||
|
||||
```bash
|
||||
# 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
|
||||
@@ -125,6 +146,36 @@ sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
# Enable SSL
|
||||
|
||||
# Stop service
|
||||
sudo systemctl stop socktop-agent
|
||||
|
||||
# Edit service to append SSL option and port
|
||||
sudo micro /etc/systemd/system/socktop-agent.service
|
||||
|
||||
--
|
||||
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
|
||||
--
|
||||
|
||||
# Reload
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Restart
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# check logs for certificate location
|
||||
sudo journalctl -u socktop-agent -f
|
||||
|
||||
--
|
||||
Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
|
||||
--
|
||||
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
@@ -135,6 +186,8 @@ Agent (server):
|
||||
socktop_agent --port 3000
|
||||
# or env: SOCKTOP_PORT=3000 socktop_agent
|
||||
# optional auth: SOCKTOP_TOKEN=changeme socktop_agent
|
||||
# enable TLS (self‑signed cert, default port 8443; you can also use -p):
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Client (TUI):
|
||||
@@ -143,17 +196,114 @@ Client (TUI):
|
||||
socktop ws://HOST:3000/ws
|
||||
# with token:
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
# TLS with pinned server certificate (recommended over the internet):
|
||||
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# (By default hostname/SAN verification is skipped for ease on home networks. To enforce it add --verify-hostname)
|
||||
socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# shorthand:
|
||||
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
||||
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
||||
```
|
||||
|
||||
Intervals (client-driven):
|
||||
- Fast metrics: ~500 ms
|
||||
- Processes: ~2 s (top 50)
|
||||
- Processes: ~2 s
|
||||
- Disks: ~5 s
|
||||
|
||||
The agent stays idle unless queried. When queried, it collects just what’s needed.
|
||||
|
||||
---
|
||||
|
||||
## Connection Profiles (Named)
|
||||
|
||||
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||
|
||||
Config file location:
|
||||
|
||||
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
|
||||
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
|
||||
|
||||
### Creating a profile
|
||||
|
||||
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
|
||||
|
||||
```bash
|
||||
socktop --profile prod ws://prod-host:3000/ws
|
||||
# With TLS pinning:
|
||||
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
|
||||
|
||||
You can also set custom intervals (milliseconds):
|
||||
|
||||
```bash
|
||||
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
|
||||
```
|
||||
```
|
||||
|
||||
If a profile already exists you will be prompted before overwriting:
|
||||
|
||||
```
|
||||
$ socktop --profile prod ws://new-host:3000/ws
|
||||
Overwrite existing profile 'prod'? [y/N]: y
|
||||
```
|
||||
|
||||
To overwrite without an interactive prompt pass `--save`:
|
||||
|
||||
```bash
|
||||
socktop --profile prod --save ws://new-host:3000/ws
|
||||
```
|
||||
|
||||
### Using a saved profile
|
||||
|
||||
Just pass the profile name (no URL needed):
|
||||
|
||||
```bash
|
||||
socktop --profile prod
|
||||
socktop -P prod-tls # short flag
|
||||
```
|
||||
|
||||
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
|
||||
|
||||
### Interactive selection (no args)
|
||||
|
||||
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
|
||||
|
||||
```
|
||||
$ socktop
|
||||
Select profile:
|
||||
1. prod
|
||||
2. prod-tls
|
||||
Enter number (or blank to abort): 2
|
||||
```
|
||||
|
||||
Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
|
||||
|
||||
### JSON format
|
||||
|
||||
An example `profiles.json` (pretty‑printed):
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"prod": { "url": "ws://prod-host:3000/ws" },
|
||||
"prod-tls": {
|
||||
"url": "wss://prod-host:8443/ws",
|
||||
"tls_ca": "/home/user/certs/prod-cert.pem",
|
||||
"metrics_interval_ms": 500,
|
||||
"processes_interval_ms": 2000
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
|
||||
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
|
||||
- Profiles are client-side convenience only; they do not affect the agent.
|
||||
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
Update the agent (systemd):
|
||||
@@ -188,6 +338,25 @@ Tip: If only the binary changed, restart is enough. If the unit file changed, ru
|
||||
- Flag: --port 8080 or -p 8080
|
||||
- Positional: socktop_agent 8080
|
||||
- Env: SOCKTOP_PORT=8080
|
||||
- TLS (self‑signed):
|
||||
- Enable: --enableSSL
|
||||
- Default TLS port: 8443 (override with --port/-p)
|
||||
- Certificate/Key location (created on first TLS run):
|
||||
- Linux (XDG): $XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem} (defaults to ~/.config)
|
||||
- The agent prints these paths on creation.
|
||||
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
|
||||
- Additional SANs: set `SOCKTOP_AGENT_EXTRA_SANS` (comma‑separated) before first TLS start to include extra IPs/DNS names in the cert. Example:
|
||||
```bash
|
||||
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL
|
||||
```
|
||||
This prevents client errors like `NotValidForName` when connecting via an IP not present in the default cert SAN list.
|
||||
- Expiry / rotation: the generated cert is valid for ~397 days from creation. If the agent fails to start with an "ExpiredCertificate" error (or your client reports expiry), simply delete the existing cert and key:
|
||||
```bash
|
||||
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
|
||||
# (adjust path if XDG_CONFIG_HOME is set or different user)
|
||||
systemctl restart socktop-agent # if running under systemd
|
||||
```
|
||||
On next TLS start the agent will generate a fresh pair. Only distribute the new cert.pem to clients (never the key).
|
||||
- Auth token (optional): SOCKTOP_TOKEN=changeme
|
||||
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
|
||||
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
|
||||
@@ -250,6 +419,28 @@ Client:
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
```
|
||||
|
||||
### TLS / WSS
|
||||
|
||||
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
|
||||
|
||||
Server (generates self‑signed cert and key on first run):
|
||||
|
||||
```bash
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Client (trust/pin the server cert; copy cert.pem from the agent):
|
||||
|
||||
```bash
|
||||
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Do not copy the private key off the server; only the cert.pem is needed by clients.
|
||||
- When --tls-ca/-t is supplied, the client auto‑upgrades ws:// to wss:// to avoid protocol mismatch.
|
||||
- Hostname (SAN) verification is DISABLED by default (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking.
|
||||
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
|
||||
|
||||
---
|
||||
|
||||
## Using tmux to monitor multiple hosts
|
||||
@@ -319,9 +510,22 @@ Tips:
|
||||
cargo fmt
|
||||
cargo clippy --all-targets --all-features
|
||||
cargo run -p socktop -- ws://127.0.0.1:3000/ws
|
||||
cargo run -p socktop_agent -- --port 3000
|
||||
# TLS (dev): first run will create certs under ~/.config/socktop_agent/tls/
|
||||
cargo run -p socktop_agent -- --enableSSL --port 8443
|
||||
```
|
||||
|
||||
### Auto-format on commit
|
||||
|
||||
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
|
||||
Enable it (one-time):
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-commit
|
||||
```
|
||||
|
||||
Every commit will then format Rust sources and restage them automatically.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
@@ -329,10 +533,13 @@ cargo run -p socktop_agent -- --port 3000
|
||||
- [x] Agent authentication (token)
|
||||
- [x] Hide per-thread entries; only show processes
|
||||
- [x] Sort top processes in the TUI
|
||||
- [ ] Configurable refresh intervals (client)
|
||||
- [x] Configurable refresh intervals (client)
|
||||
- [ ] Export metrics to file
|
||||
- [ ] TLS / WSS support
|
||||
- [x] TLS / WSS support (self‑signed server cert + client pinning)
|
||||
- [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
|
||||
- [ ] Outage notifications and reconnect.
|
||||
- [ ] Per process detailed statistics pane
|
||||
- [ ] cleanup of Disks section, properly display physical disks / partitions, remove duplicate entries
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Cross-Compiling socktop_agent for Raspberry Pi
|
||||
|
||||
This guide explains how to cross-compile the socktop_agent on various host systems and deploy it to a Raspberry Pi. Cross-compiling is particularly useful for older or resource-constrained Pi models where native compilation might be slow.
|
||||
|
||||
## Cross-Compilation Host Setup
|
||||
|
||||
Choose your host operating system:
|
||||
|
||||
- [Debian/Ubuntu](#debianubuntu-based-systems)
|
||||
- [Arch Linux](#arch-linux-based-systems)
|
||||
- [macOS](#macos)
|
||||
- [Windows](#windows)
|
||||
|
||||
## Debian/Ubuntu Based Systems
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install the cross-compilation toolchain for your target Raspberry Pi architecture:
|
||||
|
||||
```bash
|
||||
# For 64-bit Raspberry Pi (aarch64)
|
||||
sudo apt update
|
||||
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
|
||||
|
||||
# For 32-bit Raspberry Pi (armv7)
|
||||
sudo apt update
|
||||
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross libdrm-dev:armhf
|
||||
```
|
||||
|
||||
### Setup Rust Cross-Compilation Targets
|
||||
|
||||
```bash
|
||||
# For 64-bit Raspberry Pi
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
### Configure Cargo for Cross-Compilation
|
||||
|
||||
Create or edit `~/.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
```
|
||||
|
||||
## Arch Linux Based Systems
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install the cross-compilation toolchain using pacman and AUR:
|
||||
|
||||
```bash
|
||||
# Install base dependencies
|
||||
sudo pacman -S base-devel
|
||||
|
||||
# For 64-bit Raspberry Pi (aarch64)
|
||||
sudo pacman -S aarch64-linux-gnu-gcc
|
||||
# Install libdrm for aarch64 using an AUR helper (e.g., yay, paru)
|
||||
yay -S aarch64-linux-gnu-libdrm
|
||||
|
||||
# For 32-bit Raspberry Pi (armv7)
|
||||
sudo pacman -S arm-linux-gnueabihf-gcc
|
||||
# Install libdrm for armv7 using an AUR helper
|
||||
yay -S arm-linux-gnueabihf-libdrm
|
||||
```
|
||||
|
||||
### Setup Rust Cross-Compilation Targets
|
||||
|
||||
```bash
|
||||
# For 64-bit Raspberry Pi
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
### Configure Cargo for Cross-Compilation
|
||||
|
||||
Create or edit `~/.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
```
|
||||
|
||||
## macOS
|
||||
|
||||
The recommended approach for cross-compiling from macOS is to use Docker:
|
||||
|
||||
```bash
|
||||
# Install Docker
|
||||
brew install --cask docker
|
||||
|
||||
# Pull a cross-compilation Docker image
|
||||
docker pull messense/rust-musl-cross:armv7-musleabihf # For 32-bit Pi
|
||||
docker pull messense/rust-musl-cross:aarch64-musl # For 64-bit Pi
|
||||
```
|
||||
|
||||
### Using Docker for Cross-Compilation
|
||||
|
||||
```bash
|
||||
# Navigate to your socktop project directory
|
||||
cd path/to/socktop
|
||||
|
||||
# For 64-bit Raspberry Pi
|
||||
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:aarch64-musl cargo build --release --target aarch64-unknown-linux-musl -p socktop_agent
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent
|
||||
```
|
||||
|
||||
The compiled binaries will be available in your local target directory.
|
||||
|
||||
## Windows
|
||||
|
||||
The recommended approach for Windows is to use Windows Subsystem for Linux (WSL2):
|
||||
|
||||
1. Install WSL2 with a Debian/Ubuntu distribution by following the [official Microsoft documentation](https://docs.microsoft.com/en-us/windows/wsl/install).
|
||||
|
||||
2. Once WSL2 is set up with a Debian/Ubuntu distribution, open your WSL terminal and follow the [Debian/Ubuntu instructions](#debianubuntu-based-systems) above.
|
||||
|
||||
## Cross-Compile the Agent
|
||||
|
||||
After setting up your environment, build the socktop_agent for your target Raspberry Pi:
|
||||
|
||||
```bash
|
||||
# For 64-bit Raspberry Pi
|
||||
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent
|
||||
```
|
||||
|
||||
## Transfer the Binary to Your Raspberry Pi
|
||||
|
||||
Use SCP to transfer the compiled binary to your Raspberry Pi:
|
||||
|
||||
```bash
|
||||
# For 64-bit Raspberry Pi
|
||||
scp target/aarch64-unknown-linux-gnu/release/socktop_agent pi@raspberry-pi-ip:~/
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
scp target/armv7-unknown-linux-gnueabihf/release/socktop_agent pi@raspberry-pi-ip:~/
|
||||
```
|
||||
|
||||
Replace `raspberry-pi-ip` with your Raspberry Pi's IP address and `pi` with your username.
|
||||
|
||||
## Install Dependencies on the Raspberry Pi
|
||||
|
||||
SSH into your Raspberry Pi and install the required dependencies:
|
||||
|
||||
```bash
|
||||
ssh pi@raspberry-pi-ip
|
||||
|
||||
# For Raspberry Pi OS (Debian-based)
|
||||
sudo apt update
|
||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||
|
||||
# For Arch Linux ARM
|
||||
sudo pacman -Syu
|
||||
sudo pacman -S libdrm
|
||||
```
|
||||
|
||||
## Make the Binary Executable and Install
|
||||
|
||||
```bash
|
||||
chmod +x ~/socktop_agent
|
||||
|
||||
# Optional: Install system-wide
|
||||
sudo install -o root -g root -m 0755 ~/socktop_agent /usr/local/bin/socktop_agent
|
||||
|
||||
# Optional: Set up as a systemd service
|
||||
sudo install -o root -g root -m 0644 ~/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with the cross-compiled binary:
|
||||
|
||||
1. **Incorrect Architecture**: Ensure you've chosen the correct target for your Raspberry Pi model:
|
||||
- For Raspberry Pi 2: use `armv7-unknown-linux-gnueabihf`
|
||||
- For Raspberry Pi 3/4/5 in 64-bit mode: use `aarch64-unknown-linux-gnu`
|
||||
- For Raspberry Pi 3/4/5 in 32-bit mode: use `armv7-unknown-linux-gnueabihf`
|
||||
|
||||
2. **Dependency Issues**: Check for missing libraries:
|
||||
```bash
|
||||
ldd ~/socktop_agent
|
||||
```
|
||||
|
||||
3. **Run with Backtrace**: Get detailed error information:
|
||||
```bash
|
||||
RUST_BACKTRACE=1 ~/socktop_agent
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 MiB |
@@ -0,0 +1,8 @@
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
Error: Address already in use (os error 98)
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
Error: Address already in use (os error 98)
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["clippy", "rustfmt"]
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Cross-check Windows build from Linux using the GNU (MinGW) toolchain.
|
||||
# - Ensures target `x86_64-pc-windows-gnu` is installed
|
||||
# - Verifies MinGW cross-compiler is available (x86_64-w64-mingw32-gcc)
|
||||
# - Runs cargo clippy with warnings-as-errors for the Windows target
|
||||
# - Builds release binaries for the Windows target
|
||||
|
||||
echo "[socktop] Windows cross-check: clippy + build (GNU target)"
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
if ! have rustup; then
|
||||
echo "error: rustup not found. Install Rust via rustup first (see README)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q '^x86_64-pc-windows-gnu$'; then
|
||||
echo "+ rustup target add x86_64-pc-windows-gnu"
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
fi
|
||||
|
||||
if ! have x86_64-w64-mingw32-gcc; then
|
||||
echo "error: Missing MinGW cross-compiler (x86_64-w64-mingw32-gcc)." >&2
|
||||
if have pacman; then
|
||||
echo "Arch Linux: sudo pacman -S --needed mingw-w64-gcc" >&2
|
||||
elif have apt-get; then
|
||||
echo "Debian/Ubuntu: sudo apt-get install -y mingw-w64" >&2
|
||||
elif have dnf; then
|
||||
echo "Fedora: sudo dnf install -y mingw64-gcc" >&2
|
||||
else
|
||||
echo "Install the mingw-w64 toolchain for your distro, then re-run." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CARGO_FLAGS=(--workspace --all-targets --all-features --target x86_64-pc-windows-gnu)
|
||||
|
||||
echo "+ cargo clippy ${CARGO_FLAGS[*]} -- -D warnings"
|
||||
cargo clippy "${CARGO_FLAGS[@]}" -- -D warnings
|
||||
|
||||
echo "+ cargo build --release ${CARGO_FLAGS[*]}"
|
||||
cargo build --release "${CARGO_FLAGS[@]}"
|
||||
|
||||
echo "✅ Windows clippy and build completed successfully."
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Publish job: "publish new socktop agent version"
|
||||
# Usage: ./scripts/publish_socktop_agent.sh <new_version>
|
||||
|
||||
if [[ ${1:-} == "" ]]; then
|
||||
echo "Usage: $0 <new_version>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_VERSION="$1"
|
||||
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
CRATE_DIR="$ROOT_DIR/socktop_agent"
|
||||
|
||||
echo "==> Formatting socktop_agent"
|
||||
(cd "$ROOT_DIR" && cargo fmt -p socktop_agent)
|
||||
|
||||
echo "==> Running tests for socktop_agent"
|
||||
(cd "$ROOT_DIR" && cargo test -p socktop_agent)
|
||||
|
||||
echo "==> Running clippy (warnings as errors) for socktop_agent"
|
||||
(cd "$ROOT_DIR" && cargo clippy -p socktop_agent -- -D warnings)
|
||||
|
||||
echo "==> Building release for socktop_agent"
|
||||
(cd "$ROOT_DIR" && cargo build -p socktop_agent --release)
|
||||
|
||||
echo "==> Bumping version to $NEW_VERSION in socktop_agent/Cargo.toml"
|
||||
sed -i.bak -E "s/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/version = \"$NEW_VERSION\"/" "$CRATE_DIR/Cargo.toml"
|
||||
rm -f "$CRATE_DIR/Cargo.toml.bak"
|
||||
|
||||
echo "==> Committing version bump"
|
||||
(cd "$ROOT_DIR" && git add -A && git commit -m "socktop_agent: bump version to $NEW_VERSION")
|
||||
|
||||
CURRENT_BRANCH=$(cd "$ROOT_DIR" && git rev-parse --abbrev-ref HEAD)
|
||||
echo "==> Pushing to origin $CURRENT_BRANCH"
|
||||
(cd "$ROOT_DIR" && git push origin "$CURRENT_BRANCH")
|
||||
|
||||
echo "==> Publishing socktop_agent $NEW_VERSION to crates.io"
|
||||
(cd "$ROOT_DIR" && cargo publish -p socktop_agent)
|
||||
|
||||
echo "==> Done: socktop_agent $NEW_VERSION published"
|
||||
|
||||
+12
-7
@@ -1,22 +1,27 @@
|
||||
[package]
|
||||
name = "socktop"
|
||||
version = "0.1.1"
|
||||
version = "1.40.0"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
description = "Remote system monitor over WebSocket, TUI like top"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
# socktop connector for agent communication
|
||||
socktop_connector = { path = "../socktop_connector" }
|
||||
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
tungstenite = "0.27.0"
|
||||
dirs-next = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,26 @@
|
||||
# socktop (client)
|
||||
|
||||
Minimal TUI client for the socktop remote monitoring agent.
|
||||
|
||||
Features:
|
||||
- Connects to a socktop_agent over WebSocket / secure WebSocket
|
||||
- Displays CPU, memory, swap, disks, network, processes, (optional) GPU metrics
|
||||
- Self‑signed TLS cert pinning via --tls-ca
|
||||
- Profile management with saved intervals
|
||||
- Low CPU usage (request-driven updates)
|
||||
|
||||
Quick start:
|
||||
```
|
||||
cargo install socktop
|
||||
socktop ws://HOST:3000/ws
|
||||
```
|
||||
With TLS (copy agent cert first):
|
||||
```
|
||||
socktop --tls-ca cert.pem wss://HOST:8443/ws
|
||||
```
|
||||
Demo mode (spawns a local agent automatically on first run prompt):
|
||||
```
|
||||
socktop --demo
|
||||
```
|
||||
Full documentation, screenshots, and advanced usage:
|
||||
https://github.com/jasonwitty/socktop
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
+88
-29
@@ -9,28 +9,36 @@ use std::{
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Rect},
|
||||
//style::Color, // + add Color
|
||||
Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Rect},
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::history::{push_capped, PerCoreHistory};
|
||||
use crate::history::{PerCoreHistory, push_capped};
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::cpu::{
|
||||
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,
|
||||
PerCoreScrollDrag, 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,
|
||||
};
|
||||
use crate::ui::processes::{processes_handle_key, processes_handle_mouse, ProcSortBy};
|
||||
use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse};
|
||||
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};
|
||||
use socktop_connector::{
|
||||
AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent,
|
||||
connect_to_socktop_agent_with_tls,
|
||||
};
|
||||
|
||||
// Constants for minimum intervals to ensure reasonable performance
|
||||
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
||||
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
||||
|
||||
pub struct App {
|
||||
// Latest metrics + histories
|
||||
@@ -63,6 +71,13 @@ pub struct App {
|
||||
last_disks_poll: Instant,
|
||||
procs_interval: Duration,
|
||||
disks_interval: Duration,
|
||||
metrics_interval: Duration,
|
||||
|
||||
// For reconnects
|
||||
ws_url: String,
|
||||
// Security / status flags
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -91,12 +106,42 @@ impl App {
|
||||
.unwrap_or_else(Instant::now),
|
||||
procs_interval: Duration::from_secs(2),
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
ws_url: String::new(),
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&mut self, url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
|
||||
metrics_ms.inspect(|&m| {
|
||||
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
|
||||
});
|
||||
procs_ms.inspect(|&p| {
|
||||
self.procs_interval = Duration::from_millis(p.max(MIN_PROCESSES_INTERVAL_MS));
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_status(mut self, is_tls: bool, has_token: bool) -> Self {
|
||||
self.is_tls = is_tls;
|
||||
self.has_token = has_token;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&mut self,
|
||||
url: &str,
|
||||
tls_ca: Option<&str>,
|
||||
verify_hostname: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to agent
|
||||
let mut ws = connect(url).await?;
|
||||
self.ws_url = url.to_string();
|
||||
let mut ws = if let Some(ca_path) = tls_ca {
|
||||
connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await?
|
||||
} else {
|
||||
connect_to_socktop_agent(url).await?
|
||||
};
|
||||
|
||||
// Terminal setup
|
||||
enable_raw_mode()?;
|
||||
@@ -121,7 +166,7 @@ impl App {
|
||||
async fn event_loop<B: ratatui::backend::Backend>(
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
ws: &mut crate::ws::WsStream,
|
||||
ws: &mut SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
// Input (non-blocking)
|
||||
@@ -224,16 +269,15 @@ impl App {
|
||||
// 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(
|
||||
&& 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;
|
||||
}
|
||||
)
|
||||
{
|
||||
self.procs_sort_by = new_sort;
|
||||
}
|
||||
}
|
||||
Event::Resize(_, _) => {}
|
||||
@@ -245,26 +289,29 @@ impl App {
|
||||
}
|
||||
|
||||
// Fetch and update
|
||||
if let Some(m) = request_metrics(ws).await {
|
||||
self.update_with_metrics(m);
|
||||
if let Ok(response) = ws.request(AgentRequest::Metrics).await {
|
||||
if let AgentResponse::Metrics(m) = response {
|
||||
self.update_with_metrics(m);
|
||||
}
|
||||
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
if let Some(procs) = request_processes(ws).await {
|
||||
if let Some(mm) = self.last_metrics.as_mut() {
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
}
|
||||
if let Ok(AgentResponse::Processes(procs)) =
|
||||
ws.request(AgentRequest::Processes).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if let Ok(AgentResponse::Disks(disks)) = ws.request(AgentRequest::Disks).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.disks = disks;
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
}
|
||||
@@ -274,7 +321,7 @@ impl App {
|
||||
terminal.draw(|f| self.draw(f))?;
|
||||
|
||||
// Tick rate
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
sleep(self.metrics_interval).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -341,7 +388,15 @@ impl App {
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
draw_header(f, rows[0], self.last_metrics.as_ref());
|
||||
draw_header(
|
||||
f,
|
||||
rows[0],
|
||||
self.last_metrics.as_ref(),
|
||||
self.is_tls,
|
||||
self.has_token,
|
||||
self.metrics_interval,
|
||||
self.procs_interval,
|
||||
);
|
||||
|
||||
// Top row: left CPU avg, right Per-core (full top-right)
|
||||
let top_lr = ratatui::layout::Layout::default()
|
||||
@@ -461,6 +516,10 @@ impl Default for App {
|
||||
.unwrap_or_else(Instant::now),
|
||||
procs_interval: Duration::from_secs(2),
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
ws_url: String::new(),
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Library surface for integration tests and reuse.
|
||||
|
||||
pub mod types;
|
||||
|
||||
// Re-export connector functionality
|
||||
pub use socktop_connector::{SocktopConnector, connect_to_socktop_agent};
|
||||
+415
-13
@@ -2,29 +2,431 @@
|
||||
|
||||
mod app;
|
||||
mod history;
|
||||
mod profiles;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod ws;
|
||||
|
||||
use app::App;
|
||||
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
|
||||
pub(crate) struct ParsedArgs {
|
||||
url: Option<String>,
|
||||
tls_ca: Option<String>,
|
||||
profile: Option<String>,
|
||||
save: bool,
|
||||
demo: bool,
|
||||
dry_run: bool, // hidden test helper: skip connecting
|
||||
metrics_interval_ms: Option<u64>,
|
||||
processes_interval_ms: Option<u64>,
|
||||
verify_hostname: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||
let mut it = args.into_iter();
|
||||
let prog = it.next().unwrap_or_else(|| "socktop".into());
|
||||
let mut url: Option<String> = None;
|
||||
let mut tls_ca: Option<String> = None;
|
||||
let mut profile: Option<String> = None;
|
||||
let mut save = false;
|
||||
let mut demo = false;
|
||||
let mut dry_run = false;
|
||||
let mut metrics_interval_ms: Option<u64> = None;
|
||||
let mut processes_interval_ms: Option<u64> = None;
|
||||
let mut verify_hostname = false;
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => {
|
||||
return Err(format!(
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
));
|
||||
}
|
||||
"--tls-ca" | "-t" => {
|
||||
tls_ca = it.next();
|
||||
}
|
||||
"--verify-hostname" => {
|
||||
// opt-in hostname (SAN) verification
|
||||
// default behavior is to skip it for easier home network usage
|
||||
// (still pins the provided certificate)
|
||||
verify_hostname = true;
|
||||
}
|
||||
"--profile" | "-P" => {
|
||||
profile = it.next();
|
||||
}
|
||||
"--save" => {
|
||||
save = true;
|
||||
}
|
||||
"--demo" => {
|
||||
demo = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
// intentionally undocumented
|
||||
dry_run = true;
|
||||
}
|
||||
"--metrics-interval-ms" => {
|
||||
metrics_interval_ms = it.next().and_then(|v| v.parse().ok());
|
||||
}
|
||||
"--processes-interval-ms" => {
|
||||
processes_interval_ms = it.next().and_then(|v| v.parse().ok());
|
||||
}
|
||||
_ if arg.starts_with("--tls-ca=") => {
|
||||
if let Some((_, v)) = arg.split_once('=')
|
||||
&& !v.is_empty()
|
||||
{
|
||||
tls_ca = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--profile=") => {
|
||||
if let Some((_, v)) = arg.split_once('=')
|
||||
&& !v.is_empty()
|
||||
{
|
||||
profile = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--metrics-interval-ms=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
metrics_interval_ms = v.parse().ok();
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--processes-interval-ms=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
processes_interval_ms = v.parse().ok();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if url.is_none() {
|
||||
url = Some(arg);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ParsedArgs {
|
||||
url,
|
||||
tls_ca,
|
||||
profile,
|
||||
save,
|
||||
demo,
|
||||
dry_run,
|
||||
metrics_interval_ms,
|
||||
processes_interval_ms,
|
||||
verify_hostname,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = env::args();
|
||||
let prog = args.next().unwrap_or_else(|| "socktop".into());
|
||||
let url = match args.next() {
|
||||
Some(flag) if flag == "-h" || flag == "--help" => {
|
||||
println!("Usage: {prog} ws://HOST:PORT/ws");
|
||||
let parsed = match parse_args(env::args()) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
return Ok(());
|
||||
}
|
||||
Some(url) => url,
|
||||
None => {
|
||||
eprintln!("Usage: {prog} ws://HOST:PORT/ws");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
app.run(&url).await
|
||||
//support version flag (print and exit)
|
||||
if env::args().any(|a| a == "--version" || a == "-V") {
|
||||
println!("socktop {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
}
|
||||
|
||||
let profiles_file = load_profiles();
|
||||
let req = ProfileRequest {
|
||||
profile_name: parsed.profile.clone(),
|
||||
url: parsed.url.clone(),
|
||||
tls_ca: parsed.tls_ca.clone(),
|
||||
};
|
||||
|
||||
let resolved = req.resolve(&profiles_file);
|
||||
let mut profiles_mut = profiles_file.clone();
|
||||
let (url, tls_ca, metrics_interval_ms, processes_interval_ms): (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<u64>,
|
||||
Option<u64>,
|
||||
) = match resolved {
|
||||
ResolveProfile::Direct(u, t) => {
|
||||
if let Some(name) = parsed.profile.as_ref() {
|
||||
let existing = profiles_mut.profiles.get(name);
|
||||
match existing {
|
||||
None => {
|
||||
let (mi, pi) = gather_intervals(
|
||||
parsed.metrics_interval_ms,
|
||||
parsed.processes_interval_ms,
|
||||
)?;
|
||||
profiles_mut.profiles.insert(
|
||||
name.clone(),
|
||||
ProfileEntry {
|
||||
url: u.clone(),
|
||||
tls_ca: t.clone(),
|
||||
metrics_interval_ms: mi,
|
||||
processes_interval_ms: pi,
|
||||
},
|
||||
);
|
||||
let _ = save_profiles(&profiles_mut);
|
||||
(u, t, mi, pi)
|
||||
}
|
||||
Some(entry) => {
|
||||
let changed = entry.url != u || entry.tls_ca != t;
|
||||
if changed {
|
||||
let overwrite = if parsed.save {
|
||||
true
|
||||
} else {
|
||||
prompt_yes_no(&format!(
|
||||
"Overwrite existing profile '{name}'? [y/N]: "
|
||||
))
|
||||
};
|
||||
if overwrite {
|
||||
let (mi, pi) = gather_intervals(
|
||||
parsed.metrics_interval_ms,
|
||||
parsed.processes_interval_ms,
|
||||
)?;
|
||||
profiles_mut.profiles.insert(
|
||||
name.clone(),
|
||||
ProfileEntry {
|
||||
url: u.clone(),
|
||||
tls_ca: t.clone(),
|
||||
metrics_interval_ms: mi,
|
||||
processes_interval_ms: pi,
|
||||
},
|
||||
);
|
||||
let _ = save_profiles(&profiles_mut);
|
||||
(u, t, mi, pi)
|
||||
} else {
|
||||
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
|
||||
}
|
||||
} else {
|
||||
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(
|
||||
u,
|
||||
t,
|
||||
parsed.metrics_interval_ms,
|
||||
parsed.processes_interval_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
ResolveProfile::Loaded(u, t) => {
|
||||
let entry = profiles_mut
|
||||
.profiles
|
||||
.get(parsed.profile.as_ref().unwrap())
|
||||
.unwrap();
|
||||
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
|
||||
}
|
||||
ResolveProfile::PromptSelect(mut names) => {
|
||||
if !names.iter().any(|n: &String| n == "demo") {
|
||||
names.push("demo".into());
|
||||
}
|
||||
eprintln!("Select profile:");
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
eprintln!(" {}. {}", i + 1, n);
|
||||
}
|
||||
eprint!("Enter number (or blank to abort): ");
|
||||
let _ = io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
if io::stdin().read_line(&mut line).is_ok() {
|
||||
if let Ok(idx) = line.trim().parse::<usize>() {
|
||||
if (1..=names.len()).contains(&idx) {
|
||||
let name = &names[idx - 1];
|
||||
if name == "demo" {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
}
|
||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||
(
|
||||
entry.url.clone(),
|
||||
entry.tls_ca.clone(),
|
||||
entry.metrics_interval_ms,
|
||||
entry.processes_interval_ms,
|
||||
)
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
ResolveProfile::PromptCreate(name) => {
|
||||
eprintln!("Profile '{name}' does not exist yet.");
|
||||
let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?;
|
||||
if url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ca = prompt_string("Enter TLS CA path (or leave blank): ")?;
|
||||
let ca_opt = if ca.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ca.trim().to_string())
|
||||
};
|
||||
let (mi, pi) =
|
||||
gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?;
|
||||
profiles_mut.profiles.insert(
|
||||
name.clone(),
|
||||
ProfileEntry {
|
||||
url: url.trim().to_string(),
|
||||
tls_ca: ca_opt.clone(),
|
||||
metrics_interval_ms: mi,
|
||||
processes_interval_ms: pi,
|
||||
},
|
||||
);
|
||||
let _ = save_profiles(&profiles_mut);
|
||||
(url.trim().to_string(), ca_opt, mi, pi)
|
||||
}
|
||||
ResolveProfile::None => {
|
||||
//eprintln!("No URL provided and no profiles to select.");
|
||||
|
||||
//first run, no args, no profiles: show welcome message and offer demo mode
|
||||
if profiles_mut.profiles.is_empty() && parsed.url.is_none() {
|
||||
eprintln!("Welcome to socktop!");
|
||||
eprintln!("It looks like this is your first time running the application.");
|
||||
eprintln!(
|
||||
"You can connect to a socktop_agent instance to monitor system metrics and processes."
|
||||
);
|
||||
eprintln!("If you don't have an agent running, you can try the demo mode.");
|
||||
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
} else {
|
||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Err("No URL provided and no profiles to select.".into());
|
||||
}
|
||||
};
|
||||
|
||||
let is_tls = url.starts_with("wss://");
|
||||
let has_token = url.contains("token=");
|
||||
let mut app = App::new()
|
||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||
.with_status(is_tls, has_token);
|
||||
if parsed.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
app.run(&url, tls_ca.as_deref(), parsed.verify_hostname)
|
||||
.await
|
||||
}
|
||||
|
||||
fn prompt_yes_no(prompt: &str) -> bool {
|
||||
eprint!("{prompt}");
|
||||
let _ = io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
if io::stdin().read_line(&mut line).is_ok() {
|
||||
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn prompt_string(prompt: &str) -> io::Result<String> {
|
||||
eprint!("{prompt}");
|
||||
let _ = io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
io::stdin().read_line(&mut line)?;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
fn gather_intervals(
|
||||
arg_metrics: Option<u64>,
|
||||
arg_procs: Option<u64>,
|
||||
) -> Result<(Option<u64>, Option<u64>), Box<dyn std::error::Error>> {
|
||||
let default_metrics = 500u64;
|
||||
let default_procs = 2000u64;
|
||||
let metrics = match arg_metrics {
|
||||
Some(v) => Some(v),
|
||||
None => {
|
||||
let inp = prompt_string(&format!(
|
||||
"Metrics interval ms (default {default_metrics}, Enter for default): "
|
||||
))?;
|
||||
let t = inp.trim();
|
||||
if t.is_empty() {
|
||||
Some(default_metrics)
|
||||
} else {
|
||||
Some(t.parse()?)
|
||||
}
|
||||
}
|
||||
};
|
||||
let procs = match arg_procs {
|
||||
Some(v) => Some(v),
|
||||
None => {
|
||||
let inp = prompt_string(&format!(
|
||||
"Processes interval ms (default {default_procs}, Enter for default): "
|
||||
))?;
|
||||
let t = inp.trim();
|
||||
if t.is_empty() {
|
||||
Some(default_procs)
|
||||
} else {
|
||||
Some(t.parse()?)
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok((metrics, procs))
|
||||
}
|
||||
|
||||
// Demo mode implementation
|
||||
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
let child = spawn_demo_agent(port)?;
|
||||
let mut app = App::new();
|
||||
// Demo mode connects to localhost, so disable hostname verification
|
||||
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||
}
|
||||
struct DemoGuard {
|
||||
port: u16,
|
||||
child: std::sync::Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||
}
|
||||
impl Drop for DemoGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut ch) = self.child.lock().unwrap().take() {
|
||||
let _ = ch.kill();
|
||||
}
|
||||
eprintln!("Stopped demo agent on port {}", self.port);
|
||||
}
|
||||
}
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||
let candidate = find_agent_executable();
|
||||
let mut cmd = std::process::Command::new(candidate);
|
||||
cmd.arg("--port").arg(port.to_string());
|
||||
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
||||
|
||||
//JW: do not disable GPU and TEMP in demo mode
|
||||
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
||||
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
Ok(DemoGuard {
|
||||
port,
|
||||
child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))),
|
||||
})
|
||||
}
|
||||
fn find_agent_executable() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(parent) = exe.parent()
|
||||
{
|
||||
#[cfg(windows)]
|
||||
let name = "socktop_agent.exe";
|
||||
#[cfg(not(windows))]
|
||||
let name = "socktop_agent";
|
||||
let candidate = parent.join(name);
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from("socktop_agent")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca }
|
||||
//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProfileEntry {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls_ca: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metrics_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub processes_interval_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProfilesFile {
|
||||
#[serde(default)]
|
||||
pub profiles: BTreeMap<String, ProfileEntry>,
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(xdg).join("socktop")
|
||||
} else {
|
||||
dirs_next::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("socktop")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profiles_path() -> PathBuf {
|
||||
config_dir().join("profiles.json")
|
||||
}
|
||||
|
||||
pub fn load_profiles() -> ProfilesFile {
|
||||
let path = profiles_path();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => ProfilesFile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> {
|
||||
let path = profiles_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let data = serde_json::to_vec_pretty(p).expect("serialize profiles");
|
||||
fs::write(path, data)
|
||||
}
|
||||
|
||||
pub enum ResolveProfile {
|
||||
/// Use the provided runtime inputs (not persisted). (url, tls_ca)
|
||||
Direct(String, Option<String>),
|
||||
/// Loaded from existing profile entry (url, tls_ca)
|
||||
Loaded(String, Option<String>),
|
||||
/// Should prompt user to select among profile names
|
||||
PromptSelect(Vec<String>),
|
||||
/// Should prompt user to create a new profile (name)
|
||||
PromptCreate(String),
|
||||
/// No profile could be resolved (e.g., missing arguments)
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct ProfileRequest {
|
||||
pub profile_name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub tls_ca: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfileRequest {
|
||||
pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile {
|
||||
// Case: only profile name given -> try load
|
||||
if self.url.is_none() && self.profile_name.is_some() {
|
||||
let Some(name) = self.profile_name else {
|
||||
unreachable!("Already checked profile_name.is_some()")
|
||||
};
|
||||
let Some(entry) = pf.profiles.get(&name) else {
|
||||
return ResolveProfile::PromptCreate(name);
|
||||
};
|
||||
return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone());
|
||||
}
|
||||
// Both provided -> direct (maybe later saved by caller)
|
||||
if let Some(u) = self.url {
|
||||
return ResolveProfile::Direct(u, self.tls_ca);
|
||||
}
|
||||
// Nothing provided -> maybe prompt select if profiles exist
|
||||
if self.url.is_none() && self.profile_name.is_none() {
|
||||
if pf.profiles.is_empty() {
|
||||
ResolveProfile::None
|
||||
} else {
|
||||
ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect())
|
||||
}
|
||||
} else {
|
||||
ResolveProfile::None
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-76
@@ -1,78 +1,4 @@
|
||||
//! Types that mirror the agent's JSON schema.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
}
|
||||
|
||||
#[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 cpu_total: f32,
|
||||
pub cpu_per_core: Vec<f32>,
|
||||
pub mem_total: u64,
|
||||
pub mem_used: u64,
|
||||
pub swap_total: u64,
|
||||
pub swap_used: u64,
|
||||
pub hostname: String,
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
pub disks: Vec<DiskInfo>,
|
||||
pub networks: Vec<NetworkInfo>,
|
||||
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>,
|
||||
}
|
||||
// Re-export commonly used types from socktop_connector
|
||||
pub use socktop_connector::Metrics;
|
||||
|
||||
+20
-20
@@ -180,28 +180,28 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
if let Some(mut d) = drag.take()
|
||||
&& 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 {
|
||||
offset = 0;
|
||||
}
|
||||
// Keep dragging
|
||||
d.start_top = new_top;
|
||||
d.start_y = mouse.row;
|
||||
*drag = Some(d);
|
||||
(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) => {
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let title = if let Some(mm) = m {
|
||||
pub fn draw_header(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
is_tls: bool,
|
||||
has_token: bool,
|
||||
metrics_interval: Duration,
|
||||
procs_interval: Duration,
|
||||
) {
|
||||
let base = if let Some(mm) = m {
|
||||
let temp = mm
|
||||
.cpu_temp_c
|
||||
.map(|t| {
|
||||
@@ -21,12 +30,23 @@ pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>)
|
||||
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
|
||||
)
|
||||
format!("socktop — host: {} | {}", mm.hostname, temp)
|
||||
} else {
|
||||
"socktop — connecting... (press 'q' to quit)".into()
|
||||
"socktop — connecting...".into()
|
||||
};
|
||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||
// Token indicator
|
||||
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let mut parts = vec![base, tls_txt.into()];
|
||||
if !tok_txt.is_empty() {
|
||||
parts.push(tok_txt.into());
|
||||
}
|
||||
parts.push(intervals);
|
||||
parts.push("(q to quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Minimal WebSocket client helpers for requesting metrics from the agent.
|
||||
|
||||
use flate2::bufread::GzDecoder;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::io::Read;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{interval, Duration};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use crate::types::{DiskInfo, Metrics, ProcessesPayload};
|
||||
|
||||
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
// Connect to the agent and return the WS stream
|
||||
pub async fn connect(url: &str) -> Result<WsStream, Box<dyn std::error::Error>> {
|
||||
let (ws, _) = connect_async(url).await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
// Send a "get_metrics" request and await a single JSON reply
|
||||
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
|
||||
if ws.send(Message::Text("get_metrics".into())).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Metrics>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Decompress a gzip-compressed binary frame into a String.
|
||||
fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
|
||||
let mut dec = GzDecoder::new(bytes);
|
||||
let mut out = String::new();
|
||||
dec.read_to_string(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// Suppress dead_code until these are wired into the app
|
||||
#[allow(dead_code)]
|
||||
pub enum Payload {
|
||||
Metrics(Metrics),
|
||||
Disks(Vec<DiskInfo>),
|
||||
Processes(ProcessesPayload),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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_disks" request and await a JSON Vec<DiskInfo>
|
||||
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
|
||||
if ws.send(Message::Text("get_disks".into())).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Vec<DiskInfo>>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<Vec<DiskInfo>>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Send a "get_processes" request and await a JSON ProcessesPayload
|
||||
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
|
||||
if ws
|
||||
.send(Message::Text("get_processes".into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<ProcessesPayload>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn start_ws_polling(mut ws: WsStream) {
|
||||
let mut t_fast = interval(Duration::from_millis(500));
|
||||
let mut t_procs = interval(Duration::from_secs(2));
|
||||
let mut t_disks = interval(Duration::from_secs(5));
|
||||
|
||||
let _ = ws.send(Message::Text("get_metrics".into())).await;
|
||||
let _ = ws.send(Message::Text("get_processes".into())).await;
|
||||
let _ = ws.send(Message::Text("get_disks".into())).await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = t_fast.tick() => {
|
||||
let _ = ws.send(Message::Text("get_metrics".into())).await;
|
||||
}
|
||||
_ = t_procs.tick() => {
|
||||
let _ = ws.send(Message::Text("get_processes".into())).await;
|
||||
}
|
||||
_ = t_disks.tick() => {
|
||||
let _ = ws.send(Message::Text("get_disks".into())).await;
|
||||
}
|
||||
maybe = ws.next() => {
|
||||
let Some(result) = maybe else { break; };
|
||||
let Ok(msg) = result else { break; };
|
||||
match msg {
|
||||
Message::Binary(b) => {
|
||||
if let Some(json) = gunzip_to_string(&b) {
|
||||
if let Ok(payload) = parse_any_payload(&json) {
|
||||
match payload {
|
||||
Payload::Metrics(_m) => {
|
||||
// update your app state with fast metrics
|
||||
}
|
||||
Payload::Disks(_d) => {
|
||||
// update your app state with disks
|
||||
}
|
||||
Payload::Processes(_p) => {
|
||||
// update your app state with processes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Text(s) => {
|
||||
if let Ok(payload) = parse_any_payload(&s) {
|
||||
match payload {
|
||||
Payload::Metrics(_m) => {}
|
||||
Payload::Disks(_d) => {}
|
||||
Payload::Processes(_p) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! CLI arg parsing tests for socktop (client)
|
||||
use std::process::Command;
|
||||
|
||||
// We test the parsing by invoking the binary with --help and ensuring the help mentions short and long flags.
|
||||
// Also directly test the parse_args function via a tiny helper in a doctest-like fashion using a small
|
||||
// reimplementation here kept in sync with main (compile-time test).
|
||||
|
||||
#[test]
|
||||
fn test_help_mentions_short_and_long_flags() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_socktop"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.expect("run socktop --help");
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.contains("--tls-ca")
|
||||
&& text.contains("-t")
|
||||
&& text.contains("--profile")
|
||||
&& text.contains("-P"),
|
||||
"help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tlc_ca_arg_long_and_short_parsed() {
|
||||
// Use --help combined with flags to avoid network and still exercise arg acceptance
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
// Long form with help
|
||||
let out = Command::new(exe)
|
||||
.args(["--tls-ca", "/tmp/cert.pem", "--help"])
|
||||
.output()
|
||||
.expect("run socktop");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"socktop --tls-ca … --help did not succeed"
|
||||
);
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(text.contains("Usage:"));
|
||||
// Short form with help
|
||||
let out2 = Command::new(exe)
|
||||
.args(["-t", "/tmp/cert.pem", "--help"])
|
||||
.output()
|
||||
.expect("run socktop");
|
||||
assert!(out2.status.success(), "socktop -t … --help did not succeed");
|
||||
let text2 = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out2.stdout),
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
assert!(text2.contains("Usage:"));
|
||||
|
||||
// Profile flags with help (should not error)
|
||||
let out3 = Command::new(exe)
|
||||
.args(["--profile", "dev", "--help"])
|
||||
.output()
|
||||
.expect("run socktop");
|
||||
assert!(
|
||||
out3.status.success(),
|
||||
"socktop --profile dev --help did not succeed"
|
||||
);
|
||||
let text3 = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out3.stdout),
|
||||
String::from_utf8_lossy(&out3.stderr)
|
||||
);
|
||||
assert!(text3.contains("Usage:"));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Tests for profile load/save and resolution logic (non-interactive paths only)
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Global lock to serialize tests that mutate process-wide environment variables.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[allow(dead_code)] // touch crate
|
||||
fn touch() {
|
||||
let _ = socktop::types::Metrics {
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 0,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
process_count: None,
|
||||
hostname: String::new(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
};
|
||||
}
|
||||
|
||||
// We re-import internal modules by copying minimal logic here because profiles.rs isn't public.
|
||||
// Instead of exposing internals, we simulate profile saving through CLI invocations.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn run_socktop(args: &[&str]) -> (bool, String) {
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let output = Command::new(exe).args(args).output().expect("run socktop");
|
||||
let ok = output.status.success();
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
(ok, text)
|
||||
}
|
||||
|
||||
fn config_dir() -> std::path::PathBuf {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
std::path::PathBuf::from(xdg).join("socktop")
|
||||
} else {
|
||||
dirs_next::config_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("socktop")
|
||||
}
|
||||
}
|
||||
|
||||
fn profiles_path() -> std::path::PathBuf {
|
||||
config_dir().join("profiles.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_created_on_first_use() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
// Isolate config in a temp dir
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||
}
|
||||
// Ensure directory exists fresh
|
||||
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||
let _ = fs::remove_file(profiles_path());
|
||||
// Provide profile + url => should create profiles.json
|
||||
let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]);
|
||||
// We pass --help to exit early after parsing (no network attempt)
|
||||
let data = fs::read_to_string(profiles_path()).expect("profiles.json created");
|
||||
assert!(
|
||||
data.contains("unittest"),
|
||||
"profiles.json missing profile entry: {data}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_overwrite_only_when_changed() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||
}
|
||||
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||
let _ = fs::remove_file(profiles_path());
|
||||
// Initial create
|
||||
let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create
|
||||
let first = fs::read_to_string(profiles_path()).unwrap();
|
||||
// Re-run identical (should not duplicate or corrupt)
|
||||
let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical
|
||||
let second = fs::read_to_string(profiles_path()).unwrap();
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"Profile file changed despite identical input"
|
||||
);
|
||||
// Overwrite with different URL using --save (no prompt path)
|
||||
let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]);
|
||||
let third = fs::read_to_string(profiles_path()).unwrap();
|
||||
assert!(third.contains("two"), "Updated URL not written: {third}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_tls_ca_persisted() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||
}
|
||||
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||
let _ = fs::remove_file(profiles_path());
|
||||
let (_ok, _out) = run_socktop(&[
|
||||
"--profile",
|
||||
"secureX",
|
||||
"--tls-ca",
|
||||
"/tmp/cert.pem",
|
||||
"wss://host/ws",
|
||||
"--dry-run",
|
||||
]);
|
||||
let data = fs::read_to_string(profiles_path()).unwrap();
|
||||
assert!(data.contains("secureX"));
|
||||
assert!(data.contains("cert.pem"));
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "socktop_agent"
|
||||
version = "0.1.1"
|
||||
version = "1.40.70"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
description = "Remote system monitor over WebSocket, TUI like top"
|
||||
edition = "2021"
|
||||
description = "Socktop agent daemon. Serves host metrics over WebSocket."
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -13,11 +14,26 @@ sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3.31"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
nvml-wrapper = "0.10"
|
||||
# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now)
|
||||
gfxinfo = "0.1.2"
|
||||
tungstenite = "0.27.0"
|
||||
once_cell = "1.19"
|
||||
once_cell = "1.19"
|
||||
axum-server = { version = "0.6", features = ["tls-rustls"] }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2.1"
|
||||
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build)
|
||||
anyhow = "1"
|
||||
hostname = "0.3"
|
||||
prost = { workspace = true }
|
||||
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
tonic-build = { version = "0.12", default-features = false, optional = true }
|
||||
protoc-bin-vendored = "3"
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3.10"
|
||||
tokio-tungstenite = "0.21"
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
# socktop_agent (server)
|
||||
|
||||
Lightweight on‑demand metrics WebSocket server for the socktop TUI.
|
||||
|
||||
Highlights:
|
||||
- Collects system metrics only when requested (keeps idle CPU <1%)
|
||||
- Optional TLS (self‑signed cert auto‑generated & pinned by client)
|
||||
- JSON for fast metrics / disks; protobuf (optionally gzipped) for processes
|
||||
- Accurate per‑process CPU% on Linux via /proc jiffies delta
|
||||
- Optional GPU & temperature metrics (disable via env vars)
|
||||
- Simple token auth (?token=...) support
|
||||
|
||||
Run (no TLS):
|
||||
```
|
||||
cargo install socktop_agent
|
||||
socktop_agent --port 3000
|
||||
```
|
||||
Enable TLS:
|
||||
```
|
||||
SOCKTOP_ENABLE_SSL=1 socktop_agent --port 8443
|
||||
# cert/key stored under $XDG_DATA_HOME/socktop_agent/tls
|
||||
```
|
||||
Environment toggles:
|
||||
- SOCKTOP_AGENT_GPU=0 (disable GPU collection)
|
||||
- SOCKTOP_AGENT_TEMP=0 (disable temperature)
|
||||
- SOCKTOP_TOKEN=secret (require token param from client)
|
||||
- SOCKTOP_AGENT_METRICS_TTL_MS=250 (cache fast metrics window)
|
||||
- SOCKTOP_AGENT_PROCESSES_TTL_MS=1000
|
||||
- SOCKTOP_AGENT_DISKS_TTL_MS=1000
|
||||
|
||||
*NOTE ON ENV vars*
|
||||
|
||||
Generally these have been added for debugging purposes. you do not need to configure them, default values are tuned and GPU will deisable itself after the first poll if not available.
|
||||
|
||||
Systemd unit example & full docs:
|
||||
https://github.com/jasonwitty/socktop
|
||||
|
||||
## WebSocket API Integration Guide
|
||||
|
||||
The socktop_agent exposes a WebSocket API that can be directly integrated with your own applications. This allows you to build custom monitoring dashboards or analysis tools using the agent's metrics.
|
||||
|
||||
### WebSocket Endpoint
|
||||
|
||||
```
|
||||
ws://HOST:PORT/ws # Without TLS
|
||||
wss://HOST:PORT/ws # With TLS
|
||||
```
|
||||
|
||||
With authentication token (if configured):
|
||||
```
|
||||
ws://HOST:PORT/ws?token=YOUR_TOKEN
|
||||
wss://HOST:PORT/ws?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
### Communication Protocol
|
||||
|
||||
All communication uses JSON format for requests and responses, except for the process list which uses Protocol Buffers (protobuf) format with optional gzip compression.
|
||||
|
||||
#### Request Types
|
||||
|
||||
Send a JSON message with a `type` field to request specific metrics:
|
||||
|
||||
```json
|
||||
{"type": "metrics"} // Request fast-changing metrics (CPU, memory, network)
|
||||
{"type": "disks"} // Request disk information
|
||||
{"type": "processes"} // Request process list (returns protobuf)
|
||||
```
|
||||
|
||||
#### Response Formats
|
||||
|
||||
1. **Fast Metrics** (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"cpu_total": 12.4,
|
||||
"cpu_per_core": [11.2, 15.7],
|
||||
"mem_total": 33554432,
|
||||
"mem_used": 18321408,
|
||||
"swap_total": 0,
|
||||
"swap_used": 0,
|
||||
"hostname": "myserver",
|
||||
"cpu_temp_c": 42.5,
|
||||
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
|
||||
"gpus": [{"name":"nvidia-0","usage":56.7,"memory_total":8589934592,"memory_used":1073741824,"temp_c":65.0}]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Disks** (JSON):
|
||||
|
||||
```json
|
||||
[
|
||||
{"name":"nvme0n1p2","total":512000000000,"available":320000000000},
|
||||
{"name":"sda1","total":1000000000000,"available":750000000000}
|
||||
]
|
||||
```
|
||||
|
||||
3. **Processes** (Protocol Buffers):
|
||||
|
||||
Processes are returned in Protocol Buffers format, optionally gzip-compressed for large process lists. The protobuf schema is:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3;
|
||||
uint64 mem_bytes = 4;
|
||||
}
|
||||
|
||||
message ProcessList {
|
||||
uint32 process_count = 1;
|
||||
repeated Process processes = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Integration (JavaScript/Node.js)
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// Connect to the agent
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
|
||||
ws.on('open', function open() {
|
||||
console.log('Connected to socktop_agent');
|
||||
|
||||
// Request metrics immediately on connection
|
||||
ws.send(JSON.stringify({type: 'metrics'}));
|
||||
|
||||
// Set up regular polling
|
||||
setInterval(() => {
|
||||
ws.send(JSON.stringify({type: 'metrics'}));
|
||||
}, 1000);
|
||||
|
||||
// Request processes every 3 seconds
|
||||
setInterval(() => {
|
||||
ws.send(JSON.stringify({type: 'processes'}));
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
ws.on('message', function incoming(data) {
|
||||
// Check if the response is JSON or binary (protobuf)
|
||||
try {
|
||||
const jsonData = JSON.parse(data);
|
||||
console.log('Received JSON data:', jsonData);
|
||||
} catch (e) {
|
||||
console.log('Received binary data (protobuf), length:', data.length);
|
||||
// Process binary protobuf data with a library like protobufjs
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', function close() {
|
||||
console.log('Disconnected from socktop_agent');
|
||||
});
|
||||
```
|
||||
|
||||
### Example Integration (Python)
|
||||
|
||||
```python
|
||||
import json
|
||||
import asyncio
|
||||
import websockets
|
||||
|
||||
async def monitor_system():
|
||||
uri = "ws://localhost:3000/ws"
|
||||
async with websockets.connect(uri) as websocket:
|
||||
print("Connected to socktop_agent")
|
||||
|
||||
# Request initial metrics
|
||||
await websocket.send(json.dumps({"type": "metrics"}))
|
||||
|
||||
# Set up regular polling
|
||||
while True:
|
||||
# Request metrics
|
||||
await websocket.send(json.dumps({"type": "metrics"}))
|
||||
|
||||
# Receive and process response
|
||||
response = await websocket.recv()
|
||||
|
||||
# Check if response is JSON or binary (protobuf)
|
||||
try:
|
||||
data = json.loads(response)
|
||||
print(f"CPU: {data['cpu_total']}%, Memory: {data['mem_used']/data['mem_total']*100:.1f}%")
|
||||
except json.JSONDecodeError:
|
||||
print(f"Received binary data, length: {len(response)}")
|
||||
# Process binary protobuf data with a library like protobuf
|
||||
|
||||
# Wait before next poll
|
||||
await asyncio.sleep(1)
|
||||
|
||||
asyncio.run(monitor_system())
|
||||
```
|
||||
|
||||
### Notes for Integration
|
||||
|
||||
1. **Error Handling**: The WebSocket connection may close unexpectedly; implement reconnection logic in your client.
|
||||
|
||||
2. **Rate Limiting**: Avoid excessive polling that could impact the system being monitored. Recommended intervals:
|
||||
- Metrics: 500ms or slower
|
||||
- Processes: 2000ms or slower
|
||||
- Disks: 5000ms or slower
|
||||
|
||||
3. **Authentication**: If the agent is configured with a token, always include it in the WebSocket URL.
|
||||
|
||||
4. **Protocol Buffers Handling**: For processing the binary process list data, use a Protocol Buffers library for your language and the schema provided in the `proto/processes.proto` file.
|
||||
|
||||
5. **Compression**: Process lists may be gzip-compressed. Check if the response starts with the gzip magic bytes (`0x1f, 0x8b`) and decompress if necessary.
|
||||
|
||||
## LLM Integration Guide
|
||||
|
||||
If you're using an LLM to generate code for integrating with socktop_agent, this section provides structured information to help the model understand the API better.
|
||||
|
||||
### API Schema
|
||||
|
||||
```yaml
|
||||
# WebSocket API Schema for socktop_agent
|
||||
endpoint: ws://HOST:PORT/ws or wss://HOST:PORT/ws (with TLS)
|
||||
authentication:
|
||||
type: query parameter
|
||||
parameter: token
|
||||
example: ws://HOST:PORT/ws?token=YOUR_TOKEN
|
||||
|
||||
requests:
|
||||
- type: metrics
|
||||
format: JSON
|
||||
example: {"type": "metrics"}
|
||||
description: Fast-changing metrics (CPU, memory, network)
|
||||
|
||||
- type: disks
|
||||
format: JSON
|
||||
example: {"type": "disks"}
|
||||
description: Disk information
|
||||
|
||||
- type: processes
|
||||
format: JSON
|
||||
example: {"type": "processes"}
|
||||
description: Process list (returns protobuf)
|
||||
|
||||
responses:
|
||||
- request_type: metrics
|
||||
format: JSON
|
||||
schema:
|
||||
cpu_total: float # percentage of total CPU usage
|
||||
cpu_per_core: [float] # array of per-core CPU usage percentages
|
||||
mem_total: uint64 # total memory in bytes
|
||||
mem_used: uint64 # used memory in bytes
|
||||
swap_total: uint64 # total swap in bytes
|
||||
swap_used: uint64 # used swap in bytes
|
||||
hostname: string # system hostname
|
||||
cpu_temp_c: float? # CPU temperature in Celsius (optional)
|
||||
networks: [
|
||||
{
|
||||
name: string # network interface name
|
||||
received: uint64 # total bytes received
|
||||
transmitted: uint64 # total bytes transmitted
|
||||
}
|
||||
]
|
||||
gpus: [
|
||||
{
|
||||
name: string # GPU device name
|
||||
usage: float # GPU usage percentage
|
||||
memory_total: uint64 # total GPU memory in bytes
|
||||
memory_used: uint64 # used GPU memory in bytes
|
||||
temp_c: float # GPU temperature in Celsius
|
||||
}
|
||||
]?
|
||||
|
||||
- request_type: disks
|
||||
format: JSON
|
||||
schema:
|
||||
[
|
||||
{
|
||||
name: string # disk name
|
||||
total: uint64 # total space in bytes
|
||||
available: uint64 # available space in bytes
|
||||
}
|
||||
]
|
||||
|
||||
- request_type: processes
|
||||
format: Protocol Buffers (optionally gzip-compressed)
|
||||
schema: See protobuf definition below
|
||||
```
|
||||
|
||||
### Protobuf Schema (processes.proto)
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3;
|
||||
uint64 mem_bytes = 4;
|
||||
}
|
||||
|
||||
message ProcessList {
|
||||
uint32 process_count = 1;
|
||||
repeated Process processes = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Step-by-Step Integration Pseudocode
|
||||
|
||||
```
|
||||
1. Establish WebSocket connection to ws://HOST:PORT/ws
|
||||
- Add token if required: ws://HOST:PORT/ws?token=YOUR_TOKEN
|
||||
|
||||
2. For regular metrics updates:
|
||||
- Send: {"type": "metrics"}
|
||||
- Parse JSON response
|
||||
- Extract CPU, memory, network info
|
||||
|
||||
3. For disk information:
|
||||
- Send: {"type": "disks"}
|
||||
- Parse JSON response
|
||||
- Extract disk usage data
|
||||
|
||||
4. For process list:
|
||||
- Send: {"type": "processes"}
|
||||
- Check if response is binary
|
||||
- If starts with 0x1f, 0x8b bytes:
|
||||
- Decompress using gzip
|
||||
- Parse binary data using protobuf schema
|
||||
- Extract process information
|
||||
|
||||
5. Implement reconnection logic:
|
||||
- On connection close/error
|
||||
- Use exponential backoff
|
||||
|
||||
6. Respect rate limits:
|
||||
- metrics: ≥ 500ms interval
|
||||
- disks: ≥ 5000ms interval
|
||||
- processes: ≥ 2000ms interval
|
||||
```
|
||||
|
||||
### Common Implementation Patterns
|
||||
|
||||
**Pattern 1: Periodic Polling**
|
||||
```javascript
|
||||
// Set up separate timers for different metric types
|
||||
const metricsInterval = setInterval(() => ws.send(JSON.stringify({type: 'metrics'})), 500);
|
||||
const disksInterval = setInterval(() => ws.send(JSON.stringify({type: 'disks'})), 5000);
|
||||
const processesInterval = setInterval(() => ws.send(JSON.stringify({type: 'processes'})), 2000);
|
||||
|
||||
// Clean up on disconnect
|
||||
ws.on('close', () => {
|
||||
clearInterval(metricsInterval);
|
||||
clearInterval(disksInterval);
|
||||
clearInterval(processesInterval);
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Processing Binary Protobuf Data**
|
||||
```javascript
|
||||
// Using protobufjs
|
||||
const root = protobuf.loadSync('processes.proto');
|
||||
const ProcessList = root.lookupType('ProcessList');
|
||||
|
||||
ws.on('message', function(data) {
|
||||
if (typeof data !== 'string') {
|
||||
// Check for gzip compression
|
||||
if (data[0] === 0x1f && data[1] === 0x8b) {
|
||||
data = gunzipSync(data); // Use appropriate decompression library
|
||||
}
|
||||
|
||||
// Decode protobuf
|
||||
const processes = ProcessList.decode(new Uint8Array(data));
|
||||
console.log(`Total processes: ${processes.process_count}`);
|
||||
processes.processes.forEach(p => {
|
||||
console.log(`PID: ${p.pid}, Name: ${p.name}, CPU: ${p.cpu_usage}%`);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Reconnection Logic**
|
||||
```javascript
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected');
|
||||
// Start polling
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Connection lost, reconnecting...');
|
||||
setTimeout(connect, 1000); // Reconnect after 1 second
|
||||
});
|
||||
|
||||
// Handle other events...
|
||||
}
|
||||
|
||||
connect();
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
fn main() {
|
||||
// Vendored protoc for reproducible builds
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||
|
||||
println!("cargo:rerun-if-changed=proto/processes.proto");
|
||||
|
||||
// Compile protobuf definitions for processes
|
||||
let mut cfg = prost_build::Config::new();
|
||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||
cfg.protoc_executable(protoc); // Use the vendored protoc directly
|
||||
// Use local path (ensures file is inside published crate tarball)
|
||||
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
|
||||
.expect("compile protos");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
+69
-77
@@ -1,99 +1,91 @@
|
||||
//! socktop agent entrypoint: sets up sysinfo handles, launches a sampler,
|
||||
//! and serves a WebSocket endpoint at /ws.
|
||||
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
|
||||
|
||||
mod gpu;
|
||||
mod metrics;
|
||||
mod sampler;
|
||||
mod proto;
|
||||
// sampler module removed (metrics now purely request-driven)
|
||||
mod state;
|
||||
mod types;
|
||||
mod ws;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use axum::{Router, http::StatusCode, routing::get};
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
mod tls;
|
||||
|
||||
use crate::sampler::{spawn_disks_sampler, spawn_process_sampler, spawn_sampler};
|
||||
use state::AppState;
|
||||
use ws::ws_handler;
|
||||
|
||||
fn arg_flag(name: &str) -> bool {
|
||||
std::env::args().any(|a| a == name)
|
||||
}
|
||||
fn arg_value(name: &str) -> Option<String> {
|
||||
let mut it = std::env::args();
|
||||
while let Some(a) = it.next() {
|
||||
if a == name {
|
||||
return it.next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Version flag (print and exit). Keep before heavy initialization.
|
||||
if arg_flag("--version") || arg_flag("-V") {
|
||||
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let state = AppState::new();
|
||||
|
||||
// Start background sampler (adjust cadence as needed)
|
||||
// 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));
|
||||
// No background samplers: metrics collected on-demand per websocket request.
|
||||
|
||||
// Web app
|
||||
let port = resolve_port();
|
||||
// Web app: route /ws to the websocket handler
|
||||
async fn healthz() -> StatusCode {
|
||||
println!("/healthz request");
|
||||
StatusCode::OK
|
||||
}
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.with_state(state);
|
||||
.route("/ws", get(ws::ws_handler))
|
||||
.route("/healthz", get(healthz))
|
||||
.with_state(state.clone());
|
||||
|
||||
let enable_ssl =
|
||||
arg_flag("--enableSSL") || std::env::var("SOCKTOP_ENABLE_SSL").ok().as_deref() == Some("1");
|
||||
if enable_ssl {
|
||||
// Port can be overridden by --port or SOCKTOP_PORT; default to 8443 when SSL
|
||||
let port = arg_value("--port")
|
||||
.or_else(|| arg_value("-p"))
|
||||
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(8443);
|
||||
|
||||
let (cert_path, key_path) = tls::ensure_self_signed_cert()?;
|
||||
let cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
|
||||
|
||||
let addr = SocketAddr::from_str(&format!("0.0.0.0:{port}"))?;
|
||||
println!("socktop_agent: TLS enabled. Listening on wss://{addr}/ws");
|
||||
axum_server::bind_rustls(addr, cfg)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Non-TLS HTTP/WS path
|
||||
let port = arg_value("--port")
|
||||
.or_else(|| arg_value("-p"))
|
||||
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(3000);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
|
||||
//output to console
|
||||
println!("Remote agent running at http://{addr}");
|
||||
println!("WebSocket endpoint: ws://{addr}/ws");
|
||||
|
||||
//trace logging
|
||||
tracing::info!("Remote agent running at http://{} (ws at /ws)", addr);
|
||||
tracing::info!("WebSocket endpoint: ws://{}/ws", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
println!("socktop_agent: Listening on ws://{addr}/ws");
|
||||
axum_server::bind(addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Resolve the listening port from CLI args/env with a 3000 default.
|
||||
// Supports: --port <PORT>, -p <PORT>, a bare numeric positional arg, or SOCKTOP_PORT.
|
||||
fn resolve_port() -> u16 {
|
||||
const DEFAULT: u16 = 3000;
|
||||
|
||||
// Env takes precedence over positional, but is overridden by explicit flags if present.
|
||||
if let Ok(s) = std::env::var("SOCKTOP_PORT") {
|
||||
if let Ok(p) = s.parse::<u16>() {
|
||||
if p != 0 {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
eprintln!("Warning: invalid SOCKTOP_PORT='{s}'; using default {DEFAULT}");
|
||||
}
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--port" | "-p" => {
|
||||
if let Some(v) = args.next() {
|
||||
match v.parse::<u16>() {
|
||||
Ok(p) if p != 0 => return p,
|
||||
_ => {
|
||||
eprintln!("Invalid port '{v}'; using default {DEFAULT}");
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Missing value for {arg} ; using default {DEFAULT}");
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("Usage: socktop_agent [--port <PORT>] [PORT]\n SOCKTOP_PORT=<PORT> socktop_agent");
|
||||
std::process::exit(0);
|
||||
}
|
||||
s => {
|
||||
if let Ok(p) = s.parse::<u16>() {
|
||||
if p != 0 {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT
|
||||
}
|
||||
// Unit tests for CLI parsing moved to `tests/port_parse.rs`.
|
||||
|
||||
+290
-66
@@ -4,14 +4,19 @@ use crate::gpu::collect_all_gpus;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload};
|
||||
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 as StdDuration;
|
||||
use std::time::{Duration, Instant};
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
|
||||
use tracing::warn;
|
||||
|
||||
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
|
||||
// Runtime toggles (read once)
|
||||
fn gpu_enabled() -> bool {
|
||||
static ON: OnceCell<bool> = OnceCell::new();
|
||||
@@ -44,6 +49,15 @@ struct GpuCache {
|
||||
}
|
||||
static GPUC: OnceCell<Mutex<GpuCache>> = OnceCell::new();
|
||||
|
||||
// Static caches for unchanging data
|
||||
static HOSTNAME: OnceCell<String> = OnceCell::new();
|
||||
struct NetworkNameCache {
|
||||
names: Vec<String>,
|
||||
infos: Vec<NetworkInfo>,
|
||||
}
|
||||
static NETWORK_CACHE: OnceCell<Mutex<NetworkNameCache>> = OnceCell::new();
|
||||
static CPU_VEC: OnceCell<Mutex<Vec<f32>>> = OnceCell::new();
|
||||
|
||||
fn cached_temp() -> Option<f32> {
|
||||
if !temp_enabled() {
|
||||
return None;
|
||||
@@ -60,11 +74,11 @@ fn cached_temp() -> Option<f32> {
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if let Some(lock) = TEMP.get()
|
||||
&& let Ok(mut c) = lock.lock()
|
||||
{
|
||||
c.v = v;
|
||||
c.at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +98,30 @@ fn cached_gpus() -> Option<Vec<crate::gpu::GpuMetrics>> {
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if let Some(lock) = GPUC.get()
|
||||
&& 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 {
|
||||
// TTL (ms) overridable via env, default 250ms
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(250);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_metrics.lock().await;
|
||||
if cache.is_fresh(ttl)
|
||||
&& let Some(c) = cache.get()
|
||||
{
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
let mut sys = state.sys.lock().await;
|
||||
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sys.refresh_cpu_usage();
|
||||
@@ -102,9 +130,19 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
warn!("sysinfo selective refresh panicked: {e:?}");
|
||||
}
|
||||
|
||||
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
|
||||
// Get or initialize hostname once
|
||||
let hostname = HOSTNAME.get_or_init(|| state.hostname.clone()).clone();
|
||||
|
||||
// Reuse CPU vector to avoid allocation
|
||||
let cpu_total = sys.global_cpu_usage();
|
||||
let cpu_per_core: Vec<f32> = sys.cpus().iter().map(|c| c.cpu_usage()).collect();
|
||||
let cpu_per_core = {
|
||||
let vec_lock = CPU_VEC.get_or_init(|| Mutex::new(Vec::with_capacity(32)));
|
||||
let mut vec = vec_lock.lock().unwrap();
|
||||
vec.clear();
|
||||
vec.extend(sys.cpus().iter().map(|c| c.cpu_usage()));
|
||||
vec.clone() // Still need to clone but the allocation is reused
|
||||
};
|
||||
|
||||
let mem_total = sys.total_memory();
|
||||
let mem_used = mem_total.saturating_sub(sys.available_memory());
|
||||
let swap_total = sys.total_swap();
|
||||
@@ -137,38 +175,80 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
None
|
||||
};
|
||||
|
||||
// Networks
|
||||
let networks: Vec<NetworkInfo> = {
|
||||
// Networks with reusable name cache
|
||||
let networks = {
|
||||
let mut nets = state.networks.lock().await;
|
||||
nets.refresh(false);
|
||||
nets.iter()
|
||||
.map(|(name, data)| NetworkInfo {
|
||||
name: name.to_string(),
|
||||
|
||||
// Get or initialize network cache
|
||||
let cache = NETWORK_CACHE.get_or_init(|| {
|
||||
Mutex::new(NetworkNameCache {
|
||||
names: Vec::new(),
|
||||
infos: Vec::with_capacity(4), // Most systems have few network interfaces
|
||||
})
|
||||
});
|
||||
let mut cache = cache.lock().unwrap();
|
||||
|
||||
// Collect current network names
|
||||
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
|
||||
|
||||
// Update cached network names if they changed
|
||||
if cache.names != current_names {
|
||||
cache.names = current_names;
|
||||
}
|
||||
|
||||
// Reuse NetworkInfo objects
|
||||
cache.infos.clear();
|
||||
for (name, data) in nets.iter() {
|
||||
cache.infos.push(NetworkInfo {
|
||||
name: name.to_string(), // We'll still clone but avoid Vec reallocation
|
||||
received: data.total_received(),
|
||||
transmitted: data.total_transmitted(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
cache.infos.clone()
|
||||
};
|
||||
|
||||
// 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
|
||||
// GPUs: if we already determined none exist, short-circuit (no repeated probing)
|
||||
let gpus = if gpu_enabled() {
|
||||
if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
None
|
||||
} else if cached_gpus().is_some() {
|
||||
cached_gpus()
|
||||
} else {
|
||||
let v = match collect_all_gpus() {
|
||||
Ok(v) if !v.is_empty() => Some(v),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("gpu collection failed: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
// First probe records presence; subsequent calls rely on cache flags.
|
||||
if !state
|
||||
.gpu_checked
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
if v.is_some() {
|
||||
state
|
||||
.gpu_present
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
} else {
|
||||
state
|
||||
.gpu_present
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
};
|
||||
set_gpus(v.clone());
|
||||
v
|
||||
set_gpus(v.clone());
|
||||
v
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Metrics {
|
||||
let metrics = Metrics {
|
||||
cpu_total,
|
||||
cpu_per_core,
|
||||
mem_total,
|
||||
@@ -181,23 +261,48 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
networks,
|
||||
top_processes: Vec::new(),
|
||||
gpus,
|
||||
};
|
||||
{
|
||||
let mut cache = state.cache_metrics.lock().await;
|
||||
cache.set(metrics.clone());
|
||||
}
|
||||
metrics
|
||||
}
|
||||
|
||||
// Cached disks
|
||||
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1_000);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_disks.lock().await;
|
||||
if cache.is_fresh(ttl)
|
||||
&& let Some(v) = cache.get()
|
||||
{
|
||||
return v.clone();
|
||||
}
|
||||
}
|
||||
let mut disks_list = state.disks.lock().await;
|
||||
disks_list.refresh(false); // don't drop missing disks
|
||||
disks_list
|
||||
let disks: Vec<DiskInfo> = disks_list
|
||||
.iter()
|
||||
.map(|d| DiskInfo {
|
||||
name: d.name().to_string_lossy().into_owned(),
|
||||
total: d.total_space(),
|
||||
available: d.available_space(),
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
{
|
||||
let mut cache = state.cache_disks.lock().await;
|
||||
cache.set(disks.clone());
|
||||
}
|
||||
disks
|
||||
}
|
||||
|
||||
// Linux-only helpers and implementation using /proc deltas for accurate CPU%.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[inline]
|
||||
fn read_total_jiffies() -> io::Result<u64> {
|
||||
// /proc/stat first line: "cpu user nice system idle iowait irq softirq steal ..."
|
||||
@@ -216,6 +321,7 @@ fn read_total_jiffies() -> io::Result<u64> {
|
||||
Err(io::Error::other("no cpu line"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[inline]
|
||||
fn read_proc_jiffies(pid: u32) -> Option<u64> {
|
||||
let path = format!("/proc/{pid}/stat");
|
||||
@@ -230,12 +336,26 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
|
||||
Some(utime.saturating_add(stime))
|
||||
}
|
||||
|
||||
// Replace the body of collect_processes_top_k to use /proc deltas.
|
||||
// This makes CPU% = (delta_proc / delta_total) * 100 over the 2s interval.
|
||||
pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPayload {
|
||||
// Fresh view to avoid lingering entries and select "no tasks" (no per-thread rows).
|
||||
// Only processes, no per-thread entries.
|
||||
let mut sys = System::new();
|
||||
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
// Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates).
|
||||
.unwrap_or(1_500);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_processes.lock().await;
|
||||
if cache.is_fresh(ttl)
|
||||
&& let Some(c) = cache.get()
|
||||
{
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
// Reuse shared System to avoid reallocation; refresh processes fully.
|
||||
let mut sys_guard = state.sys.lock().await;
|
||||
let sys = &mut *sys_guard;
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::All,
|
||||
false,
|
||||
@@ -256,12 +376,20 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
|
||||
// Compute deltas vs last sample
|
||||
let (last_total, mut last_map) = {
|
||||
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(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
|
||||
@@ -278,7 +406,7 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
.collect();
|
||||
return ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: top_k_sorted(procs, k),
|
||||
top_processes: procs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,26 +430,122 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
})
|
||||
.collect();
|
||||
|
||||
ProcessesPayload {
|
||||
let payload = ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: top_k_sorted(procs, k),
|
||||
top_processes: procs,
|
||||
};
|
||||
{
|
||||
let mut cache = state.cache_processes.lock().await;
|
||||
cache.set(payload.clone());
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
// Small helper to select and sort top-k by cpu
|
||||
fn top_k_sorted(mut v: Vec<ProcessInfo>, k: usize) -> Vec<ProcessInfo> {
|
||||
if v.len() > k {
|
||||
v.select_nth_unstable_by(k, |a, b| {
|
||||
b.cpu_usage
|
||||
.partial_cmp(&a.cpu_usage)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
v.truncate(k);
|
||||
/// Collect all processes (non-Linux): optimized for reduced allocations and selective updates.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
// Serve from cache if fresh
|
||||
{
|
||||
let cache = state.cache_processes.lock().await;
|
||||
if cache.is_fresh(StdDuration::from_millis(2_000)) {
|
||||
// Use fixed TTL for cache check
|
||||
if let Some(c) = cache.get() {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| {
|
||||
b.cpu_usage
|
||||
.partial_cmp(&a.cpu_usage)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
v
|
||||
|
||||
// Single efficient refresh with optimized CPU collection
|
||||
let (total_count, procs) = {
|
||||
let mut sys = state.sys.lock().await;
|
||||
let kind = ProcessRefreshKind::nothing().with_memory();
|
||||
|
||||
// Optimize refresh strategy based on system load
|
||||
//if load > 5.0 {
|
||||
|
||||
//JW too complicated. simplify to remove strange behavior
|
||||
|
||||
// For active systems, get accurate CPU metrics
|
||||
sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind.with_cpu());
|
||||
|
||||
// } else {
|
||||
// // For idle systems, just get basic process info
|
||||
// sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind);
|
||||
// sys.refresh_cpu_usage();
|
||||
// }
|
||||
|
||||
let total_count = sys.processes().len();
|
||||
let cpu_count = sys.cpus().len() as f32;
|
||||
|
||||
// Reuse allocations via process cache
|
||||
let mut proc_cache = state.proc_cache.lock().await;
|
||||
proc_cache.reusable_vec.clear();
|
||||
|
||||
// Collect all processes, will sort by CPU later
|
||||
for p in sys.processes().values() {
|
||||
let pid = p.pid().as_u32();
|
||||
|
||||
// Reuse cached name if available
|
||||
let name = if let Some(cached) = proc_cache.names.get(&pid) {
|
||||
cached.clone()
|
||||
} else {
|
||||
let new_name = p.name().to_string_lossy().into_owned();
|
||||
proc_cache.names.insert(pid, new_name.clone());
|
||||
new_name
|
||||
};
|
||||
|
||||
// Convert to percentage of total CPU capacity
|
||||
// e.g., 100% on 2 cores of 8 core system = 25% total CPU
|
||||
let raw = p.cpu_usage(); // This is per-core percentage
|
||||
let total_cpu = raw.clamp(0.0, 100.0) / cpu_count;
|
||||
|
||||
proc_cache.reusable_vec.push(ProcessInfo {
|
||||
pid,
|
||||
name,
|
||||
cpu_usage: total_cpu,
|
||||
mem_bytes: p.memory(),
|
||||
});
|
||||
}
|
||||
|
||||
//JW no need to sort here; client does the sorting
|
||||
|
||||
// // Sort by CPU usage
|
||||
// proc_cache.reusable_vec.sort_by(|a, b| {
|
||||
// b.cpu_usage
|
||||
// .partial_cmp(&a.cpu_usage)
|
||||
// .unwrap_or(std::cmp::Ordering::Equal)
|
||||
// });
|
||||
|
||||
// Clean up old process names cache when it grows too large
|
||||
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
|
||||
|
||||
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
|
||||
let now = std::time::Instant::now();
|
||||
proc_cache
|
||||
.names
|
||||
.retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid)));
|
||||
tracing::debug!(
|
||||
"Cleaned up {} stale process names in {}ms",
|
||||
proc_cache.names.capacity() - proc_cache.names.len(),
|
||||
now.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
// Get all processes, take ownership of the vec (will be replaced with empty vec)
|
||||
(total_count, std::mem::take(&mut proc_cache.reusable_vec))
|
||||
};
|
||||
|
||||
let payload = ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: procs,
|
||||
};
|
||||
|
||||
{
|
||||
let mut cache = state.cache_processes.lock().await;
|
||||
cache.set(payload.clone());
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Metrics {
|
||||
pub ts_unix_ms: i64,
|
||||
pub host: String,
|
||||
pub uptime_secs: u64,
|
||||
pub cpu_overall: f32,
|
||||
pub cpu_per_core: Vec<f32>,
|
||||
pub load_avg: (f64, f64, f64),
|
||||
pub mem_total_mb: u64,
|
||||
pub mem_used_mb: u64,
|
||||
pub swap_total_mb: u64,
|
||||
pub swap_used_mb: u64,
|
||||
pub net_aggregate: NetTotals,
|
||||
pub top_processes: Vec<Proc>,
|
||||
// Generated protobuf modules live under OUT_DIR; include them here.
|
||||
// This module will expose socktop::Processes and socktop::Process types.
|
||||
pub mod pb {
|
||||
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetTotals {
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Proc {
|
||||
pub pid: i32,
|
||||
pub name: String,
|
||||
pub cpu: f32,
|
||||
pub mem_mb: u64,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! Background sampler: periodically collects metrics and updates precompressed caches,
|
||||
//! so WS replies just read and send cached bytes.
|
||||
|
||||
use crate::state::AppState;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// 500ms: fast path (cpu/mem/net/temp/gpu)
|
||||
pub fn spawn_sampler(_state: AppState, _period: Duration) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
// no-op background sampler (request-driven collection elsewhere)
|
||||
loop {
|
||||
sleep(Duration::from_secs(3600)).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Shared agent state: sysinfo handles and hot JSON cache.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use std::time::{Duration, Instant};
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -11,26 +12,82 @@ pub type SharedComponents = Arc<Mutex<Components>>;
|
||||
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>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub struct ProcessCache {
|
||||
pub names: HashMap<u32, String>,
|
||||
pub reusable_vec: Vec<crate::types::ProcessInfo>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl Default for ProcessCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
names: HashMap::with_capacity(1000), // Pre-allocate for typical modern system process count
|
||||
reusable_vec: Vec::with_capacity(1000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub sys: SharedSystem,
|
||||
pub components: SharedComponents,
|
||||
pub disks: SharedDisks,
|
||||
pub networks: SharedNetworks,
|
||||
pub hostname: String,
|
||||
|
||||
// For correct per-process CPU% using /proc deltas
|
||||
// For correct per-process CPU% using /proc deltas (Linux only path uses this tracker)
|
||||
#[cfg(target_os = "linux")]
|
||||
pub proc_cpu: Arc<Mutex<ProcCpuTracker>>,
|
||||
|
||||
// Process name caching and vector reuse for non-Linux to reduce allocations
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub proc_cache: Arc<Mutex<ProcessCache>>,
|
||||
|
||||
// Connection tracking (to allow future idle sleeps if desired)
|
||||
pub client_count: Arc<AtomicUsize>,
|
||||
|
||||
pub auth_token: Option<String>,
|
||||
// GPU negative cache (probe once). gpu_checked=true after first attempt; gpu_present reflects result.
|
||||
pub gpu_checked: Arc<AtomicBool>,
|
||||
pub gpu_present: Arc<AtomicBool>,
|
||||
|
||||
// Lightweight on-demand caches (TTL based) to cap CPU under bursty polling.
|
||||
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
|
||||
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
|
||||
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CacheEntry<T> {
|
||||
pub at: Option<Instant>,
|
||||
pub value: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
at: None,
|
||||
value: None,
|
||||
}
|
||||
}
|
||||
pub fn is_fresh(&self, ttl: Duration) -> bool {
|
||||
self.at.is_some_and(|t| t.elapsed() < ttl) && self.value.is_some()
|
||||
}
|
||||
pub fn set(&mut self, v: T) {
|
||||
self.value = Some(v);
|
||||
self.at = Some(Instant::now());
|
||||
}
|
||||
pub fn get(&self) -> Option<&T> {
|
||||
self.value.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -45,11 +102,20 @@ impl AppState {
|
||||
components: Arc::new(Mutex::new(components)),
|
||||
disks: Arc::new(Mutex::new(disks)),
|
||||
networks: Arc::new(Mutex::new(networks)),
|
||||
hostname: System::host_name().unwrap_or_else(|| "unknown".into()),
|
||||
#[cfg(target_os = "linux")]
|
||||
proc_cpu: Arc::new(Mutex::new(ProcCpuTracker::default())),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
proc_cache: Arc::new(Mutex::new(ProcessCache::default())),
|
||||
client_count: Arc::new(AtomicUsize::new(0)),
|
||||
auth_token: std::env::var("SOCKTOP_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
gpu_checked: Arc::new(AtomicBool::new(false)),
|
||||
gpu_present: Arc::new(AtomicBool::new(false)),
|
||||
cache_metrics: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_disks: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_processes: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, IsCa, SanType};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("socktop_agent")
|
||||
.join("tls")
|
||||
}
|
||||
|
||||
pub fn cert_paths() -> (PathBuf, PathBuf) {
|
||||
let dir = config_dir();
|
||||
(dir.join("cert.pem"), dir.join("key.pem"))
|
||||
}
|
||||
|
||||
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
||||
let (cert_path, key_path) = cert_paths();
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
return Ok((cert_path, key_path));
|
||||
}
|
||||
fs::create_dir_all(cert_path.parent().unwrap())?;
|
||||
|
||||
let hostname = hostname::get()
|
||||
.ok()
|
||||
.and_then(|s| s.into_string().ok())
|
||||
.unwrap_or_else(|| "localhost".to_string());
|
||||
|
||||
let mut params = CertificateParams::new(vec![hostname.clone(), "localhost".into()])?;
|
||||
params
|
||||
.subject_alt_names
|
||||
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V6(
|
||||
::std::net::Ipv6Addr::LOCALHOST,
|
||||
)));
|
||||
params
|
||||
.subject_alt_names
|
||||
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
|
||||
|
||||
// Allow operator to provide extra SANs (comma-separated), e.g. IPs or DNS names
|
||||
if let Ok(extra) = std::env::var("SOCKTOP_AGENT_EXTRA_SANS") {
|
||||
for raw in extra.split(',') {
|
||||
let s = raw.trim();
|
||||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ip) = s.parse::<IpAddr>() {
|
||||
params.subject_alt_names.push(SanType::IpAddress(ip));
|
||||
} else {
|
||||
match s.to_string().try_into() {
|
||||
Ok(dns) => params.subject_alt_names.push(SanType::DnsName(dns)),
|
||||
Err(_) => eprintln!("socktop_agent: ignoring invalid SAN entry: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, hostname.clone());
|
||||
params.distinguished_name = dn;
|
||||
params.is_ca = IsCa::NoCa;
|
||||
// Dynamic validity: start slightly in the past to avoid clock skew issues, end ~397 days later
|
||||
let now = OffsetDateTime::now_utc();
|
||||
params.not_before = now - Duration::minutes(5);
|
||||
params.not_after = now + Duration::days(397);
|
||||
|
||||
// Generate key pair (default is ECDSA P256 SHA256)
|
||||
let key_pair = rcgen::KeyPair::generate()?; // defaults to ECDSA P256 SHA256
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
let cert_pem = cert.pem();
|
||||
let key_pem = key_pair.serialize_pem();
|
||||
|
||||
let mut f = fs::File::create(&cert_path)?;
|
||||
f.write_all(cert_pem.as_bytes())?;
|
||||
let mut k = fs::File::create(&key_path)?;
|
||||
k.write_all(key_pem.as_bytes())?;
|
||||
|
||||
println!(
|
||||
"socktop_agent: generated self-signed TLS certificate at {}",
|
||||
cert_path.display()
|
||||
);
|
||||
println!("socktop_agent: private key at {}", key_path.display());
|
||||
Ok((cert_path, key_path))
|
||||
}
|
||||
+155
-11
@@ -5,26 +5,47 @@ use axum::{
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
response::Response,
|
||||
};
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use futures_util::StreamExt;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_top_k};
|
||||
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all};
|
||||
use crate::proto::pb;
|
||||
use crate::state::AppState;
|
||||
|
||||
// Compression threshold based on typical payload size
|
||||
const COMPRESSION_THRESHOLD: usize = 768;
|
||||
|
||||
// Reusable buffer for compression to avoid allocations
|
||||
struct CompressionCache {
|
||||
processes_vec: Vec<pb::Process>,
|
||||
}
|
||||
|
||||
impl CompressionCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
processes_vec: Vec::with_capacity(512), // Typical process count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static COMPRESSION_CACHE: OnceCell<Mutex<CompressionCache>> = OnceCell::new();
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// optional auth
|
||||
if let Some(expected) = state.auth_token.as_ref() {
|
||||
if q.get("token") != Some(expected) {
|
||||
return ws.on_upgrade(|socket| async move {
|
||||
let _ = socket.close().await;
|
||||
});
|
||||
}
|
||||
if let Some(expected) = state.auth_token.as_ref()
|
||||
&& q.get("token") != Some(expected)
|
||||
{
|
||||
return ws.on_upgrade(|socket| async move {
|
||||
let _ = socket.close().await;
|
||||
});
|
||||
}
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
@@ -44,8 +65,51 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
let _ = send_json(&mut socket, &d).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;
|
||||
let payload = collect_processes_all(&state).await;
|
||||
|
||||
// Map to protobuf message
|
||||
// Get cached buffers
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
// Reuse process vector to build the list
|
||||
cache.processes_vec.clear();
|
||||
cache
|
||||
.processes_vec
|
||||
.extend(payload.top_processes.into_iter().map(|p| pb::Process {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
}));
|
||||
|
||||
let pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows: std::mem::take(&mut cache.processes_vec),
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else {
|
||||
// compress if large
|
||||
if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cache); // Explicit drop to release mutex early
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
@@ -59,7 +123,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
// 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 {
|
||||
if json.len() <= COMPRESSION_THRESHOLD {
|
||||
return ws.send(Message::Text(json)).await;
|
||||
}
|
||||
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
|
||||
@@ -67,3 +131,83 @@ async fn send_json<T: serde::Serialize>(ws: &mut WebSocket, value: &T) -> Result
|
||||
let bin = enc.finish().unwrap_or_else(|_| json.into_bytes());
|
||||
ws.send(Message::Binary(bin)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use prost::Message as ProstMessage;
|
||||
use sysinfo::System;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_list_not_empty() {
|
||||
// Initialize system data first to ensure we have processes
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
// Create state and put the refreshed system in it
|
||||
let state = AppState::new();
|
||||
{
|
||||
let mut sys_lock = state.sys.lock().await;
|
||||
*sys_lock = sys;
|
||||
}
|
||||
|
||||
// Get processes directly using the collection function
|
||||
let processes = collect_processes_all(&state).await;
|
||||
|
||||
// Convert to protobuf message format
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
// Reuse process vector to build the list
|
||||
cache.processes_vec.clear();
|
||||
cache
|
||||
.processes_vec
|
||||
.extend(processes.top_processes.into_iter().map(|p| pb::Process {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
}));
|
||||
|
||||
// Create the protobuf message
|
||||
let pb = pb::Processes {
|
||||
process_count: processes.process_count as u64,
|
||||
rows: cache.processes_vec.clone(),
|
||||
};
|
||||
|
||||
// Test protobuf encoding/decoding
|
||||
let mut buf = Vec::new();
|
||||
prost::Message::encode(&pb, &mut buf).expect("Failed to encode protobuf");
|
||||
let decoded = pb::Processes::decode(buf.as_slice()).expect("Failed to decode protobuf");
|
||||
|
||||
// Print debug info
|
||||
println!("Process count: {}", pb.process_count);
|
||||
println!("Process vector length: {}", pb.rows.len());
|
||||
println!("Encoded size: {} bytes", buf.len());
|
||||
println!("Decoded process count: {}", decoded.rows.len());
|
||||
|
||||
// Print first few processes if available
|
||||
for (i, process) in pb.rows.iter().take(5).enumerate() {
|
||||
println!(
|
||||
"Process {}: {} (PID: {}) CPU: {:.1}% MEM: {} bytes",
|
||||
i + 1,
|
||||
process.name,
|
||||
process.pid,
|
||||
process.cpu_usage,
|
||||
process.mem_bytes
|
||||
);
|
||||
}
|
||||
|
||||
// Validate
|
||||
assert!(!pb.rows.is_empty(), "Process list should not be empty");
|
||||
assert!(
|
||||
pb.process_count > 0,
|
||||
"Process count should be greater than 0"
|
||||
);
|
||||
assert_eq!(
|
||||
pb.process_count as usize,
|
||||
pb.rows.len(),
|
||||
"Process count mismatch with actual rows"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! CLI arg parsing tests for socktop_agent (server)
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_help_and_port_short_long() {
|
||||
// We verify port flags are accepted by ensuring the process starts (then we kill quickly).
|
||||
// Use an unlikely port to avoid conflicts.
|
||||
let exe = env!("CARGO_BIN_EXE_socktop_agent");
|
||||
|
||||
// TLS enabled with long --port
|
||||
let mut child = Command::new(exe)
|
||||
.args(["--enableSSL", "--port", "9555"])
|
||||
.spawn()
|
||||
.expect("spawn agent");
|
||||
// Give it a moment to bind
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
// TLS enabled with short -p
|
||||
let mut child2 = Command::new(exe)
|
||||
.args(["--enableSSL", "-p", "9556"])
|
||||
.spawn()
|
||||
.expect("spawn agent");
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let _ = child2.kill();
|
||||
let _ = child2.wait();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Unit test for port parsing logic moved out of `main.rs`.
|
||||
|
||||
fn parse_port<I: IntoIterator<Item = String>>(args: I, default_port: u16) -> u16 {
|
||||
let mut it = args.into_iter();
|
||||
let _ = it.next(); // program name
|
||||
let mut long: Option<String> = None;
|
||||
let mut short: Option<String> = None;
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--port" => long = it.next(),
|
||||
"-p" => short = it.next(),
|
||||
_ if a.starts_with("--port=") => {
|
||||
if let Some((_, v)) = a.split_once('=') {
|
||||
long = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
long.or(short)
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(default_port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_long_short_and_assign() {
|
||||
assert_eq!(
|
||||
parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443),
|
||||
9001
|
||||
);
|
||||
assert_eq!(
|
||||
parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443),
|
||||
9002
|
||||
);
|
||||
assert_eq!(
|
||||
parse_port(vec!["agent".into(), "--port=9003".into()], 8443),
|
||||
9003
|
||||
);
|
||||
assert_eq!(parse_port(vec!["agent".into()], 8443), 8443);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
fn expected_paths(config_home: &std::path::Path) -> (PathBuf, PathBuf) {
|
||||
let base = config_home.join("socktop_agent").join("tls");
|
||||
(base.join("cert.pem"), base.join("key.pem"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_self_signed_cert_and_key_in_xdg_path() {
|
||||
// Create an isolated fake XDG_CONFIG_HOME
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let xdg = tmpdir.path().to_path_buf();
|
||||
|
||||
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed
|
||||
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
|
||||
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
|
||||
cmd.env("XDG_CONFIG_HOME", &xdg)
|
||||
.arg("--enableSSL")
|
||||
.arg("-p")
|
||||
.arg("0");
|
||||
|
||||
// Spawn the process and poll for cert generation
|
||||
let mut child = cmd.spawn().expect("spawn agent");
|
||||
|
||||
// Poll up to ~3s for files to appear to avoid timing flakes
|
||||
let (cert_path, key_path) = expected_paths(&xdg);
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(3000);
|
||||
let interval = Duration::from_millis(50);
|
||||
while start.elapsed() < timeout {
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(interval);
|
||||
}
|
||||
|
||||
// Terminate the process regardless
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
// Verify files exist at expected paths
|
||||
assert!(
|
||||
cert_path.exists(),
|
||||
"cert not found at {}",
|
||||
cert_path.display()
|
||||
);
|
||||
assert!(key_path.exists(), "key not found at {}", key_path.display());
|
||||
|
||||
// Also ensure they are non-empty
|
||||
let cert_md = fs::metadata(&cert_path).expect("cert metadata");
|
||||
let key_md = fs::metadata(&key_path).expect("key metadata");
|
||||
assert!(cert_md.len() > 0, "cert is empty");
|
||||
assert!(key_md.len() > 0, "key is empty");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
[package]
|
||||
name = "socktop_connector"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "WebSocket connector library for socktop agent communication"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
repository = "https://github.com/jasonwitty/socktop"
|
||||
readme = "README.md"
|
||||
keywords = ["monitoring", "websocket", "metrics", "system"]
|
||||
categories = ["network-programming", "development-tools"]
|
||||
documentation = "https://docs.rs/socktop_connector"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# docs.rs specific metadata
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[dependencies]
|
||||
# WebSocket client - only for non-WASM targets
|
||||
tokio-tungstenite = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
futures-util = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
|
||||
# WASM WebSocket support
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
wasm-bindgen-futures = { version = "0.4", optional = true }
|
||||
js-sys = { version = "0.3", optional = true }
|
||||
web-sys = { version = "0.3", features = ["WebSocket", "MessageEvent", "ErrorEvent", "CloseEvent", "BinaryType", "Window", "console"], optional = true }
|
||||
|
||||
# TLS support
|
||||
rustls = { version = "0.23", features = ["ring"], optional = true }
|
||||
rustls-pemfile = { version = "2.1", optional = true }
|
||||
|
||||
# Serialization - always available
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Compression - used in both networking and WASM modes
|
||||
flate2 = "1.0"
|
||||
|
||||
# Protobuf - always available
|
||||
prost = { workspace = true }
|
||||
|
||||
# Error handling - always available
|
||||
thiserror = "2.0"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
protoc-bin-vendored = "3.0"
|
||||
|
||||
[features]
|
||||
default = ["networking", "tls"]
|
||||
networking = ["tokio-tungstenite", "tokio", "futures-util", "url"]
|
||||
tls = ["networking", "rustls", "rustls-pemfile"]
|
||||
wasm = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys"] # WASM-compatible networking with compression
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Witty
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,486 @@
|
||||
# socktop_connector
|
||||
|
||||
A WebSocket connector library for communicating with socktop agents.
|
||||
|
||||
## Overview
|
||||
|
||||
`socktop_connector` provides a high-level, type-safe interface for connecting to socktop agents over WebSocket connections. It handles connection management, TLS certificate pinning, compression, and protocol buffer decoding automatically.
|
||||
|
||||
The library is designed for professional use with structured error handling that allows you to pattern match on specific error types, making it easy to implement robust error recovery and monitoring strategies.
|
||||
|
||||
## Features
|
||||
|
||||
- **WebSocket Communication**: Support for both `ws://` and `wss://` connections
|
||||
- **TLS Security**: Certificate pinning for secure connections with self-signed certificates
|
||||
- **Hostname Verification**: Configurable hostname verification for TLS connections
|
||||
- **Type Safety**: Strongly typed requests and responses
|
||||
- **Automatic Compression**: Handles gzip compression/decompression transparently
|
||||
- **Protocol Buffer Support**: Decodes binary process data automatically
|
||||
- **Error Handling**: Comprehensive error handling with structured error types for pattern matching
|
||||
|
||||
## Connection Types
|
||||
|
||||
### Non-TLS Connections (`ws://`)
|
||||
Use `connect_to_socktop_agent()` for unencrypted WebSocket connections.
|
||||
|
||||
### TLS Connections (`wss://`)
|
||||
Use `connect_to_socktop_agent_with_tls()` for encrypted connections with certificate pinning. You can control hostname verification with the `verify_hostname` parameter.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add this to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
socktop_connector = "0.1.5"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "macros"] }
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent, AgentRequest, AgentResponse};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to a socktop agent (non-TLS connections are always unverified)
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
|
||||
// Request metrics
|
||||
match connector.request(AgentRequest::Metrics).await? {
|
||||
AgentResponse::Metrics(metrics) => {
|
||||
println!("CPU: {}%, Memory: {}/{}MB",
|
||||
metrics.cpu_total,
|
||||
metrics.mem_used / 1024 / 1024,
|
||||
metrics.mem_total / 1024 / 1024
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
// Request process list
|
||||
match connector.request(AgentRequest::Processes).await? {
|
||||
AgentResponse::Processes(processes) => {
|
||||
println!("Total processes: {}", processes.process_count);
|
||||
for process in processes.top_processes.iter().take(5) {
|
||||
println!(" {} (PID: {}) - CPU: {}%",
|
||||
process.name, process.pid, process.cpu_usage);
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling with Pattern Matching
|
||||
|
||||
Take advantage of structured error types for robust error handling:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent, ConnectorError, AgentRequest};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Handle connection errors specifically
|
||||
let mut connector = match connect_to_socktop_agent("ws://localhost:3000/ws").await {
|
||||
Ok(conn) => conn,
|
||||
Err(ConnectorError::WebSocketError(e)) => {
|
||||
eprintln!("Failed to connect to WebSocket: {}", e);
|
||||
return;
|
||||
}
|
||||
Err(ConnectorError::UrlError(e)) => {
|
||||
eprintln!("Invalid URL provided: {}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Connection failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle request errors specifically
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(response) => println!("Success: {:?}", response),
|
||||
Err(ConnectorError::JsonError(e)) => {
|
||||
eprintln!("Failed to parse server response: {}", e);
|
||||
}
|
||||
Err(ConnectorError::WebSocketError(e)) => {
|
||||
eprintln!("Communication error: {}", e);
|
||||
}
|
||||
Err(e) => eprintln!("Request failed: {}", e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TLS with Certificate Pinning
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent_with_tls, AgentRequest};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect with TLS certificate pinning and hostname verification
|
||||
let mut connector = connect_to_socktop_agent_with_tls(
|
||||
"wss://remote-host:8443/ws",
|
||||
"/path/to/cert.pem",
|
||||
false // Enable hostname verification
|
||||
).await?;
|
||||
|
||||
let response = connector.request(AgentRequest::Disks).await?;
|
||||
println!("Got disk info: {:?}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```rust
|
||||
use socktop_connector::{ConnectorConfig, SocktopConnector, AgentRequest};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a custom configuration
|
||||
let config = ConnectorConfig::new("wss://remote-host:8443/ws")
|
||||
.with_tls_ca("/path/to/cert.pem")
|
||||
.with_hostname_verification(false);
|
||||
|
||||
// Create and connect
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
connector.connect().await?;
|
||||
|
||||
// Make requests
|
||||
let response = connector.request(AgentRequest::Metrics).await?;
|
||||
|
||||
// Clean disconnect
|
||||
connector.disconnect().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Protocol Configuration
|
||||
|
||||
For version compatibility (if applies), you can configure WebSocket protocol version and sub-protocols:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{ConnectorConfig, SocktopConnector, connect_to_socktop_agent_with_config};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Method 1: Using the convenience function
|
||||
let connector = connect_to_socktop_agent_with_config(
|
||||
"ws://localhost:3000/ws",
|
||||
Some(vec!["socktop".to_string(), "v1".to_string()]), // Sub-protocols
|
||||
Some("13".to_string()), // WebSocket version (13 is standard)
|
||||
).await?;
|
||||
|
||||
// Method 2: Using ConnectorConfig builder
|
||||
let config = ConnectorConfig::new("ws://localhost:3000/ws")
|
||||
.with_protocols(vec!["socktop".to_string()])
|
||||
.with_version("13");
|
||||
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
connector.connect().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** WebSocket version 13 is the current standard and is used by default. The sub-protocols feature is useful for protocol negotiation with servers that support multiple protocols.
|
||||
|
||||
## Continuous Updates
|
||||
|
||||
The socktop agent provides real-time system metrics. Each request returns the current snapshot, but you can implement continuous monitoring by making requests in a loop:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent, AgentRequest, AgentResponse, ConnectorError};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
|
||||
// Monitor system metrics every 2 seconds
|
||||
loop {
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
// Calculate total network activity across all interfaces
|
||||
let total_rx: u64 = metrics.networks.iter().map(|n| n.received).sum();
|
||||
let total_tx: u64 = metrics.networks.iter().map(|n| n.transmitted).sum();
|
||||
|
||||
println!("CPU: {:.1}%, Memory: {:.1}%, Network: ↓{} ↑{}",
|
||||
metrics.cpu_total,
|
||||
(metrics.mem_used as f64 / metrics.mem_total as f64) * 100.0,
|
||||
format_bytes(total_rx),
|
||||
format_bytes(total_tx)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error getting metrics: {}", e);
|
||||
|
||||
// You can pattern match on specific error types for different handling
|
||||
match e {
|
||||
socktop_connector::ConnectorError::WebSocketError(_) => {
|
||||
eprintln!("Connection lost, attempting to reconnect...");
|
||||
// Implement reconnection logic here
|
||||
break;
|
||||
}
|
||||
socktop_connector::ConnectorError::JsonError(_) => {
|
||||
eprintln!("Data parsing error, continuing...");
|
||||
// Continue with next iteration for transient parsing errors
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Other error, stopping monitoring");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit_index = 0;
|
||||
|
||||
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
format!("{:.1}{}", size, UNITS[unit_index])
|
||||
}
|
||||
```
|
||||
|
||||
### Understanding Data Freshness
|
||||
|
||||
The socktop agent implements intelligent caching to avoid overwhelming the system:
|
||||
|
||||
- **Metrics**: Cached for ~250ms by default (cheap / fast-changing data like CPU, memory)
|
||||
- **Processes**: Cached for ~1500ms by default (exppensive / moderately changing data)
|
||||
- **Disks**: Cached for ~1000ms by default (cheap / slowly changing data)
|
||||
|
||||
These values have been generally tuned in advance. You should not need to override them. The reason for this cache is for the use case that multiple clients are requesting data. In general a single client should never really hit a cached response since the polling rates are slower that the cache intervals. Cache intervals have been tuned based on how much work the agent has to do in the case of reloading fresh data.
|
||||
|
||||
|
||||
This means:
|
||||
|
||||
1. **Multiple rapid requests** for the same data type will return cached results
|
||||
2. **Different data types** have independent cache timers
|
||||
3. **Fresh data** is automatically retrieved when cache expires
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent, AgentRequest, AgentResponse};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
|
||||
// This demonstrates cache behavior
|
||||
println!("Requesting metrics twice quickly...");
|
||||
|
||||
// First request - fresh data from system
|
||||
let start = std::time::Instant::now();
|
||||
connector.request(AgentRequest::Metrics).await?;
|
||||
println!("First request took: {:?}", start.elapsed());
|
||||
|
||||
// Second request immediately - cached data
|
||||
let start = std::time::Instant::now();
|
||||
connector.request(AgentRequest::Metrics).await?;
|
||||
println!("Second request took: {:?}", start.elapsed()); // Much faster!
|
||||
|
||||
// Wait for cache to expire, then request again
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
let start = std::time::Instant::now();
|
||||
connector.request(AgentRequest::Metrics).await?;
|
||||
println!("Third request (after cache expiry): {:?}", start.elapsed());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The WebSocket connection remains open between requests, providing efficient real-time monitoring without connection overhead.
|
||||
|
||||
## Request Types
|
||||
|
||||
The library supports three types of requests:
|
||||
|
||||
- `AgentRequest::Metrics` - Get current system metrics (CPU, memory, network, etc.)
|
||||
- `AgentRequest::Disks` - Get disk usage information
|
||||
- `AgentRequest::Processes` - Get running process information
|
||||
|
||||
## Response Types
|
||||
|
||||
Responses are automatically parsed into strongly-typed structures:
|
||||
|
||||
- `AgentResponse::Metrics(Metrics)` - System metrics with CPU, memory, network data
|
||||
- `AgentResponse::Disks(Vec<DiskInfo>)` - List of disk usage information
|
||||
- `AgentResponse::Processes(ProcessesPayload)` - Process list with CPU and memory usage
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The library provides flexible configuration through the `ConnectorConfig` builder:
|
||||
|
||||
- `with_tls_ca(path)` - Enable TLS with certificate pinning
|
||||
- `with_hostname_verification(bool)` - Control hostname verification for TLS connections
|
||||
- `true` (recommended): Verify the server hostname matches the certificate
|
||||
- `false`: Skip hostname verification (useful for localhost or IP-based connections)
|
||||
- `with_protocols(Vec<String>)` - Set WebSocket sub-protocols for protocol negotiation
|
||||
- `with_version(String)` - Set WebSocket protocol version (default is "13", the current standard)
|
||||
|
||||
**Note**: Hostname verification only applies to TLS connections (`wss://`). Non-TLS connections (`ws://`) don't use certificates, so hostname verification is not applicable.
|
||||
|
||||
## WASM Compatibility (experimental)
|
||||
|
||||
`socktop_connector` provides **full WebSocket support** for WebAssembly (WASM) environments, including complete networking functionality with automatic compression and protobuf decoding.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||
wasm-bindgen = "0.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
```
|
||||
|
||||
### What Works
|
||||
- ✅ Full WebSocket connectivity (`ws://` connections)
|
||||
- ✅ All request types (`Metrics`, `Disks`, `Processes`)
|
||||
- ✅ Automatic gzip decompression for metrics and disks
|
||||
- ✅ Automatic protobuf decoding for process data
|
||||
- ✅ All types (`ConnectorConfig`, `AgentRequest`, `AgentResponse`)
|
||||
- ✅ JSON serialization/deserialization
|
||||
- ✅ Protocol and version configuration
|
||||
|
||||
### What Doesn't Work
|
||||
- ❌ TLS connections (`wss://`) - use `ws://` only
|
||||
- ❌ TLS certificate handling
|
||||
|
||||
### Basic WASM Usage
|
||||
|
||||
```rust
|
||||
use wasm_bindgen::prelude::*;
|
||||
use socktop_connector::{ConnectorConfig, SocktopConnector, AgentRequest};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn test_connection() {
|
||||
let config = ConnectorConfig::new("ws://localhost:3000/ws");
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
|
||||
match connector.connect().await {
|
||||
Ok(()) => {
|
||||
// Request metrics with automatic gzip decompression
|
||||
let response = connector.request(AgentRequest::Metrics).await.unwrap();
|
||||
console_log!("Got metrics: {:?}", response);
|
||||
|
||||
// Request processes with automatic protobuf decoding
|
||||
let response = connector.request(AgentRequest::Processes).await.unwrap();
|
||||
console_log!("Got processes: {:?}", response);
|
||||
}
|
||||
Err(e) => console_log!("Connection failed: {}", e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete WASM Guide
|
||||
|
||||
For detailed implementation examples, complete code samples, and a working test environment, see the **[WASM Compatibility Guide](../socktop_wasm_test/README.md)** in the `socktop_wasm_test/` directory.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Production TLS**: You can enable hostname verification (`verify_hostname: true`) for production systems, This will add an additional level of production of verifying the hostname against the certificate. Generally this is to stop a man in the middle attack, but since it will be the client who is fooled and not the server, the risk and likelyhood of this use case is rather low. Which is why this is disabled by default.
|
||||
- **Certificate Pinning**: Use `with_tls_ca()` for self-signed certificates, the socktop agent will generate certificates on start. see main readme for more details.
|
||||
- **Non-TLS**: Use only for development or trusted networks
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Currently no environment variables are used. All configuration is done through the API.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The library uses structured error types via `thiserror` for comprehensive error handling. You can pattern match on specific error types:
|
||||
|
||||
```rust
|
||||
use socktop_connector::{connect_to_socktop_agent, ConnectorError, AgentRequest};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
match connect_to_socktop_agent("invalid://url").await {
|
||||
Ok(mut connector) => {
|
||||
// Handle successful connection
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(response) => println!("Got response: {:?}", response),
|
||||
Err(ConnectorError::WebSocketError(e)) => {
|
||||
eprintln!("WebSocket communication failed: {}", e);
|
||||
}
|
||||
Err(ConnectorError::JsonError(e)) => {
|
||||
eprintln!("Failed to parse response: {}", e);
|
||||
}
|
||||
Err(e) => eprintln!("Other error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(ConnectorError::UrlError(e)) => {
|
||||
eprintln!("Invalid URL: {}", e);
|
||||
}
|
||||
Err(ConnectorError::WebSocketError(e)) => {
|
||||
eprintln!("Failed to connect: {}", e);
|
||||
}
|
||||
Err(ConnectorError::TlsError(msg)) => {
|
||||
eprintln!("TLS error: {}", msg);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Connection failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
The `ConnectorError` enum provides specific variants for different error conditions:
|
||||
|
||||
- `ConnectorError::WebSocketError` - WebSocket connection or communication errors
|
||||
- `ConnectorError::TlsError` - TLS-related errors (certificate validation, etc.)
|
||||
- `ConnectorError::UrlError` - URL parsing errors
|
||||
- `ConnectorError::JsonError` - JSON serialization/deserialization errors
|
||||
- `ConnectorError::ProtocolError` - Protocol-level errors
|
||||
- `ConnectorError::CompressionError` - Gzip compression/decompression errors
|
||||
- `ConnectorError::IoError` - I/O errors
|
||||
- `ConnectorError::Other` - Other errors with descriptive messages
|
||||
|
||||
All errors implement `std::error::Error` so they work seamlessly with `Box<dyn std::error::Error>`, `anyhow`, and other error handling crates.
|
||||
|
||||
### Migration from Generic Errors
|
||||
|
||||
If you were previously using the library with generic error handling, your existing code will continue to work:
|
||||
|
||||
```rust
|
||||
// This continues to work as before
|
||||
async fn my_function() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
let response = connector.request(AgentRequest::Metrics).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// But now you can also use structured error handling for better control
|
||||
async fn improved_function() -> Result<(), ConnectorError> {
|
||||
let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
let response = connector.request(AgentRequest::Metrics).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see the LICENSE file for details.
|
||||
@@ -0,0 +1,10 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Set the protoc binary path to use the vendored version for CI compatibility
|
||||
// SAFETY: We're only setting PROTOC in a build script environment, which is safe
|
||||
unsafe {
|
||||
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
|
||||
}
|
||||
|
||||
prost_build::compile_protos(&["processes.proto"], &["."])?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Example of using socktop_connector in a WASM environment.
|
||||
//!
|
||||
//! This example demonstrates how to use the connector without TLS dependencies
|
||||
//! for WebAssembly builds.
|
||||
|
||||
use socktop_connector::{AgentRequest, ConnectorConfig, connect_to_socktop_agent};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("WASM-compatible socktop connector example");
|
||||
|
||||
// For WASM builds, use ws:// (not wss://) to avoid TLS dependencies
|
||||
let url = "ws://localhost:3000/ws";
|
||||
|
||||
// Method 1: Simple connection (recommended for most use cases)
|
||||
let mut connector = connect_to_socktop_agent(url).await?;
|
||||
|
||||
// Method 2: With custom WebSocket configuration
|
||||
let config = ConnectorConfig::new(url)
|
||||
.with_protocols(vec!["socktop".to_string()])
|
||||
.with_version("13".to_string());
|
||||
|
||||
let mut connector_custom = socktop_connector::SocktopConnector::new(config);
|
||||
connector_custom.connect().await?;
|
||||
|
||||
// Make a request to get metrics
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(response) => {
|
||||
println!("Successfully received response: {response:?}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Request failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("WASM example completed successfully!");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Configuration for socktop WebSocket connections.
|
||||
|
||||
/// Configuration for connecting to a socktop agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectorConfig {
|
||||
pub url: String,
|
||||
pub tls_ca_path: Option<String>,
|
||||
pub verify_hostname: bool,
|
||||
pub ws_protocols: Option<Vec<String>>,
|
||||
pub ws_version: Option<String>,
|
||||
}
|
||||
|
||||
impl ConnectorConfig {
|
||||
/// Create a new connector configuration with the given URL.
|
||||
pub fn new(url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
url: url.into(),
|
||||
tls_ca_path: None,
|
||||
verify_hostname: false,
|
||||
ws_protocols: None,
|
||||
ws_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the path to a custom TLS CA certificate file.
|
||||
pub fn with_tls_ca(mut self, ca_path: impl Into<String>) -> Self {
|
||||
self.tls_ca_path = Some(ca_path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable hostname verification for TLS connections.
|
||||
pub fn with_hostname_verification(mut self, verify: bool) -> Self {
|
||||
self.verify_hostname = verify;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set WebSocket sub-protocols to negotiate.
|
||||
pub fn with_protocols(mut self, protocols: Vec<String>) -> Self {
|
||||
self.ws_protocols = Some(protocols);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set WebSocket protocol version (default is "13").
|
||||
pub fn with_version(mut self, version: impl Into<String>) -> Self {
|
||||
self.ws_version = Some(version.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
//! Modular SocktopConnector implementation using networking and WASM modules.
|
||||
|
||||
use crate::config::ConnectorConfig;
|
||||
use crate::error::{ConnectorError, Result};
|
||||
use crate::{AgentRequest, AgentResponse};
|
||||
|
||||
#[cfg(feature = "networking")]
|
||||
use crate::networking::{
|
||||
WsStream, connect_to_agent, request_disks, request_metrics, request_processes,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
use crate::wasm::{connect_to_agent, send_request_and_wait};
|
||||
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
use crate::{DiskInfo, Metrics, ProcessesPayload};
|
||||
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
use web_sys::WebSocket;
|
||||
|
||||
/// Main connector for communicating with socktop agents
|
||||
pub struct SocktopConnector {
|
||||
pub config: ConnectorConfig,
|
||||
#[cfg(feature = "networking")]
|
||||
stream: Option<WsStream>,
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
websocket: Option<WebSocket>,
|
||||
}
|
||||
|
||||
impl SocktopConnector {
|
||||
/// Create a new connector with the given configuration
|
||||
pub fn new(config: ConnectorConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
#[cfg(feature = "networking")]
|
||||
stream: None,
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
websocket: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "networking")]
|
||||
impl SocktopConnector {
|
||||
/// Connect to the agent
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
let stream = connect_to_agent(&self.config).await?;
|
||||
self.stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a request to the agent and get the response
|
||||
pub async fn request(&mut self, request: AgentRequest) -> Result<AgentResponse> {
|
||||
let stream = self.stream.as_mut().ok_or(ConnectorError::NotConnected)?;
|
||||
|
||||
match request {
|
||||
AgentRequest::Metrics => {
|
||||
let metrics = request_metrics(stream)
|
||||
.await
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get metrics"))?;
|
||||
Ok(AgentResponse::Metrics(metrics))
|
||||
}
|
||||
AgentRequest::Disks => {
|
||||
let disks = request_disks(stream)
|
||||
.await
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get disks"))?;
|
||||
Ok(AgentResponse::Disks(disks))
|
||||
}
|
||||
AgentRequest::Processes => {
|
||||
let processes = request_processes(stream)
|
||||
.await
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?;
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the connector is connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.stream.is_some()
|
||||
}
|
||||
|
||||
/// Disconnect from the agent
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
if let Some(mut stream) = self.stream.take() {
|
||||
let _ = stream.close(None).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// WASM WebSocket implementation
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
impl SocktopConnector {
|
||||
/// Connect to the agent using WASM WebSocket
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
let websocket = connect_to_agent(&self.config).await?;
|
||||
self.websocket = Some(websocket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a request to the agent and get the response
|
||||
pub async fn request(&mut self, request: AgentRequest) -> Result<AgentResponse> {
|
||||
let ws = self
|
||||
.websocket
|
||||
.as_ref()
|
||||
.ok_or(ConnectorError::NotConnected)?;
|
||||
|
||||
send_request_and_wait(ws, request).await
|
||||
}
|
||||
|
||||
/// Check if the connector is connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
use crate::utils::WEBSOCKET_OPEN;
|
||||
self.websocket
|
||||
.as_ref()
|
||||
.is_some_and(|ws| ws.ready_state() == WEBSOCKET_OPEN)
|
||||
}
|
||||
|
||||
/// Disconnect from the agent
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
if let Some(ws) = self.websocket.take() {
|
||||
let _ = ws.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request metrics from the agent
|
||||
pub async fn get_metrics(&mut self) -> Result<Metrics> {
|
||||
match self.request(AgentRequest::Metrics).await? {
|
||||
AgentResponse::Metrics(metrics) => Ok(metrics),
|
||||
_ => Err(ConnectorError::protocol_error(
|
||||
"Unexpected response type for metrics",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request disk information from the agent
|
||||
pub async fn get_disks(&mut self) -> Result<Vec<DiskInfo>> {
|
||||
match self.request(AgentRequest::Disks).await? {
|
||||
AgentResponse::Disks(disks) => Ok(disks),
|
||||
_ => Err(ConnectorError::protocol_error(
|
||||
"Unexpected response type for disks",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request process information from the agent
|
||||
pub async fn get_processes(&mut self) -> Result<ProcessesPayload> {
|
||||
match self.request(AgentRequest::Processes).await? {
|
||||
AgentResponse::Processes(processes) => Ok(processes),
|
||||
_ => Err(ConnectorError::protocol_error(
|
||||
"Unexpected response type for processes",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stub implementations when neither networking nor wasm is enabled
|
||||
#[cfg(not(any(feature = "networking", feature = "wasm")))]
|
||||
impl SocktopConnector {
|
||||
/// Connect to the socktop agent endpoint.
|
||||
///
|
||||
/// Note: Networking functionality is disabled. Enable the "networking" feature to use this function.
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
Err(ConnectorError::protocol_error(
|
||||
"Networking functionality disabled. Enable the 'networking' feature to connect to agents.",
|
||||
))
|
||||
}
|
||||
|
||||
/// Send a request to the agent and await a response.
|
||||
///
|
||||
/// Note: Networking functionality is disabled. Enable the "networking" feature to use this function.
|
||||
pub async fn request(&mut self, _request: AgentRequest) -> Result<AgentResponse> {
|
||||
Err(ConnectorError::protocol_error(
|
||||
"Networking functionality disabled. Enable the 'networking' feature to send requests.",
|
||||
))
|
||||
}
|
||||
|
||||
/// Close the connection to the agent.
|
||||
///
|
||||
/// Note: Networking functionality is disabled. This is a no-op when networking is disabled.
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
Ok(()) // No-op when networking is disabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to create a connector and connect in one step.
|
||||
///
|
||||
/// This function is for non-TLS WebSocket connections (`ws://`). Since there's no
|
||||
/// certificate involved, hostname verification is not applicable.
|
||||
///
|
||||
/// For TLS connections with certificate pinning, use `connect_to_socktop_agent_with_tls()`.
|
||||
#[cfg(feature = "networking")]
|
||||
pub async fn connect_to_socktop_agent(url: impl Into<String>) -> Result<SocktopConnector> {
|
||||
let config = ConnectorConfig::new(url);
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
connector.connect().await?;
|
||||
Ok(connector)
|
||||
}
|
||||
|
||||
/// Convenience function to create a connector with TLS and connect in one step.
|
||||
///
|
||||
/// This function enables TLS with certificate pinning using the provided CA certificate.
|
||||
/// The `verify_hostname` parameter controls whether the server's hostname is verified
|
||||
/// against the certificate (recommended for production, can be disabled for testing).
|
||||
#[cfg(feature = "tls")]
|
||||
#[cfg(feature = "networking")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
|
||||
pub async fn connect_to_socktop_agent_with_tls(
|
||||
url: impl Into<String>,
|
||||
ca_path: impl Into<String>,
|
||||
verify_hostname: bool,
|
||||
) -> Result<SocktopConnector> {
|
||||
let config = ConnectorConfig::new(url)
|
||||
.with_tls_ca(ca_path)
|
||||
.with_hostname_verification(verify_hostname);
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
connector.connect().await?;
|
||||
Ok(connector)
|
||||
}
|
||||
|
||||
/// Convenience function to create a connector with custom WebSocket protocol configuration.
|
||||
///
|
||||
/// This function allows you to specify WebSocket protocol version and sub-protocols.
|
||||
/// Most users should use the simpler `connect_to_socktop_agent()` function instead.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// use socktop_connector::connect_to_socktop_agent_with_config;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let connector = connect_to_socktop_agent_with_config(
|
||||
/// "ws://localhost:3000/ws",
|
||||
/// Some(vec!["socktop".to_string()]), // WebSocket sub-protocols
|
||||
/// Some("13".to_string()), // WebSocket version (13 is standard)
|
||||
/// ).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[cfg(feature = "networking")]
|
||||
pub async fn connect_to_socktop_agent_with_config(
|
||||
url: impl Into<String>,
|
||||
protocols: Option<Vec<String>>,
|
||||
version: Option<String>,
|
||||
) -> Result<SocktopConnector> {
|
||||
let mut config = ConnectorConfig::new(url);
|
||||
|
||||
if let Some(protocols) = protocols {
|
||||
config = config.with_protocols(protocols);
|
||||
}
|
||||
|
||||
if let Some(version) = version {
|
||||
config = config.with_version(version);
|
||||
}
|
||||
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
connector.connect().await?;
|
||||
Ok(connector)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Error types for socktop_connector
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur when using socktop_connector
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConnectorError {
|
||||
/// WebSocket connection failed
|
||||
#[cfg(feature = "networking")]
|
||||
#[error("WebSocket connection failed: {source}")]
|
||||
ConnectionFailed {
|
||||
source: Box<tokio_tungstenite::tungstenite::Error>,
|
||||
},
|
||||
|
||||
/// URL parsing error
|
||||
#[cfg(feature = "networking")]
|
||||
#[error("Invalid URL: {url}")]
|
||||
InvalidUrl {
|
||||
url: String,
|
||||
#[source]
|
||||
source: url::ParseError,
|
||||
},
|
||||
|
||||
/// TLS certificate error
|
||||
#[error("TLS certificate error: {message}")]
|
||||
TlsError {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
},
|
||||
|
||||
/// Certificate file not found or invalid
|
||||
#[error("Certificate file error at '{path}': {message}")]
|
||||
CertificateError { path: String, message: String },
|
||||
|
||||
/// Invalid server response format
|
||||
#[error("Invalid response from server: {message}")]
|
||||
InvalidResponse { message: String },
|
||||
|
||||
/// JSON parsing error
|
||||
#[error("JSON parsing error: {source}")]
|
||||
JsonError {
|
||||
#[from]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
/// Request/response protocol error
|
||||
#[error("Protocol error: {message}")]
|
||||
ProtocolError { message: String },
|
||||
|
||||
/// Connection is not established
|
||||
#[error("Not connected to server")]
|
||||
NotConnected,
|
||||
|
||||
/// Connection was closed unexpectedly
|
||||
#[error("Connection closed: {reason}")]
|
||||
ConnectionClosed { reason: String },
|
||||
|
||||
/// IO error (network, file system, etc.)
|
||||
#[error("IO error: {source}")]
|
||||
IoError {
|
||||
#[from]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// Compression/decompression error
|
||||
#[error("Compression error: {message}")]
|
||||
CompressionError { message: String },
|
||||
|
||||
/// Protocol Buffer parsing error
|
||||
#[error("Protocol buffer error: {source}")]
|
||||
ProtobufError {
|
||||
#[from]
|
||||
source: prost::DecodeError,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result type alias for connector operations
|
||||
pub type Result<T> = std::result::Result<T, ConnectorError>;
|
||||
|
||||
impl ConnectorError {
|
||||
/// Create a TLS error with context
|
||||
pub fn tls_error(
|
||||
message: impl Into<String>,
|
||||
source: impl std::error::Error + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self::TlsError {
|
||||
message: message.into(),
|
||||
source: Box::new(source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a certificate error
|
||||
pub fn certificate_error(path: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self::CertificateError {
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a protocol error
|
||||
pub fn protocol_error(message: impl Into<String>) -> Self {
|
||||
Self::ProtocolError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invalid response error
|
||||
pub fn invalid_response(message: impl Into<String>) -> Self {
|
||||
Self::InvalidResponse {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a connection closed error
|
||||
pub fn connection_closed(reason: impl Into<String>) -> Self {
|
||||
Self::ConnectionClosed {
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a compression error
|
||||
pub fn compression_error(message: impl Into<String>) -> Self {
|
||||
Self::CompressionError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a serialization error (wraps JSON error)
|
||||
pub fn serialization_error(message: impl Into<String>) -> Self {
|
||||
Self::ProtocolError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "networking")]
|
||||
impl From<url::ParseError> for ConnectorError {
|
||||
fn from(source: url::ParseError) -> Self {
|
||||
Self::InvalidUrl {
|
||||
url: "unknown".to_string(), // We don't have the URL in the error context
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual From implementation for boxed tungstenite errors
|
||||
#[cfg(feature = "networking")]
|
||||
impl From<tokio_tungstenite::tungstenite::Error> for ConnectorError {
|
||||
fn from(source: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
Self::ConnectionFailed {
|
||||
source: Box::new(source),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! WebSocket connector library for socktop agents.
|
||||
//!
|
||||
//! This library provides a high-level interface for connecting to socktop agents
|
||||
//! over WebSocket connections with support for TLS and certificate pinning.
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use socktop_connector::{connect_to_socktop_agent, AgentRequest, AgentResponse};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
//!
|
||||
//! // Get comprehensive system metrics
|
||||
//! if let Ok(AgentResponse::Metrics(metrics)) = connector.request(AgentRequest::Metrics).await {
|
||||
//! println!("Hostname: {}", metrics.hostname);
|
||||
//! println!("CPU Usage: {:.1}%", metrics.cpu_total);
|
||||
//!
|
||||
//! // CPU temperature if available
|
||||
//! if let Some(temp) = metrics.cpu_temp_c {
|
||||
//! println!("CPU Temperature: {:.1}°C", temp);
|
||||
//! }
|
||||
//!
|
||||
//! // Memory usage
|
||||
//! println!("Memory: {:.1} GB / {:.1} GB",
|
||||
//! metrics.mem_used as f64 / 1_000_000_000.0,
|
||||
//! metrics.mem_total as f64 / 1_000_000_000.0);
|
||||
//!
|
||||
//! // Per-core CPU usage
|
||||
//! for (i, usage) in metrics.cpu_per_core.iter().enumerate() {
|
||||
//! println!("Core {}: {:.1}%", i, usage);
|
||||
//! }
|
||||
//!
|
||||
//! // GPU information
|
||||
//! if let Some(gpus) = &metrics.gpus {
|
||||
//! for gpu in gpus {
|
||||
//! if let Some(name) = &gpu.name {
|
||||
//! println!("GPU {}: {:.1}% usage", name, gpu.utilization.unwrap_or(0.0));
|
||||
//! if let Some(temp) = gpu.temp {
|
||||
//! println!(" Temperature: {:.1}°C", temp);
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Get process information
|
||||
//! if let Ok(AgentResponse::Processes(processes)) = connector.request(AgentRequest::Processes).await {
|
||||
//! println!("Running processes: {}", processes.process_count);
|
||||
//! for proc in &processes.top_processes {
|
||||
//! println!(" PID {}: {} ({:.1}% CPU, {:.1} MB RAM)",
|
||||
//! proc.pid, proc.name, proc.cpu_usage, proc.mem_bytes as f64 / 1_000_000.0);
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Get disk information
|
||||
//! if let Ok(AgentResponse::Disks(disks)) = connector.request(AgentRequest::Disks).await {
|
||||
//! for disk in disks {
|
||||
//! let used_gb = (disk.total - disk.available) as f64 / 1_000_000_000.0;
|
||||
//! let total_gb = disk.total as f64 / 1_000_000_000.0;
|
||||
//! println!("Disk {}: {:.1} GB / {:.1} GB", disk.name, used_gb, total_gb);
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # TLS Support
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use socktop_connector::connect_to_socktop_agent_with_tls;
|
||||
//!
|
||||
//! # #[tokio::main]
|
||||
//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let connector = connect_to_socktop_agent_with_tls(
|
||||
//! "wss://secure-host:3000/ws",
|
||||
//! "/path/to/ca.pem",
|
||||
//! false // Enable hostname verification
|
||||
//! ).await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! # Continuous Monitoring
|
||||
//!
|
||||
//! For real-time system monitoring, you can make requests in a loop. The agent
|
||||
//! implements intelligent caching to avoid overwhelming the system:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use socktop_connector::{connect_to_socktop_agent, AgentRequest, AgentResponse};
|
||||
//! use tokio::time::{sleep, Duration};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let mut connector = connect_to_socktop_agent("ws://localhost:3000/ws").await?;
|
||||
//!
|
||||
//! // Monitor system metrics every 2 seconds
|
||||
//! loop {
|
||||
//! match connector.request(AgentRequest::Metrics).await {
|
||||
//! Ok(AgentResponse::Metrics(metrics)) => {
|
||||
//! // Calculate total network activity across all interfaces
|
||||
//! let total_rx: u64 = metrics.networks.iter().map(|n| n.received).sum();
|
||||
//! let total_tx: u64 = metrics.networks.iter().map(|n| n.transmitted).sum();
|
||||
//!
|
||||
//! println!("CPU: {:.1}%, Memory: {:.1}%, Network: ↓{} ↑{}",
|
||||
//! metrics.cpu_total,
|
||||
//! (metrics.mem_used as f64 / metrics.mem_total as f64) * 100.0,
|
||||
//! format_bytes(total_rx),
|
||||
//! format_bytes(total_tx)
|
||||
//! );
|
||||
//! }
|
||||
//! Err(e) => {
|
||||
//! eprintln!("Connection error: {}", e);
|
||||
//! break;
|
||||
//! }
|
||||
//! _ => unreachable!(),
|
||||
//! }
|
||||
//!
|
||||
//! sleep(Duration::from_secs(2)).await;
|
||||
//! }
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//!
|
||||
//! fn format_bytes(bytes: u64) -> String {
|
||||
//! const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
|
||||
//! let mut size = bytes as f64;
|
||||
//! let mut unit_index = 0;
|
||||
//!
|
||||
//! while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
//! size /= 1024.0;
|
||||
//! unit_index += 1;
|
||||
//! }
|
||||
//!
|
||||
//! format!("{:.1}{}", size, UNITS[unit_index])
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
// Core modules
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
// Implementation modules
|
||||
#[cfg(feature = "networking")]
|
||||
pub mod networking;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
// Main connector implementation
|
||||
pub mod connector_impl;
|
||||
|
||||
// Re-export the main types
|
||||
pub use config::ConnectorConfig;
|
||||
pub use connector_impl::SocktopConnector;
|
||||
pub use error::{ConnectorError, Result};
|
||||
pub use types::{
|
||||
AgentRequest, AgentResponse, DiskInfo, GpuInfo, Metrics, NetworkInfo, ProcessInfo,
|
||||
ProcessesPayload,
|
||||
};
|
||||
|
||||
// Re-export convenience functions
|
||||
#[cfg(feature = "networking")]
|
||||
pub use connector_impl::{connect_to_socktop_agent, connect_to_socktop_agent_with_config};
|
||||
|
||||
#[cfg(all(feature = "tls", feature = "networking"))]
|
||||
pub use connector_impl::connect_to_socktop_agent_with_tls;
|
||||
|
||||
#[cfg(feature = "networking")]
|
||||
pub use networking::WsStream;
|
||||
|
||||
// Protobuf types for internal use
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
pub mod pb {
|
||||
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! WebSocket connection handling for native (non-WASM) environments.
|
||||
|
||||
use crate::config::ConnectorConfig;
|
||||
use crate::error::{ConnectorError, Result};
|
||||
|
||||
use std::io::BufReader;
|
||||
use std::sync::Arc;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
use {
|
||||
rustls::{self, ClientConfig},
|
||||
rustls::{
|
||||
DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
crypto::ring,
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
},
|
||||
rustls_pemfile::Item,
|
||||
std::fs::File,
|
||||
tokio_tungstenite::Connector,
|
||||
};
|
||||
|
||||
pub type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// Connect to the agent and return the WS stream
|
||||
pub async fn connect_to_agent(config: &ConnectorConfig) -> Result<WsStream> {
|
||||
#[cfg(feature = "tls")]
|
||||
ensure_crypto_provider();
|
||||
|
||||
let mut u = Url::parse(&config.url)?;
|
||||
if let Some(ca_path) = &config.tls_ca_path {
|
||||
if u.scheme() == "ws" {
|
||||
let _ = u.set_scheme("wss");
|
||||
}
|
||||
return connect_with_ca_and_config(u.as_str(), ca_path, config).await;
|
||||
}
|
||||
// No TLS - hostname verification is not applicable
|
||||
connect_without_ca_and_config(u.as_str(), config).await
|
||||
}
|
||||
|
||||
async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> Result<WsStream> {
|
||||
let mut req = url.into_client_request()?;
|
||||
|
||||
// Apply WebSocket protocol configuration
|
||||
if let Some(version) = &config.ws_version {
|
||||
req.headers_mut().insert(
|
||||
"Sec-WebSocket-Version",
|
||||
version
|
||||
.parse()
|
||||
.map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(protocols) = &config.ws_protocols {
|
||||
let protocols_str = protocols.join(", ");
|
||||
req.headers_mut().insert(
|
||||
"Sec-WebSocket-Protocol",
|
||||
protocols_str
|
||||
.parse()
|
||||
.map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let (ws, _) = connect_async(req).await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
async fn connect_with_ca_and_config(
|
||||
url: &str,
|
||||
ca_path: &str,
|
||||
config: &ConnectorConfig,
|
||||
) -> Result<WsStream> {
|
||||
// Initialize the crypto provider for rustls
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let mut root = RootCertStore::empty();
|
||||
let mut reader = BufReader::new(File::open(ca_path)?);
|
||||
let mut der_certs = Vec::new();
|
||||
while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) {
|
||||
if let Item::X509Certificate(der) = item {
|
||||
der_certs.push(der);
|
||||
}
|
||||
}
|
||||
root.add_parsable_certificates(der_certs);
|
||||
|
||||
let mut cfg = ClientConfig::builder()
|
||||
.with_root_certificates(root)
|
||||
.with_no_client_auth();
|
||||
|
||||
let mut req = url.into_client_request()?;
|
||||
|
||||
// Apply WebSocket protocol configuration
|
||||
if let Some(version) = &config.ws_version {
|
||||
req.headers_mut().insert(
|
||||
"Sec-WebSocket-Version",
|
||||
version
|
||||
.parse()
|
||||
.map_err(|_| ConnectorError::protocol_error("Invalid WebSocket version"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(protocols) = &config.ws_protocols {
|
||||
let protocols_str = protocols.join(", ");
|
||||
req.headers_mut().insert(
|
||||
"Sec-WebSocket-Protocol",
|
||||
protocols_str
|
||||
.parse()
|
||||
.map_err(|_| ConnectorError::protocol_error("Invalid WebSocket protocols"))?,
|
||||
);
|
||||
}
|
||||
|
||||
if !config.verify_hostname {
|
||||
#[derive(Debug)]
|
||||
struct NoVerify;
|
||||
impl ServerCertVerifier for NoVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> std::result::Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
vec![
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
]
|
||||
}
|
||||
}
|
||||
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
|
||||
eprintln!(
|
||||
"socktop_connector: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking."
|
||||
);
|
||||
}
|
||||
let cfg = Arc::new(cfg);
|
||||
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
|
||||
req,
|
||||
None,
|
||||
config.verify_hostname,
|
||||
Some(Connector::Rustls(cfg)),
|
||||
)
|
||||
.await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tls"))]
|
||||
async fn connect_with_ca_and_config(
|
||||
_url: &str,
|
||||
_ca_path: &str,
|
||||
_config: &ConnectorConfig,
|
||||
) -> Result<WsStream> {
|
||||
Err(ConnectorError::tls_error(
|
||||
"TLS support not compiled in",
|
||||
std::io::Error::new(std::io::ErrorKind::Unsupported, "TLS not available"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
fn ensure_crypto_provider() {
|
||||
let _ = ring::default_provider().install_default();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Networking module for native WebSocket connections.
|
||||
|
||||
pub mod connection;
|
||||
pub mod requests;
|
||||
|
||||
pub use connection::*;
|
||||
pub use requests::*;
|
||||
@@ -0,0 +1,84 @@
|
||||
//! WebSocket request handlers for native (non-WASM) environments.
|
||||
|
||||
use crate::networking::WsStream;
|
||||
use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip};
|
||||
use crate::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, pb};
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as ProstMessage;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Send a "get_metrics" request and await a single JSON reply
|
||||
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
|
||||
if ws.send(Message::Text("get_metrics".into())).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => gunzip_to_string(&b)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Metrics>(&s).ok()),
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a "get_disks" request and await a JSON Vec<DiskInfo>
|
||||
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
|
||||
if ws.send(Message::Text("get_disks".into())).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => gunzip_to_string(&b)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Vec<DiskInfo>>(&s).ok()),
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<Vec<DiskInfo>>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped)
|
||||
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
|
||||
if ws
|
||||
.send(Message::Text("get_processes".into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
let gz = is_gzip(&b);
|
||||
let data = if gz { gunzip_to_vec(&b).ok()? } else { b };
|
||||
match pb::Processes::decode(data.as_slice()) {
|
||||
Ok(pb) => {
|
||||
let rows: Vec<ProcessInfo> = pb
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|p: pb::Process| ProcessInfo {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
Some(ProcessesPayload {
|
||||
process_count: pb.process_count as usize,
|
||||
top_processes: rows,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") {
|
||||
eprintln!("protobuf decode failed: {e}");
|
||||
}
|
||||
// Fallback: maybe it's JSON (bytes already decompressed if gz)
|
||||
match String::from_utf8(data) {
|
||||
Ok(s) => serde_json::from_str::<ProcessesPayload>(&s).ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Types that represent data from the socktop agent.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NetworkInfo {
|
||||
pub name: String,
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GpuInfo {
|
||||
pub name: Option<String>,
|
||||
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>,
|
||||
|
||||
#[serde(default, alias = "temp_c", alias = "temperature_c")]
|
||||
pub temp: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Metrics {
|
||||
pub cpu_total: f32,
|
||||
pub cpu_per_core: Vec<f32>,
|
||||
pub mem_total: u64,
|
||||
pub mem_used: u64,
|
||||
pub swap_total: u64,
|
||||
pub swap_used: u64,
|
||||
pub hostname: String,
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
pub disks: Vec<DiskInfo>,
|
||||
pub networks: Vec<NetworkInfo>,
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ProcessesPayload {
|
||||
pub process_count: usize,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
|
||||
/// Request types that can be sent to the agent
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
#[serde(rename = "metrics")]
|
||||
Metrics,
|
||||
#[serde(rename = "disks")]
|
||||
Disks,
|
||||
#[serde(rename = "processes")]
|
||||
Processes,
|
||||
}
|
||||
|
||||
impl AgentRequest {
|
||||
/// Convert to the legacy string format used by the agent
|
||||
pub fn to_legacy_string(&self) -> String {
|
||||
match self {
|
||||
AgentRequest::Metrics => "get_metrics".to_string(),
|
||||
AgentRequest::Disks => "get_disks".to_string(),
|
||||
AgentRequest::Processes => "get_processes".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response types that can be received from the agent
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentResponse {
|
||||
#[serde(rename = "metrics")]
|
||||
Metrics(Metrics),
|
||||
#[serde(rename = "disks")]
|
||||
Disks(Vec<DiskInfo>),
|
||||
#[serde(rename = "processes")]
|
||||
Processes(ProcessesPayload),
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Shared utilities for both networking and WASM implementations.
|
||||
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
use flate2::read::GzDecoder;
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
use std::io::Read;
|
||||
|
||||
use crate::error::{ConnectorError, Result};
|
||||
|
||||
// WebSocket state constants
|
||||
#[cfg(feature = "wasm")]
|
||||
#[allow(dead_code)]
|
||||
pub const WEBSOCKET_CONNECTING: u16 = 0;
|
||||
#[cfg(feature = "wasm")]
|
||||
#[allow(dead_code)]
|
||||
pub const WEBSOCKET_OPEN: u16 = 1;
|
||||
#[cfg(feature = "wasm")]
|
||||
#[allow(dead_code)]
|
||||
pub const WEBSOCKET_CLOSING: u16 = 2;
|
||||
#[cfg(feature = "wasm")]
|
||||
#[allow(dead_code)]
|
||||
pub const WEBSOCKET_CLOSED: u16 = 3;
|
||||
|
||||
// Gzip magic header constants
|
||||
pub const GZIP_MAGIC_1: u8 = 0x1f;
|
||||
pub const GZIP_MAGIC_2: u8 = 0x8b;
|
||||
|
||||
/// Unified gzip decompression to string for both networking and WASM
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
pub fn gunzip_to_string(bytes: &[u8]) -> Result<String> {
|
||||
let mut decoder = GzDecoder::new(bytes);
|
||||
let mut decompressed = String::new();
|
||||
decoder
|
||||
.read_to_string(&mut decompressed)
|
||||
.map_err(|e| ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")))?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
/// Unified gzip decompression to bytes for both networking and WASM
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
pub fn gunzip_to_vec(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut decoder = GzDecoder::new(bytes);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.map_err(|e| ConnectorError::protocol_error(format!("Gzip decompression failed: {e}")))?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
/// Unified gzip detection for both networking and WASM
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
pub fn is_gzip(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[0] == GZIP_MAGIC_1 && bytes[1] == GZIP_MAGIC_2
|
||||
}
|
||||
|
||||
/// Unified debug logging for both networking and WASM modes
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
#[allow(dead_code)]
|
||||
pub fn log_debug(message: &str) {
|
||||
#[cfg(feature = "networking")]
|
||||
if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") {
|
||||
eprintln!("{message}");
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
eprintln!("{message}");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! WebSocket connection handling for WASM environments.
|
||||
|
||||
use crate::config::ConnectorConfig;
|
||||
use crate::error::{ConnectorError, Result};
|
||||
use crate::utils::{WEBSOCKET_CLOSED, WEBSOCKET_CLOSING, WEBSOCKET_OPEN};
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::WebSocket;
|
||||
|
||||
/// Connect to the agent using WASM WebSocket
|
||||
pub async fn connect_to_agent(config: &ConnectorConfig) -> Result<WebSocket> {
|
||||
let websocket = WebSocket::new(&config.url).map_err(|e| {
|
||||
ConnectorError::protocol_error(format!("Failed to create WebSocket: {e:?}"))
|
||||
})?;
|
||||
|
||||
// Set binary type for proper message handling
|
||||
websocket.set_binary_type(web_sys::BinaryType::Arraybuffer);
|
||||
|
||||
// Wait for connection to be ready with proper async delays
|
||||
let start_time = js_sys::Date::now();
|
||||
let timeout_ms = 10000.0; // 10 second timeout (increased from 5)
|
||||
|
||||
// Poll connection status until ready or timeout
|
||||
loop {
|
||||
let ready_state = websocket.ready_state();
|
||||
|
||||
if ready_state == WEBSOCKET_OPEN {
|
||||
// OPEN - connection is ready
|
||||
break;
|
||||
} else if ready_state == WEBSOCKET_CLOSED {
|
||||
// CLOSED
|
||||
return Err(ConnectorError::protocol_error(
|
||||
"WebSocket connection closed",
|
||||
));
|
||||
} else if ready_state == WEBSOCKET_CLOSING {
|
||||
// CLOSING
|
||||
return Err(ConnectorError::protocol_error("WebSocket is closing"));
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
let now = js_sys::Date::now();
|
||||
if now - start_time > timeout_ms {
|
||||
return Err(ConnectorError::protocol_error(
|
||||
"WebSocket connection timeout",
|
||||
));
|
||||
}
|
||||
|
||||
// Proper async delay using setTimeout Promise
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED));
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
closure.as_ref().unchecked_ref(),
|
||||
100, // 100ms delay between polls
|
||||
)
|
||||
.unwrap();
|
||||
closure.forget();
|
||||
});
|
||||
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
Ok(websocket)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! WASM module for browser WebSocket connections.
|
||||
|
||||
pub mod connection;
|
||||
pub mod requests;
|
||||
|
||||
pub use connection::*;
|
||||
pub use requests::*;
|
||||
@@ -0,0 +1,398 @@
|
||||
//! WebSocket request handlers for WASM environments.
|
||||
|
||||
use crate::error::{ConnectorError, Result};
|
||||
use crate::pb::Processes;
|
||||
use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip, log_debug};
|
||||
use crate::{AgentRequest, AgentResponse, DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
|
||||
|
||||
use prost::Message as ProstMessage;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::WebSocket;
|
||||
|
||||
/// Send a request and wait for response with binary data handling
|
||||
pub async fn send_request_and_wait(
|
||||
websocket: &WebSocket,
|
||||
request: AgentRequest,
|
||||
) -> Result<AgentResponse> {
|
||||
// Use the legacy string format that the agent expects
|
||||
let request_string = request.to_legacy_string();
|
||||
|
||||
// Send request
|
||||
websocket
|
||||
.send_with_str(&request_string)
|
||||
.map_err(|e| ConnectorError::protocol_error(format!("Failed to send message: {e:?}")))?;
|
||||
|
||||
// Wait for response using JavaScript Promise
|
||||
let (response, binary_data) = wait_for_response_with_binary(websocket).await?;
|
||||
|
||||
// Parse the response based on the request type
|
||||
match request {
|
||||
AgentRequest::Metrics => {
|
||||
// Check if this is binary data (protobuf from agent)
|
||||
if response.starts_with("BINARY_DATA:") {
|
||||
// Extract the byte count
|
||||
let byte_count: usize = response
|
||||
.strip_prefix("BINARY_DATA:")
|
||||
.unwrap_or("0")
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
|
||||
// For now, return a placeholder metrics response indicating binary data received
|
||||
// TODO: Implement proper protobuf decoding for binary data
|
||||
let placeholder_metrics = Metrics {
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![0.0],
|
||||
mem_total: 0,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: format!("Binary protobuf data ({byte_count} bytes)"),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
process_count: None,
|
||||
};
|
||||
Ok(AgentResponse::Metrics(placeholder_metrics))
|
||||
} else {
|
||||
// Try to parse as JSON (fallback)
|
||||
let metrics: Metrics = serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!("Failed to parse metrics: {e}"))
|
||||
})?;
|
||||
Ok(AgentResponse::Metrics(metrics))
|
||||
}
|
||||
}
|
||||
AgentRequest::Disks => {
|
||||
let disks: Vec<DiskInfo> = serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!("Failed to parse disks: {e}"))
|
||||
})?;
|
||||
Ok(AgentResponse::Disks(disks))
|
||||
}
|
||||
AgentRequest::Processes => {
|
||||
log_debug(&format!(
|
||||
"🔍 Processing process request - response: {}",
|
||||
if response.len() > 100 {
|
||||
format!("{}...", &response[..100])
|
||||
} else {
|
||||
response.clone()
|
||||
}
|
||||
));
|
||||
log_debug(&format!(
|
||||
"🔍 Binary data available: {}",
|
||||
binary_data.is_some()
|
||||
));
|
||||
if let Some(ref data) = binary_data {
|
||||
log_debug(&format!("🔍 Binary data size: {} bytes", data.len()));
|
||||
// Check if it's gzipped data and decompress it first
|
||||
if is_gzip(data) {
|
||||
log_debug("🔍 Process data is gzipped, decompressing...");
|
||||
match gunzip_to_vec(data) {
|
||||
Ok(decompressed_bytes) => {
|
||||
log_debug(&format!(
|
||||
"🔍 Successfully decompressed {} bytes, now decoding protobuf...",
|
||||
decompressed_bytes.len()
|
||||
));
|
||||
// Now decode the decompressed bytes as protobuf
|
||||
match <Processes as ProstMessage>::decode(decompressed_bytes.as_slice())
|
||||
{
|
||||
Ok(protobuf_processes) => {
|
||||
log_debug(&format!(
|
||||
"✅ Successfully decoded {} processes from gzipped protobuf",
|
||||
protobuf_processes.rows.len()
|
||||
));
|
||||
|
||||
// Convert protobuf processes to ProcessInfo structs
|
||||
let processes: Vec<ProcessInfo> = protobuf_processes
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|p| ProcessInfo {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let processes_payload = ProcessesPayload {
|
||||
top_processes: processes,
|
||||
process_count: protobuf_processes.process_count as usize,
|
||||
};
|
||||
return Ok(AgentResponse::Processes(processes_payload));
|
||||
}
|
||||
Err(e) => {
|
||||
log_debug(&format!(
|
||||
"❌ Failed to decode decompressed protobuf: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log_debug(&format!(
|
||||
"❌ Failed to decompress gzipped process data: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is binary data (protobuf from agent)
|
||||
if response.starts_with("BINARY_DATA:") {
|
||||
// Extract the binary data size and decode protobuf
|
||||
let byte_count_str = response.strip_prefix("BINARY_DATA:").unwrap_or("0");
|
||||
let _byte_count: usize = byte_count_str.parse().unwrap_or(0);
|
||||
|
||||
// Check if we have the actual binary data
|
||||
if let Some(binary_bytes) = binary_data {
|
||||
log_debug(&format!(
|
||||
"🔧 Decoding {} bytes of protobuf process data",
|
||||
binary_bytes.len()
|
||||
));
|
||||
|
||||
// Try to decode the protobuf data using the prost Message trait
|
||||
match <Processes as ProstMessage>::decode(&binary_bytes[..]) {
|
||||
Ok(protobuf_processes) => {
|
||||
log_debug(&format!(
|
||||
"✅ Successfully decoded {} processes from protobuf",
|
||||
protobuf_processes.rows.len()
|
||||
));
|
||||
|
||||
// Convert protobuf processes to ProcessInfo structs
|
||||
let processes: Vec<ProcessInfo> = protobuf_processes
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|p| ProcessInfo {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let processes_payload = ProcessesPayload {
|
||||
top_processes: processes,
|
||||
process_count: protobuf_processes.process_count as usize,
|
||||
};
|
||||
Ok(AgentResponse::Processes(processes_payload))
|
||||
}
|
||||
Err(e) => {
|
||||
log_debug(&format!("❌ Failed to decode protobuf: {e}"));
|
||||
// Fallback to empty processes
|
||||
let processes = ProcessesPayload {
|
||||
top_processes: vec![],
|
||||
process_count: 0,
|
||||
};
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log_debug(
|
||||
"❌ Binary data indicator received but no actual binary data preserved",
|
||||
);
|
||||
let processes = ProcessesPayload {
|
||||
top_processes: vec![],
|
||||
process_count: 0,
|
||||
};
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
} else {
|
||||
// Try to parse as JSON (fallback)
|
||||
let processes: ProcessesPayload = serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!("Failed to parse processes: {e}"))
|
||||
})?;
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_response_with_binary(websocket: &WebSocket) -> Result<(String, Option<Vec<u8>>)> {
|
||||
let start_time = js_sys::Date::now();
|
||||
let timeout_ms = 10000.0; // 10 second timeout
|
||||
|
||||
// Store the response in a shared location
|
||||
let response_cell = Rc::new(RefCell::new(None::<String>));
|
||||
let binary_data_cell = Rc::new(RefCell::new(None::<Vec<u8>>));
|
||||
let error_cell = Rc::new(RefCell::new(None::<String>));
|
||||
|
||||
// Use a unique request ID to avoid message collision
|
||||
let _request_id = js_sys::Math::random();
|
||||
let response_received = Rc::new(RefCell::new(false));
|
||||
|
||||
// Set up the message handler that only processes if we haven't gotten a response yet
|
||||
{
|
||||
let response_cell = response_cell.clone();
|
||||
let binary_data_cell = binary_data_cell.clone();
|
||||
let response_received = response_received.clone();
|
||||
let onmessage_callback = Closure::wrap(Box::new(move |e: web_sys::MessageEvent| {
|
||||
// Only process if we haven't already received a response for this request
|
||||
if !*response_received.borrow() {
|
||||
// Handle text messages (JSON responses for metrics/disks)
|
||||
if let Ok(data) = e.data().dyn_into::<js_sys::JsString>() {
|
||||
let message = data.as_string().unwrap_or_default();
|
||||
if !message.is_empty() {
|
||||
// Debug: Log what we received (truncated)
|
||||
let preview = if message.len() > 100 {
|
||||
format!("{}...", &message[..100])
|
||||
} else {
|
||||
message.clone()
|
||||
};
|
||||
log_debug(&format!("🔍 Received text: {preview}"));
|
||||
|
||||
*response_cell.borrow_mut() = Some(message);
|
||||
*response_received.borrow_mut() = true;
|
||||
}
|
||||
}
|
||||
// Handle binary messages (could be JSON as text bytes or actual protobuf)
|
||||
else if let Ok(array_buffer) = e.data().dyn_into::<js_sys::ArrayBuffer>() {
|
||||
let uint8_array = js_sys::Uint8Array::new(&array_buffer);
|
||||
let length = uint8_array.length() as usize;
|
||||
let mut bytes = vec![0u8; length];
|
||||
uint8_array.copy_to(&mut bytes);
|
||||
|
||||
log_debug(&format!("🔍 Received binary data: {length} bytes"));
|
||||
|
||||
// Debug: Log the first few bytes to see what we're dealing with
|
||||
let first_bytes = if bytes.len() >= 4 {
|
||||
format!(
|
||||
"0x{:02x} 0x{:02x} 0x{:02x} 0x{:02x}",
|
||||
bytes[0], bytes[1], bytes[2], bytes[3]
|
||||
)
|
||||
} else {
|
||||
format!("Only {} bytes available", bytes.len())
|
||||
};
|
||||
log_debug(&format!("🔍 First bytes: {first_bytes}"));
|
||||
|
||||
// Try to decode as UTF-8 text first (in case it's JSON sent as binary)
|
||||
match String::from_utf8(bytes.clone()) {
|
||||
Ok(text) => {
|
||||
// If it decodes to valid UTF-8, check if it looks like JSON
|
||||
let trimmed = text.trim();
|
||||
if (trimmed.starts_with('{') && trimmed.ends_with('}'))
|
||||
|| (trimmed.starts_with('[') && trimmed.ends_with(']'))
|
||||
{
|
||||
log_debug(&format!(
|
||||
"🔍 Binary data is actually JSON text: {}",
|
||||
if text.len() > 100 {
|
||||
format!("{}...", &text[..100])
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
));
|
||||
*response_cell.borrow_mut() = Some(text);
|
||||
*response_received.borrow_mut() = true;
|
||||
} else {
|
||||
log_debug(&format!(
|
||||
"🔍 Binary data is UTF-8 text but not JSON: {}",
|
||||
if text.len() > 100 {
|
||||
format!("{}...", &text[..100])
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
));
|
||||
*response_cell.borrow_mut() = Some(text);
|
||||
*response_received.borrow_mut() = true;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// If it's not valid UTF-8, check if it's gzipped data
|
||||
if is_gzip(&bytes) {
|
||||
log_debug(&format!(
|
||||
"🔍 Binary data appears to be gzipped ({length} bytes)"
|
||||
));
|
||||
// Try to decompress using unified gzip decompression
|
||||
match gunzip_to_string(&bytes) {
|
||||
Ok(decompressed_text) => {
|
||||
log_debug(&format!(
|
||||
"🔍 Gzipped data decompressed to text: {}",
|
||||
if decompressed_text.len() > 100 {
|
||||
format!("{}...", &decompressed_text[..100])
|
||||
} else {
|
||||
decompressed_text.clone()
|
||||
}
|
||||
));
|
||||
*response_cell.borrow_mut() = Some(decompressed_text);
|
||||
*response_received.borrow_mut() = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log_debug(&format!("🔍 Failed to decompress gzip: {e}"));
|
||||
// Fallback: treat as actual binary protobuf data
|
||||
*binary_data_cell.borrow_mut() = Some(bytes.clone());
|
||||
*response_cell.borrow_mut() =
|
||||
Some(format!("BINARY_DATA:{length}"));
|
||||
*response_received.borrow_mut() = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If it's not valid UTF-8 and not gzipped, it's likely actual binary protobuf data
|
||||
log_debug(&format!(
|
||||
"🔍 Binary data is actual protobuf ({length} bytes)"
|
||||
));
|
||||
*binary_data_cell.borrow_mut() = Some(bytes);
|
||||
*response_cell.borrow_mut() = Some(format!("BINARY_DATA:{length}"));
|
||||
*response_received.borrow_mut() = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log what type of data we got
|
||||
log_debug(&format!("🔍 Received unknown data type: {:?}", e.data()));
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
websocket.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
onmessage_callback.forget();
|
||||
}
|
||||
|
||||
// Set up the error handler
|
||||
{
|
||||
let error_cell = error_cell.clone();
|
||||
let response_received = response_received.clone();
|
||||
let onerror_callback = Closure::wrap(Box::new(move |_e: web_sys::ErrorEvent| {
|
||||
if !*response_received.borrow() {
|
||||
*error_cell.borrow_mut() = Some("WebSocket error occurred".to_string());
|
||||
*response_received.borrow_mut() = true;
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
websocket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
onerror_callback.forget();
|
||||
}
|
||||
|
||||
// Poll for response with proper async delays
|
||||
loop {
|
||||
// Check for response
|
||||
if *response_received.borrow() {
|
||||
if let Some(response) = response_cell.borrow().as_ref() {
|
||||
let binary_data = binary_data_cell.borrow().clone();
|
||||
return Ok((response.clone(), binary_data));
|
||||
}
|
||||
if let Some(error) = error_cell.borrow().as_ref() {
|
||||
return Err(ConnectorError::protocol_error(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
let now = js_sys::Date::now();
|
||||
if now - start_time > timeout_ms {
|
||||
*response_received.borrow_mut() = true; // Mark as done to prevent future processing
|
||||
return Err(ConnectorError::protocol_error("WebSocket response timeout"));
|
||||
}
|
||||
|
||||
// Wait 50ms before checking again
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let closure = Closure::once(move || resolve.call0(&JsValue::UNDEFINED));
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
closure.as_ref().unchecked_ref(),
|
||||
50,
|
||||
)
|
||||
.unwrap();
|
||||
closure.forget();
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use socktop_connector::{
|
||||
AgentRequest, AgentResponse, connect_to_socktop_agent, connect_to_socktop_agent_with_tls,
|
||||
};
|
||||
|
||||
// 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_connector --test integration_test -- --nocapture
|
||||
#[tokio::test]
|
||||
async fn probe_ws_endpoints() {
|
||||
// Gate the test to avoid CI failures when no agent is running.
|
||||
let url = match std::env::var("SOCKTOP_WS") {
|
||||
Ok(v) if !v.is_empty() => v,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"skipping ws_probe: set SOCKTOP_WS=ws://host:port/ws to run this integration test"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Optional pinned CA for WSS/self-signed setups
|
||||
let tls_ca = std::env::var("SOCKTOP_TLS_CA").ok();
|
||||
|
||||
let mut connector = if let Some(ca_path) = tls_ca {
|
||||
connect_to_socktop_agent_with_tls(&url, ca_path, true)
|
||||
.await
|
||||
.expect("connect ws with TLS")
|
||||
} else {
|
||||
connect_to_socktop_agent(&url).await.expect("connect ws")
|
||||
};
|
||||
|
||||
// Should get fast metrics quickly
|
||||
let response = connector.request(AgentRequest::Metrics).await;
|
||||
assert!(response.is_ok(), "expected Metrics payload within timeout");
|
||||
if let Ok(AgentResponse::Metrics(_)) = response {
|
||||
// Success
|
||||
} else {
|
||||
panic!("expected Metrics response");
|
||||
}
|
||||
|
||||
// Processes may be gzipped and a bit slower, but should arrive
|
||||
let response = connector.request(AgentRequest::Processes).await;
|
||||
assert!(
|
||||
response.is_ok(),
|
||||
"expected Processes payload within timeout"
|
||||
);
|
||||
if let Ok(AgentResponse::Processes(_)) = response {
|
||||
// Success
|
||||
} else {
|
||||
panic!("expected Processes response");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Build artifacts
|
||||
/target/
|
||||
/pkg/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backup files
|
||||
*~
|
||||
*.bak
|
||||
Generated
+741
@@ -0,0 +1,741 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[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.4+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.175"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-build"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools",
|
||||
"log",
|
||||
"multimap",
|
||||
"once_cell",
|
||||
"petgraph",
|
||||
"prettyplease",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"regex",
|
||||
"syn",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
|
||||
dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
|
||||
dependencies = [
|
||||
"protoc-bin-vendored-linux-aarch_64",
|
||||
"protoc-bin-vendored-linux-ppcle_64",
|
||||
"protoc-bin-vendored-linux-s390_64",
|
||||
"protoc-bin-vendored-linux-x86_32",
|
||||
"protoc-bin-vendored-linux-x86_64",
|
||||
"protoc-bin-vendored-macos-aarch_64",
|
||||
"protoc-bin-vendored-macos-x86_64",
|
||||
"protoc-bin-vendored-win32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-aarch_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-ppcle_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-s390_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-x86_32"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-x86_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-macos-aarch_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-macos-x86_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-win32"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.143"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socktop_connector"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a63dadaa5105df11b0684759a829012257d48e72a469cc554c0cf4394605f5a"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"protoc-bin-vendored",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socktop_wasm_test"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"getrandom 0.2.16",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socktop_connector",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.4+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36"
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "socktop_wasm_test"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Make this a standalone package, not part of the parent workspace
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# Use WASM features for WebSocket connectivity (published version)
|
||||
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"console",
|
||||
"WebSocket",
|
||||
"MessageEvent",
|
||||
"ErrorEvent",
|
||||
"CloseEvent",
|
||||
"BinaryType",
|
||||
]
|
||||
|
||||
# Enable JS feature for WASM random number generation
|
||||
[dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
@@ -0,0 +1,150 @@
|
||||
# WASM Compatibility Guide for socktop_connector
|
||||
|
||||
This directory contains a complete WebAssembly (WASM) compatibility test and implementation guide for the `socktop_connector` library.
|
||||
|
||||
## Overview
|
||||
|
||||
`socktop_connector` provides **full WebSocket networking support** for WebAssembly environments. The library includes complete connectivity functionality with automatic compression and protobuf decoding, making it easy to connect to socktop agents directly from browser applications.
|
||||
|
||||
## What Works in WASM
|
||||
|
||||
- ✅ **Full WebSocket connections** (`ws://` connections)
|
||||
- ✅ **All request types** (`AgentRequest::Metrics`, `AgentRequest::Disks`, `AgentRequest::Processes`)
|
||||
- ✅ **Automatic data processing**: Gzip decompression for metrics/disks, protobuf decoding for processes
|
||||
- ✅ Configuration types (`ConnectorConfig`)
|
||||
- ✅ Request/Response types (`AgentRequest`, `AgentResponse`)
|
||||
- ✅ JSON serialization/deserialization of all types
|
||||
- ✅ Protocol and version configuration builders
|
||||
- ✅ All type-safe validation and error handling
|
||||
|
||||
## What Doesn't Work in WASM
|
||||
|
||||
- ❌ TLS connections (`wss://`) - use `ws://` only
|
||||
- ❌ TLS certificate handling (use non-TLS endpoints)
|
||||
|
||||
## Quick Start - WASM Test Page
|
||||
|
||||
```bash
|
||||
# Please note that the test assumes you have and agent runnign on your local host at port 3000. If you would like to use an alternate configuration please update lib.rs prior to build.
|
||||
|
||||
# Build the WASM package
|
||||
wasm-pack build --target web --out-dir pkg
|
||||
|
||||
# Serve the test page
|
||||
basic-http-server . --addr 127.0.0.1:8000
|
||||
|
||||
# Open http://127.0.0.1:8000 in your browser
|
||||
# Check the browser console for test results
|
||||
```
|
||||
|
||||
<img src="./screenshot_09092025_134458.jpg" width="85%">
|
||||
|
||||
|
||||
## WASM Dependencies
|
||||
|
||||
The test uses the WASM-compatible networking features:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wasm-bindgen = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = ["console"]
|
||||
```
|
||||
|
||||
**Key**: Use `features = ["wasm"]` to enable full WebSocket networking support in WASM builds.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### 1. Use socktop_connector Types for Configuration
|
||||
|
||||
```rust
|
||||
use wasm_bindgen::prelude::*;
|
||||
use socktop_connector::{ConnectorConfig, AgentRequest, AgentResponse};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn create_config() -> String {
|
||||
// Use socktop_connector types for type-safe configuration
|
||||
let config = ConnectorConfig::new("ws://localhost:3000/ws")
|
||||
.with_protocols(vec!["socktop".to_string(), "v1".to_string()])
|
||||
.with_version("13".to_string());
|
||||
|
||||
// Return JSON for use with browser WebSocket API
|
||||
serde_json::to_string(&config).unwrap_or_default()
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Type-Safe Requests
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub fn create_metrics_request() -> String {
|
||||
let request = AgentRequest::Metrics;
|
||||
serde_json::to_string(&request).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn create_processes_request() -> String {
|
||||
let request = AgentRequest::Processes;
|
||||
serde_json::to_string(&request).unwrap_or_default()
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Parse Responses with Type Safety
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_metrics_response(json: &str) -> Option<String> {
|
||||
match serde_json::from_str::<AgentResponse>(json) {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
Some(format!("CPU: {}%, Memory: {}MB",
|
||||
metrics.cpu_total,
|
||||
metrics.mem_used / 1024 / 1024))
|
||||
}
|
||||
_ => None
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Browser Integration
|
||||
|
||||
Then in JavaScript:
|
||||
|
||||
```javascript
|
||||
import init, {
|
||||
create_config,
|
||||
create_metrics_request,
|
||||
parse_metrics_response
|
||||
} from './pkg/socktop_wasm_test.js';
|
||||
|
||||
async function run() {
|
||||
await init();
|
||||
|
||||
// Use type-safe configuration
|
||||
const configJson = create_config();
|
||||
const config = JSON.parse(configJson);
|
||||
|
||||
// Create WebSocket with proper protocols
|
||||
const ws = new WebSocket(config.url, config.ws_protocols);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send type-safe requests
|
||||
ws.send(create_metrics_request());
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Handle responses with type safety
|
||||
const result = parse_metrics_response(event.data);
|
||||
if (result) {
|
||||
console.log(result);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
run();
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Socktop Connector WASM Test</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 20px; background-color: #f5f5f5; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.log { margin: 5px 0; padding: 5px; border-radius: 4px; }
|
||||
.success { color: #0a7c0a; background-color: #e8f5e8; }
|
||||
.warning { color: #b8860b; background-color: #fdf6e3; }
|
||||
.error { color: #d2322d; background-color: #f9e6e6; }
|
||||
.info { color: #0969da; background-color: #e6f3ff; }
|
||||
button {
|
||||
background: #0969da;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button:hover { background: #0757c7; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.server-input {
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
width: 300px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.input-group { margin: 15px 0; }
|
||||
.input-group label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||
#output {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
min-height: 200px;
|
||||
background: #fafafa;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.status { font-weight: bold; margin: 10px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🦀 Socktop Connector WASM Test</h1>
|
||||
|
||||
<div class="status">
|
||||
<p><strong>Test Purpose:</strong> Verify socktop_connector works in WebAssembly without TLS dependencies</p>
|
||||
<p><strong>Status:</strong> <span id="status">Loading WASM module...</span></p>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="server-url">Server URL:</label>
|
||||
<input type="text" id="server-url" class="server-input" value="ws://localhost:3000/ws"
|
||||
placeholder="ws://localhost:3000/ws">
|
||||
</div>
|
||||
|
||||
<button id="test-btn" disabled>Run WASM Test</button>
|
||||
<button id="clear-btn">Clear Output</button>
|
||||
|
||||
<h3>Output:</h3>
|
||||
<div id="output"></div>
|
||||
|
||||
<h3>ICON LEGEND:</h3>
|
||||
<ul>
|
||||
<li>✅ <strong>Success:</strong> No rustls/TLS errors, connector loads in WASM</li>
|
||||
<li>⚠️ <strong>Expected:</strong> Connection failures without running socktop_agent</li>
|
||||
<li>❌ <strong>Failure:</strong> Build errors or TLS dependency issues</li>
|
||||
</ul>
|
||||
|
||||
<p><small>💡 <strong>Tip:</strong> start socktop_agent with: <code>socktop_agent --port 3000</code></small></p>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import init, { test_socktop_connector } from './pkg/socktop_wasm_test.js';
|
||||
|
||||
const output = document.getElementById('output');
|
||||
const testBtn = document.getElementById('test-btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
// Capture console output and display it on page
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
|
||||
function addLog(text, type = 'info') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `log ${type}`;
|
||||
div.textContent = new Date().toLocaleTimeString() + ' - ' + text;
|
||||
output.appendChild(div);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
console.log = function(...args) {
|
||||
originalLog.apply(console, args);
|
||||
const text = args.join(' ');
|
||||
let type = 'info';
|
||||
if (text.includes('✅')) {
|
||||
type = 'success';
|
||||
} else if (text.includes('⚠️')) {
|
||||
type = 'warning';
|
||||
} else if (text.includes('❌')) {
|
||||
type = 'error';
|
||||
}
|
||||
addLog(text, type);
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
originalError.apply(console, args);
|
||||
addLog('ERROR: ' + args.join(' '), 'error');
|
||||
};
|
||||
|
||||
clearBtn.onclick = () => {
|
||||
output.innerHTML = '';
|
||||
};
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await init();
|
||||
addLog('WASM module initialized successfully!', 'success');
|
||||
status.textContent = 'Ready to test';
|
||||
testBtn.disabled = false;
|
||||
|
||||
testBtn.onclick = () => {
|
||||
testBtn.disabled = true;
|
||||
const serverUrl = document.getElementById('server-url').value.trim();
|
||||
addLog('=== Starting WASM Test ===', 'info');
|
||||
addLog(`🌐 Using server: ${serverUrl}`, 'info');
|
||||
try {
|
||||
test_socktop_connector(serverUrl || undefined);
|
||||
setTimeout(() => {
|
||||
testBtn.disabled = false;
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
addLog('Test execution failed: ' + e.message, 'error');
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
addLog('Failed to initialize WASM: ' + e.message, 'error');
|
||||
status.textContent = 'Failed to load WASM module';
|
||||
console.error('WASM initialization error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@@ -0,0 +1,188 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use socktop_connector::{ConnectorConfig, AgentRequest, SocktopConnector};
|
||||
|
||||
// Import the `console.log` function from the Web API
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console)]
|
||||
fn log(s: &str);
|
||||
}
|
||||
|
||||
// Define a macro for easier console logging
|
||||
macro_rules! console_log {
|
||||
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
|
||||
}
|
||||
|
||||
// This is the main entry point called from JavaScript
|
||||
#[wasm_bindgen]
|
||||
pub fn test_socktop_connector(server_url: Option<String>) {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Use provided URL or default
|
||||
let url = server_url.unwrap_or_else(|| "ws://localhost:3000/ws".to_string());
|
||||
|
||||
console_log!("🦀 Starting WASM connector test...");
|
||||
console_log!("🌐 Connecting to: {}", url);
|
||||
|
||||
// Test 1: Create configuration
|
||||
let config = ConnectorConfig::new(&url);
|
||||
console_log!("✅ Config created: {}", config.url);
|
||||
|
||||
// Test 2: Test configuration methods
|
||||
let config_with_protocols = config
|
||||
.clone()
|
||||
.with_protocols(vec!["socktop".to_string(), "v1".to_string()]);
|
||||
console_log!("✅ Config with protocols: {:?}", config_with_protocols.ws_protocols);
|
||||
|
||||
let config_with_version = config_with_protocols.with_version("13".to_string());
|
||||
console_log!("✅ Config with version: {:?}", config_with_version.ws_version);
|
||||
|
||||
// Test 3: Create request types
|
||||
let _metrics_request = AgentRequest::Metrics;
|
||||
let _disks_request = AgentRequest::Disks;
|
||||
let _processes_request = AgentRequest::Processes;
|
||||
console_log!("✅ AgentRequest types created");
|
||||
|
||||
// Test 4: Test serialization
|
||||
if let Ok(json) = serde_json::to_string(&AgentRequest::Metrics) {
|
||||
console_log!("✅ Serialization works: {}", json);
|
||||
}
|
||||
|
||||
// Test 5: WebSocket connection test
|
||||
console_log!("🌐 Testing WebSocket connection...");
|
||||
|
||||
spawn_local(async move {
|
||||
test_websocket_connection(config_with_version).await;
|
||||
|
||||
console_log!("");
|
||||
console_log!("🎉 socktop_connector WASM Test Results:");
|
||||
console_log!("✅ ConnectorConfig API works in WASM");
|
||||
console_log!("✅ AgentRequest types work in WASM");
|
||||
console_log!("✅ SocktopConnector compiles for WASM");
|
||||
console_log!("✅ Connection stays alive with regular requests");
|
||||
});
|
||||
}
|
||||
|
||||
async fn test_websocket_connection(config: ConnectorConfig) {
|
||||
console_log!("📡 Connecting to agent...");
|
||||
|
||||
let mut connector = SocktopConnector::new(config);
|
||||
|
||||
match connector.connect().await {
|
||||
Ok(()) => {
|
||||
console_log!("✅ Connected!");
|
||||
|
||||
// Test continuous monitoring (5 rounds)
|
||||
for round in 1..=5 {
|
||||
console_log!("🔄 Round {}/5 - Requesting metrics...", round);
|
||||
|
||||
// Request metrics (mimicking TUI behavior)
|
||||
match connector.request(AgentRequest::Metrics).await {
|
||||
Ok(response) => {
|
||||
match &response {
|
||||
socktop_connector::AgentResponse::Metrics(metrics) => {
|
||||
console_log!("✅ Round {} - CPU: {:.1}%, Mem: {:.1}%, Host: {}",
|
||||
round,
|
||||
metrics.cpu_total,
|
||||
(metrics.mem_used as f64 / metrics.mem_total as f64) * 100.0,
|
||||
metrics.hostname
|
||||
);
|
||||
|
||||
// Show JSON summary for each round (clean, collapsible)
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&response) {
|
||||
console_log!("📊 Round {} JSON ({} chars):", round, json_str.len());
|
||||
console_log!("{}", json_str);
|
||||
}
|
||||
},
|
||||
_ => console_log!("📊 Round {} - Received non-metrics response", round),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console_log!("❌ Round {} failed: {}", round, e);
|
||||
console_log!("🔍 Error details: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Every other round, also test disks and processes
|
||||
if round % 2 == 0 {
|
||||
console_log!("💾 Round {} - Requesting disk info...", round);
|
||||
match connector.request(AgentRequest::Disks).await {
|
||||
Ok(response) => {
|
||||
match &response {
|
||||
socktop_connector::AgentResponse::Disks(disks) => {
|
||||
console_log!("✅ Round {} - Got {} disks", round, disks.len());
|
||||
for disk in disks.iter().take(3) { // Show first 3 disks
|
||||
let used_gb = (disk.total - disk.available) / 1024 / 1024 / 1024;
|
||||
let total_gb = disk.total / 1024 / 1024 / 1024;
|
||||
console_log!(" 💿 {}: {}/{} GB used", disk.name, used_gb, total_gb);
|
||||
}
|
||||
},
|
||||
_ => console_log!("❌ Round {} - Unexpected disk response type", round),
|
||||
}
|
||||
},
|
||||
Err(e) => console_log!("❌ Round {} - Disk request failed: {}", round, e),
|
||||
}
|
||||
|
||||
console_log!("⚙️ Round {} - Requesting process info...", round);
|
||||
match connector.request(AgentRequest::Processes).await {
|
||||
Ok(response) => {
|
||||
match &response {
|
||||
socktop_connector::AgentResponse::Processes(processes) => {
|
||||
console_log!("✅ Round {} - Process count: {}, Top processes: {}",
|
||||
round,
|
||||
processes.process_count,
|
||||
processes.top_processes.len()
|
||||
);
|
||||
if processes.top_processes.is_empty() {
|
||||
console_log!("ℹ️ No top processes in response (process_count: {})", processes.process_count);
|
||||
} else {
|
||||
for process in processes.top_processes.iter().take(3) { // Show top 3 processes
|
||||
console_log!(" ⚙️ {}: {:.1}% CPU, {} MB",
|
||||
process.name,
|
||||
process.cpu_usage,
|
||||
process.mem_bytes / 1024 / 1024
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => console_log!("❌ Round {} - Unexpected process response type", round),
|
||||
}
|
||||
},
|
||||
Err(e) => console_log!("❌ Round {} - Process request failed: {}", round, e),
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 1 second between rounds
|
||||
if round < 5 {
|
||||
console_log!("⏱️ Waiting 1 second...");
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let closure = wasm_bindgen::closure::Closure::once(move || resolve.call0(&wasm_bindgen::JsValue::UNDEFINED));
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
closure.as_ref().unchecked_ref(),
|
||||
1000, // 1 second delay
|
||||
)
|
||||
.unwrap();
|
||||
closure.forget();
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
console_log!("");
|
||||
console_log!("🎉 Test completed successfully!");
|
||||
|
||||
// Clean disconnect
|
||||
match connector.disconnect().await {
|
||||
Ok(()) => console_log!("✅ Disconnected"),
|
||||
Err(e) => console_log!("⚠️ Disconnect error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console_log!("❌ Connection failed: {}", e);
|
||||
console_log!("💡 Make sure socktop_agent is running on localhost:3000");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>socktop_connector WASM Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.log {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
height: 500px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
button {
|
||||
background: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:hover {
|
||||
background: #005a87;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🦀 socktop_connector WASM Test</h1>
|
||||
|
||||
<p>This test demonstrates that <code>socktop_connector</code> works properly in WebAssembly with real WebSocket connections.</p>
|
||||
|
||||
<div style="margin: 20px 0; padding: 15px; background: #f9f9f9; border-radius: 4px;">
|
||||
<label for="serverUrl" style="display: block; font-weight: bold; margin-bottom: 5px;">
|
||||
🌐 WebSocket Server URL:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverUrl"
|
||||
value="ws://localhost:3000/ws"
|
||||
style="width: 300px; padding: 8px; margin-right: 10px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
placeholder="ws://your-server:port/ws"
|
||||
/>
|
||||
<small style="color: #666;">Edit if your socktop_agent is running on a different host/port</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onclick="runTest()">Run Test</button>
|
||||
<button onclick="clearLog()">Clear Log</button>
|
||||
</div>
|
||||
|
||||
<h2>Test Output:</h2>
|
||||
<div id="log" class="log">Click "Run Test" to start...\n</div>
|
||||
|
||||
<script type="module">
|
||||
import init, { test_socktop_connector } from './pkg/socktop_wasm_test.js';
|
||||
|
||||
const logElement = document.getElementById('log');
|
||||
let wasmInitialized = false;
|
||||
|
||||
// Override console.log to capture output
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = function(...args) {
|
||||
originalConsoleLog.apply(console, arguments);
|
||||
logElement.textContent += args.join(' ') + '\n';
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
};
|
||||
|
||||
// Initialize WASM module on page load
|
||||
async function initializeWasm() {
|
||||
try {
|
||||
console.log('🔄 Loading WASM module...');
|
||||
await init();
|
||||
wasmInitialized = true;
|
||||
console.log('✅ WASM module initialized successfully!');
|
||||
updateButtonState();
|
||||
} catch (error) {
|
||||
console.log('❌ Failed to initialize WASM module:', error);
|
||||
console.log('💡 Make sure the build was successful and pkg/ directory exists');
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonState() {
|
||||
const button = document.querySelector('button');
|
||||
if (wasmInitialized) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Run Test';
|
||||
button.style.cursor = 'pointer';
|
||||
button.title = 'Click to run the WASM test';
|
||||
} else {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Loading WASM...';
|
||||
button.style.cursor = 'not-allowed';
|
||||
button.title = 'WASM module is loading...';
|
||||
}
|
||||
}
|
||||
|
||||
window.runTest = async function() {
|
||||
if (!wasmInitialized) {
|
||||
console.log('❌ WASM module not initialized yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the server URL from the input field
|
||||
const serverUrl = document.getElementById('serverUrl').value.trim();
|
||||
if (!serverUrl) {
|
||||
console.log('❌ Server URL cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('🚀 Starting socktop_connector WASM test...');
|
||||
console.log('🌐 Server URL: ' + serverUrl);
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
test_socktop_connector(serverUrl);
|
||||
} catch (error) {
|
||||
console.log('❌ Error running test:', error);
|
||||
console.log('🔍 Error details:', error.stack);
|
||||
}
|
||||
};
|
||||
|
||||
window.clearLog = function() {
|
||||
logElement.textContent = 'Click "Run Test" to start...\n';
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
initializeWasm();
|
||||
|
||||
// Update button state initially
|
||||
updateButtonState();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "zellij_socktop_plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
zellij-tile = "0.40.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||
futures = "0.3"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "0.4"
|
||||
default-features = false
|
||||
features = ["clock", "std", "wasmbind"]
|
||||
@@ -0,0 +1,101 @@
|
||||
# Zellij Socktop Plugin
|
||||
|
||||
A Zellij plugin that displays real-time system metrics from a socktop agent.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Build the plugin:**
|
||||
```bash
|
||||
cargo build --target wasm32-wasi --release
|
||||
```
|
||||
|
||||
2. **Install in Zellij:**
|
||||
```bash
|
||||
# Copy the WASM file to Zellij plugins directory
|
||||
mkdir -p ~/.config/zellij/plugins
|
||||
cp target/wasm32-wasi/release/zellij_socktop_plugin.wasm ~/.config/zellij/plugins/socktop.wasm
|
||||
cp plugin.yaml ~/.config/zellij/plugins/
|
||||
```
|
||||
|
||||
3. **Use in Zellij layout:**
|
||||
```yaml
|
||||
# ~/.config/zellij/layouts/socktop.yaml
|
||||
template:
|
||||
direction: Horizontal
|
||||
parts:
|
||||
- direction: Vertical
|
||||
borderless: true
|
||||
split_size:
|
||||
Fixed: 1
|
||||
run:
|
||||
plugin:
|
||||
location: "file:~/.config/zellij/plugins/socktop.wasm"
|
||||
configuration:
|
||||
server_url: "ws://localhost:3000/ws"
|
||||
- direction: Vertical
|
||||
```
|
||||
|
||||
4. **Launch Zellij with the layout:**
|
||||
```bash
|
||||
zellij --layout socktop
|
||||
```
|
||||
|
||||
## Plugin Features
|
||||
|
||||
- **Real-time Metrics**: Displays CPU and memory usage
|
||||
- **Auto-refresh**: Updates every 2 seconds
|
||||
- **Reconnection**: Press 'r' to reconnect to socktop agent
|
||||
- **Configurable**: Set custom server URL in plugin config
|
||||
- **Error Handling**: Shows connection status and errors
|
||||
|
||||
## Configuration Options
|
||||
|
||||
- `server_url`: WebSocket URL for socktop agent (default: `ws://localhost:3000/ws`)
|
||||
|
||||
## Controls
|
||||
|
||||
- **`r`** - Reconnect to socktop agent
|
||||
- Plugin updates automatically every 2 seconds
|
||||
|
||||
## Development Notes
|
||||
|
||||
This is a scaffold implementation. To make it fully functional:
|
||||
|
||||
1. **Async Operations**: Zellij plugins have limitations with async operations. You may need to:
|
||||
- Use a different async runtime or approach
|
||||
- Handle WebSocket connections in a background thread
|
||||
- Use message passing between threads
|
||||
|
||||
2. **Error Handling**: Add more robust error handling for:
|
||||
- Network connectivity issues
|
||||
- Invalid server URLs
|
||||
- Agent unavailability
|
||||
|
||||
3. **UI Improvements**:
|
||||
- Add more detailed metrics display
|
||||
- Implement scrolling for large datasets
|
||||
- Add color coding for status indicators
|
||||
|
||||
4. **Performance**:
|
||||
- Implement caching to reduce agent requests
|
||||
- Add configurable update intervals
|
||||
- Optimize WASM binary size
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `zellij-tile`: Zellij plugin framework
|
||||
- `socktop_connector`: WebSocket connector with WASM support
|
||||
- `serde`: JSON serialization
|
||||
- `chrono`: Time handling (WASM-compatible)
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Add WASM target
|
||||
rustup target add wasm32-wasi
|
||||
|
||||
# Build for WASM
|
||||
cargo build --target wasm32-wasi --release
|
||||
|
||||
# The plugin will be at: target/wasm32-wasi/release/zellij_socktop_plugin.wasm
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
name: "socktop"
|
||||
version: "0.1.0"
|
||||
authors: ["Your Name <your@email.com>"]
|
||||
plugin: true
|
||||
permissions:
|
||||
- ReadApplicationState
|
||||
configuration:
|
||||
server_url:
|
||||
type: "string"
|
||||
default: "ws://localhost:3000/ws"
|
||||
description: "WebSocket URL for socktop agent"
|
||||
@@ -0,0 +1,180 @@
|
||||
use zellij_tile::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use socktop_connector::{ConnectorConfig, AgentRequest, SocktopConnector, AgentResponse};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
connector: Option<SocktopConnector>,
|
||||
metrics_data: Option<String>,
|
||||
connection_status: String,
|
||||
error_message: Option<String>,
|
||||
update_counter: u32,
|
||||
}
|
||||
|
||||
static mut STATE: State = State {
|
||||
connector: None,
|
||||
metrics_data: None,
|
||||
connection_status: String::new(),
|
||||
error_message: None,
|
||||
update_counter: 0,
|
||||
};
|
||||
|
||||
register_plugin!(State);
|
||||
|
||||
impl ZellijPlugin for State {
|
||||
fn load(&mut self, configuration: BTreeMap<String, String>) {
|
||||
// Get server URL from plugin config or use default
|
||||
let server_url = configuration
|
||||
.get("server_url")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "ws://localhost:3000/ws".to_string());
|
||||
|
||||
// Initialize connector configuration
|
||||
let config = ConnectorConfig::new(&server_url);
|
||||
let connector = SocktopConnector::new(config);
|
||||
|
||||
unsafe {
|
||||
STATE.connector = Some(connector);
|
||||
STATE.connection_status = "Connecting...".to_string();
|
||||
}
|
||||
|
||||
// Set up periodic updates
|
||||
set_timeout(1.0); // Update every second
|
||||
|
||||
// Start initial connection
|
||||
self.connect_to_socktop();
|
||||
|
||||
request_permission(&[
|
||||
PermissionType::ReadApplicationState,
|
||||
]);
|
||||
}
|
||||
|
||||
fn update(&mut self, event: Event) -> bool {
|
||||
match event {
|
||||
Event::Timer(_) => {
|
||||
unsafe {
|
||||
STATE.update_counter += 1;
|
||||
}
|
||||
|
||||
// Request metrics every update cycle
|
||||
self.fetch_metrics();
|
||||
|
||||
// Set next timer
|
||||
set_timeout(2.0); // Update every 2 seconds
|
||||
true
|
||||
}
|
||||
Event::Key(key) => {
|
||||
match key {
|
||||
Key::Char('r') => {
|
||||
// Reconnect on 'r' key press
|
||||
self.connect_to_socktop();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, rows: usize, cols: usize) {
|
||||
unsafe {
|
||||
let mut output = Vec::new();
|
||||
|
||||
// Header
|
||||
output.push("╭─ Socktop Metrics Plugin ─╮".to_string());
|
||||
output.push(format!("│ Status: {:<18} │", STATE.connection_status));
|
||||
output.push("├──────────────────────────╯".to_string());
|
||||
|
||||
// Metrics display
|
||||
if let Some(ref metrics) = STATE.metrics_data {
|
||||
output.push("│ System Metrics:".to_string());
|
||||
output.push(format!("│ {}", metrics));
|
||||
} else if let Some(ref error) = STATE.error_message {
|
||||
output.push("│ Error:".to_string());
|
||||
output.push(format!("│ {}", error));
|
||||
} else {
|
||||
output.push("│ Waiting for data...".to_string());
|
||||
}
|
||||
|
||||
// Footer
|
||||
output.push("│".to_string());
|
||||
output.push(format!("│ Updates: {} │ Press 'r' to reconnect", STATE.update_counter));
|
||||
output.push("╰──────────────────────────╯".to_string());
|
||||
|
||||
// Print lines within terminal bounds
|
||||
for (i, line) in output.iter().enumerate() {
|
||||
if i < rows {
|
||||
println!("{}", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn connect_to_socktop(&mut self) {
|
||||
unsafe {
|
||||
if let Some(ref mut connector) = STATE.connector {
|
||||
STATE.connection_status = "Connecting...".to_string();
|
||||
STATE.error_message = None;
|
||||
|
||||
// In a real implementation, you'd use async/await here
|
||||
// For this scaffold, we'll simulate the connection
|
||||
// Note: Zellij plugins have limitations with async operations
|
||||
STATE.connection_status = "Connected".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_metrics(&mut self) {
|
||||
unsafe {
|
||||
if let Some(ref mut connector) = STATE.connector {
|
||||
// Try to get real metrics from socktop agent
|
||||
match self.try_get_metrics(connector) {
|
||||
Ok(metrics_text) => {
|
||||
STATE.metrics_data = Some(metrics_text);
|
||||
STATE.connection_status = "Active".to_string();
|
||||
STATE.error_message = None;
|
||||
}
|
||||
Err(error) => {
|
||||
STATE.error_message = Some(error);
|
||||
STATE.connection_status = "Error".to_string();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
STATE.error_message = Some("No connector available".to_string());
|
||||
STATE.connection_status = "Disconnected".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_get_metrics(&mut self, connector: &mut SocktopConnector) -> Result<String, String> {
|
||||
// Note: This is synchronous for simplicity. In a real plugin you might need
|
||||
// to handle async operations differently depending on Zellij's threading model.
|
||||
|
||||
// For now, we'll use a blocking approach or return a placeholder
|
||||
// that indicates we're trying to connect
|
||||
|
||||
// Attempt connection if not connected
|
||||
if let Err(e) = futures::executor::block_on(connector.connect()) {
|
||||
return Err(format!("Connection failed: {}", e));
|
||||
}
|
||||
|
||||
// Request metrics
|
||||
match futures::executor::block_on(connector.request(AgentRequest::Metrics)) {
|
||||
Ok(AgentResponse::Metrics(metrics)) => {
|
||||
Ok(format!(
|
||||
"CPU: {:.1}% | Mem: {:.1}% | Host: {} | Load: {:.2}",
|
||||
metrics.cpu_total,
|
||||
(metrics.mem_used as f64 / metrics.mem_total as f64) * 100.0,
|
||||
metrics.hostname,
|
||||
metrics.load_avg_1m
|
||||
))
|
||||
}
|
||||
Ok(_) => Err("Unexpected response type".to_string()),
|
||||
Err(e) => Err(format!("Request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user