Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e53d0ab98d | |||
| 2ca51adc61 | |||
| 67ecf36883 | |||
| 9a35306340 | |||
| a4bb6f170a | |||
| f9114426cc | |||
| 8ee2a03a2c | |||
| 0275b1871d | |||
| 9491dc50a8 | |||
| e7eb3e6557 | |||
| a596acfb72 | |||
| b727e54589 | |||
| 2af08c455a | |||
| d049846564 | |||
| 97308f9d15 | |||
| 4cef273e57 | |||
| 660474a6ce | |||
| 93dd14967d | |||
| 923a3872fe | |||
| 5f10e34341 | |||
| b80d322650 | |||
| fff386f9d5 | |||
| 93f4e1feea | |||
| 97255b42fb | |||
| 554a2c349f | |||
| 10501168c5 | |||
| d346c61c28 |
+67
-52
@@ -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,75 +23,89 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
- name: Build (release)
|
||||
run: cargo build --release --workspace
|
||||
- name: Start agent (Ubuntu)
|
||||
|
||||
- name: "Linux: start agent and run WS probe"
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Use debug build for faster startup in CI
|
||||
RUST_LOG=info cargo run -p socktop_agent -- -p 3000 &
|
||||
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=$!
|
||||
echo "AGENT_PID=$AGENT_PID" >> $GITHUB_ENV
|
||||
# Wait for port 3000 to accept connections (30s max)
|
||||
for i in {1..60}; do
|
||||
if bash -lc "</dev/tcp/127.0.0.1/3000" &>/dev/null; then
|
||||
echo "agent is ready"
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
- name: Run WS probe test (Ubuntu)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
env:
|
||||
SOCKTOP_WS: ws://127.0.0.1:3000/ws
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo test -p socktop --test ws_probe -- --nocapture
|
||||
- name: Stop agent (Ubuntu)
|
||||
if: always() && matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${AGENT_PID:-}" ]; then kill $AGENT_PID || true; fi
|
||||
- name: Start agent (Windows)
|
||||
if ! curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
|
||||
echo "--- agent.log (tail) ---"
|
||||
tail -n 200 agent.log || true
|
||||
(command -v ss >/dev/null && ss -ltnp || netstat -ltnp) || true
|
||||
kill $AGENT_PID || true
|
||||
exit 1
|
||||
fi
|
||||
SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop --test ws_probe -- --nocapture
|
||||
kill $AGENT_PID || true
|
||||
|
||||
- name: "Windows: start agent and run WS probe"
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$p = Start-Process -FilePath "cargo" -ArgumentList "run -p socktop_agent -- -p 3000" -PassThru
|
||||
echo "AGENT_PID=$($p.Id)" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
$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++) {
|
||||
if (Test-NetConnection -ComputerName 127.0.0.1 -Port 3000 -InformationLevel Quiet) { $ready = $true; break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
$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"
|
||||
}
|
||||
if (-not $ready) { Write-Error "agent did not become ready" }
|
||||
- name: Run WS probe test (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
|
||||
cargo test -p socktop --test ws_probe -- --nocapture
|
||||
- name: Stop agent (Windows)
|
||||
if: always() && matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:AGENT_PID) { Stop-Process -Id $env:AGENT_PID -Force -ErrorAction SilentlyContinue }
|
||||
try {
|
||||
cargo test -p socktop --test ws_probe -- --nocapture
|
||||
} finally {
|
||||
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
- name: Smoke test (client --help)
|
||||
run: cargo run -p socktop -- --help
|
||||
- name: Package artifacts
|
||||
|
||||
- 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:
|
||||
@@ -99,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
|
||||
|
||||
Generated
+204
@@ -540,6 +540,27 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-next"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys-next"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -591,6 +612,12 @@ 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"
|
||||
@@ -1198,6 +1225,16 @@ dependencies = [
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -1322,6 +1359,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1506,6 +1549,16 @@ version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -1608,6 +1661,122 @@ 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 0.13.0",
|
||||
"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 0.13.0",
|
||||
"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"
|
||||
@@ -1712,6 +1881,17 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
@@ -2042,16 +2222,22 @@ version = "0.1.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crossterm 0.27.0",
|
||||
"dirs-next",
|
||||
"flate2",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"protoc-bin-vendored",
|
||||
"ratatui",
|
||||
"rustls 0.23.31",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
@@ -2065,6 +2251,7 @@ dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"bytes",
|
||||
"flate2",
|
||||
"futures",
|
||||
"futures-util",
|
||||
@@ -2073,6 +2260,10 @@ dependencies = [
|
||||
"nvml-wrapper",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"protoc-bin-vendored",
|
||||
"rustls 0.23.31",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
@@ -2080,6 +2271,7 @@ dependencies = [
|
||||
"sysinfo",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tonic-build",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tungstenite 0.27.0",
|
||||
@@ -2330,6 +2522,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-build"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
|
||||
dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
|
||||
+14
@@ -34,3 +34,17 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# web server (remote-agent)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
# protobuf
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
bytes = "1"
|
||||
dirs-next = "2"
|
||||
|
||||
[profile.release]
|
||||
# Favor smaller, simpler binaries with good runtime perf
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
opt-level = 3
|
||||
strip = "symbols"
|
||||
@@ -94,31 +94,18 @@ cargo build --release
|
||||
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
||||
```
|
||||
|
||||
Tip: Add ?token=... if you enable auth (see Security).
|
||||
### Quick demo (no agent setup)
|
||||
|
||||
TLS quick start (optional, recommended on untrusted networks):
|
||||
|
||||
- Start the agent with TLS enabled (default TLS port 8443). On first run it will generate a self‑signed certificate and key under your config directory.
|
||||
Spin up a temporary local agent on port 3231 and connect automatically:
|
||||
|
||||
```bash
|
||||
./target/release/socktop_agent --enableSSL --port 8443 # or: -p 8443
|
||||
# First run prints the cert and key paths, e.g.:
|
||||
# socktop_agent: generated self-signed TLS certificate at /home/you/.config/socktop_agent/tls/cert.pem
|
||||
# socktop_agent: private key at /home/you/.config/socktop_agent/tls/key.pem
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
- Copy the certificate file to the client machine (keep the key private on the server):
|
||||
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:
|
||||
|
||||
```bash
|
||||
scp /home/you/.config/socktop_agent/tls/cert.pem you@client:/tmp/socktop-agent-ca.pem
|
||||
```
|
||||
|
||||
- Connect with the TUI, pinning the server cert:
|
||||
|
||||
```bash
|
||||
./target/release/socktop --tls-ca /tmp/socktop-agent-ca.pem wss://REMOTE_HOST:8443/ws
|
||||
# Note: if you pass --tls-ca but use ws://, the client auto-upgrades to wss://
|
||||
```
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -186,6 +173,96 @@ The agent stays idle unless queried. When queried, it collects just what’s nee
|
||||
|
||||
---
|
||||
|
||||
## 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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+9
-1
@@ -19,8 +19,16 @@ crossterm = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
dirs-next = { workspace = true }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2.1"
|
||||
prost = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
protoc-bin-vendored = "3"
|
||||
@@ -0,0 +1,8 @@
|
||||
fn main() {
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||
std::env::set_var("PROTOC", protoc);
|
||||
let mut cfg = prost_build::Config::new();
|
||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||
cfg.compile_protos(&["../proto/processes.proto"], &["../proto"])
|
||||
.expect("compile protos");
|
||||
}
|
||||
+36
-2
@@ -63,9 +63,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 {
|
||||
@@ -94,10 +98,29 @@ 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 fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
|
||||
if let Some(m) = metrics_ms {
|
||||
self.metrics_interval = Duration::from_millis(m.max(100));
|
||||
}
|
||||
if let Some(p) = procs_ms {
|
||||
self.procs_interval = Duration::from_millis(p.max(200));
|
||||
}
|
||||
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,
|
||||
@@ -284,7 +307,7 @@ impl App {
|
||||
terminal.draw(|f| self.draw(f))?;
|
||||
|
||||
// Tick rate
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
sleep(self.metrics_interval).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -351,7 +374,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()
|
||||
@@ -471,7 +502,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+343
-19
@@ -2,29 +2,65 @@
|
||||
|
||||
mod app;
|
||||
mod history;
|
||||
mod profiles;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod ws;
|
||||
|
||||
use app::App;
|
||||
use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, ResolveProfile};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
|
||||
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option<String>), String> {
|
||||
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>,
|
||||
}
|
||||
|
||||
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;
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => {
|
||||
return Err(format!(
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
|
||||
));
|
||||
return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--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();
|
||||
}
|
||||
"--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('=') {
|
||||
if !v.is_empty() {
|
||||
@@ -32,37 +68,325 @@ fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<(String, Option
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--profile=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
if !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] ws://HOST:PORT/ws"
|
||||
));
|
||||
return Err(format!("Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match url {
|
||||
Some(u) => Ok((u, tls_ca)),
|
||||
None => Err(format!(
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws"
|
||||
)),
|
||||
}
|
||||
Ok(ParsedArgs {
|
||||
url,
|
||||
tls_ca,
|
||||
profile,
|
||||
save,
|
||||
demo,
|
||||
dry_run,
|
||||
metrics_interval_ms,
|
||||
processes_interval_ms,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Reuse the same parsing logic for testability
|
||||
let (url, tls_ca) = match parse_args(env::args()) {
|
||||
let parsed = match parse_args(env::args()) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
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| 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 idx >= 1 && idx <= names.len() {
|
||||
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.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
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()).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();
|
||||
tokio::select! { res=app.run(&url,None)=>{ 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() {
|
||||
if 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,102 @@
|
||||
//! 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 name = self.profile_name.unwrap();
|
||||
if let Some(entry) = pf.profiles.get(&name) {
|
||||
return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone());
|
||||
} else {
|
||||
return ResolveProfile::PromptCreate(name);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+49
-92
@@ -2,20 +2,24 @@
|
||||
|
||||
use flate2::bufread::GzDecoder;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as _;
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use rustls_pemfile::Item;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::OnceLock;
|
||||
use std::io::Read;
|
||||
use std::{fs::File, io::BufReader, sync::Arc};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{interval, timeout, Duration};
|
||||
use tokio_tungstenite::{
|
||||
connect_async, connect_async_tls_with_config, tungstenite::client::IntoClientRequest,
|
||||
tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use crate::types::{DiskInfo, Metrics, ProcessesPayload};
|
||||
use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
|
||||
|
||||
mod pb {
|
||||
// generated by build.rs
|
||||
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
|
||||
}
|
||||
|
||||
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
@@ -57,16 +61,6 @@ async fn connect_with_ca(url: &str, ca_path: &str) -> Result<WsStream, Box<dyn s
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn debug_on() -> bool {
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| {
|
||||
std::env::var("SOCKTOP_DEBUG")
|
||||
.map(|v| v != "0")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -89,6 +83,16 @@ fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn gunzip_to_vec(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut dec = GzDecoder::new(bytes);
|
||||
let mut out = Vec::new();
|
||||
dec.read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn is_gzip(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
|
||||
}
|
||||
// Suppress dead_code until these are wired into the app
|
||||
#[allow(dead_code)]
|
||||
pub enum Payload {
|
||||
@@ -97,23 +101,6 @@ pub enum Payload {
|
||||
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() {
|
||||
@@ -128,7 +115,7 @@ pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Send a "get_processes" request and await a JSON ProcessesPayload
|
||||
// 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()))
|
||||
@@ -139,68 +126,38 @@ pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<ProcessesPayload>(&s).ok())
|
||||
let gz = is_gzip(&b);
|
||||
let data = if gz { gunzip_to_vec(&b)? } else { b };
|
||||
match pb::Processes::decode(data.as_slice()) {
|
||||
Ok(pb) => {
|
||||
let rows: Vec<ProcessInfo> = pb
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|p: pb::Process| ProcessInfo {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
Some(ProcessesPayload {
|
||||
process_count: pb.process_count as usize,
|
||||
top_processes: rows,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") {
|
||||
eprintln!("protobuf decode failed: {e}");
|
||||
}
|
||||
// Fallback: maybe it's JSON (bytes already decompressed if gz)
|
||||
match String::from_utf8(data) {
|
||||
Ok(s) => serde_json::from_str::<ProcessesPayload>(&s).ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@ fn test_help_mentions_short_and_long_flags() {
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.contains("--tls-ca") && text.contains("-t"),
|
||||
"help text missing --tls-ca/-t\n{text}"
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,4 +56,20 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
|
||||
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,118 @@
|
||||
//! 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();
|
||||
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();
|
||||
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();
|
||||
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"));
|
||||
}
|
||||
@@ -15,7 +15,9 @@ async fn probe_ws_endpoints() {
|
||||
}
|
||||
};
|
||||
|
||||
let mut ws = connect(&url).await.expect("connect ws");
|
||||
// Optional pinned CA for WSS/self-signed setups
|
||||
let tls_ca = std::env::var("SOCKTOP_TLS_CA").ok();
|
||||
let mut ws = connect(&url, tls_ca.as_deref()).await.expect("connect ws");
|
||||
|
||||
// Should get fast metrics quickly
|
||||
let m = request_metrics(&mut ws).await;
|
||||
|
||||
@@ -27,6 +27,14 @@ rustls-pemfile = "2.1"
|
||||
openssl = { version = "0.10", features = ["vendored"] } # for cross‑platform self‑signed generation
|
||||
anyhow = "1"
|
||||
hostname = "0.3"
|
||||
bytes = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
prost-types = { workspace = true }
|
||||
tonic-build = { version = "0.12", default-features = false, optional = true }
|
||||
protoc-bin-vendored = "3"
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3.10"
|
||||
@@ -0,0 +1,11 @@
|
||||
fn main() {
|
||||
// Ensure protoc exists (vendored for reproducible builds)
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||
std::env::set_var("PROTOC", protoc);
|
||||
|
||||
// Compile protobuf definitions for processes
|
||||
let mut cfg = prost_build::Config::new();
|
||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||
cfg.compile_protos(&["../proto/processes.proto"], &["../proto"])
|
||||
.expect("compile protos");
|
||||
}
|
||||
@@ -3,12 +3,13 @@
|
||||
|
||||
mod gpu;
|
||||
mod metrics;
|
||||
mod proto;
|
||||
mod sampler;
|
||||
mod state;
|
||||
mod types;
|
||||
mod ws;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use axum::{http::StatusCode, routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -47,8 +48,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _h_disks = spawn_disks_sampler(state.clone(), std::time::Duration::from_secs(5));
|
||||
|
||||
// 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::ws_handler))
|
||||
.route("/healthz", get(healthz))
|
||||
.with_state(state.clone());
|
||||
|
||||
let enable_ssl =
|
||||
|
||||
@@ -236,9 +236,9 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
|
||||
Some(utime.saturating_add(stime))
|
||||
}
|
||||
|
||||
/// Collect top processes (Linux variant): compute CPU% via /proc jiffies delta.
|
||||
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPayload {
|
||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
// Fresh view to avoid lingering entries and select "no tasks" (no per-thread rows).
|
||||
let mut sys = System::new();
|
||||
sys.refresh_processes_specifics(
|
||||
@@ -291,7 +291,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,13 +317,13 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
|
||||
ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: top_k_sorted(procs, k),
|
||||
top_processes: procs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect top processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
|
||||
/// Collect all processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPayload {
|
||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
use tokio::time::sleep;
|
||||
|
||||
let mut sys = state.sys.lock().await;
|
||||
@@ -344,7 +344,7 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
|
||||
let total_count = sys.processes().len();
|
||||
|
||||
let mut procs: Vec<ProcessInfo> = sys
|
||||
let procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| ProcessInfo {
|
||||
@@ -354,8 +354,6 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
mem_bytes: p.memory(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
procs = top_k_sorted(procs, k);
|
||||
ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: procs,
|
||||
@@ -363,19 +361,4 @@ pub async fn collect_processes_top_k(state: &AppState, k: usize) -> ProcessesPay
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
v.sort_by(|a, b| {
|
||||
b.cpu_usage
|
||||
.partial_cmp(&a.cpu_usage)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
v
|
||||
}
|
||||
// Client now handles sorting/pagination.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+35
-3
@@ -10,7 +10,8 @@ use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
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;
|
||||
|
||||
pub async fn ws_handler(
|
||||
@@ -44,8 +45,39 @@ 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
|
||||
let rows: Vec<pb::Process> = payload
|
||||
.top_processes
|
||||
.into_iter()
|
||||
.map(|p| pb::Process {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
let pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows,
|
||||
};
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else {
|
||||
// compress if large
|
||||
if buf.len() <= 768 {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
|
||||
if enc.write_all(&buf).is_ok() {
|
||||
let bin = enc.finish().unwrap_or(buf);
|
||||
let _ = socket.send(Message::Binary(bin)).await;
|
||||
} else {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
|
||||
Reference in New Issue
Block a user