Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe3ef7f25e | |||
| 4c59716610 | |||
| 0322308896 | |||
| ebda3c51af | |||
| 623a6e5f85 | |||
| 20966d0c94 | |||
| f95a64a18b | |||
| 1c0c44ec3c | |||
| ee4468ca23 | |||
| 697a77bdab | |||
| 8f452a35e6 | |||
| 1d285c3c4e | |||
| 0fb45f6c50 | |||
| be24fa3859 | |||
| 39619b2845 | |||
| 40c925e7f9 | |||
| 8f69e469e6 | |||
| 3024816525 | |||
| 1d7bc42d59 | |||
| 518ae8c2bf | |||
| 6eb1809309 | |||
| 1c01902a71 | |||
| 9d302ad475 | |||
| 7875f132f7 | |||
| 0d789fb97c | |||
| 5ddaed298b | |||
| 1528568c30 | |||
| 6f238cdf25 | |||
| ffe451edaa | |||
| c9bde52cb1 | |||
| 0603746d7c | |||
| 25632f3427 | |||
| e51cdb0c50 | |||
| 1cb05d404b | |||
| 4196066e57 | |||
| 47e96c7d92 | |||
| bae2ecb79a | |||
| bd0d15a1ae | |||
| 689498c5f4 | |||
| 34e260a612 | |||
| 47eff3a75c | |||
| 0210b49219 | |||
| 70a150152c | |||
| f4b54db399 | |||
| e857cfc665 | |||
| e66008f341 | |||
| a238ce320b | |||
| b635f5d7f4 | |||
| 18b41c1b45 | |||
| 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 | |||
| b74242e6d9 | |||
| 4e378b882a | |||
| 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 | |||
| 6a27280f8d | |||
| 38b0cdcf0e | |||
| 268627ed63 | |||
| 55a663cf7a | |||
| 8b76ccb742 | |||
| d0f6cb0e70 | |||
| 56ebe6bbab | |||
| dc90de7ff1 | |||
| 319f47eb73 | |||
| fd2889ccca | |||
| 0859f50897 | |||
| 5c002f0b2b | |||
| 5a824c2098 | |||
| ffb381e40e | |||
| 0a70d7fd39 | |||
| 8d81ee1f7e | |||
| 1e248306a6 | |||
| 7cd6a6e0a1 | |||
| 8f58feffbe | |||
| 5790ef753b | |||
| 9a49fd6b24 | |||
| b35e431200 | |||
| 4b52382326 | |||
| 11506699e3 | |||
| 4c6c707dd0 | |||
| d69a4104fc | |||
| c3f81eef25 | |||
| 05276f9eea | |||
| 0105b29bfc | |||
| 0cbba6b290 | |||
| 250f7bf93a | |||
| 4efeb3b60f | |||
| d20061614c | |||
| 289c9f7ebe | |||
| bdfa74be54 | |||
| 6efdc35b19 | |||
| 20278d67f1 | |||
| a4f69a5f7d | |||
| 9b1643afac | |||
| a9086eac84 | |||
| 4bd6744df4 | |||
| 274a485f8d | |||
| 747aef0005 | |||
| a0e17c6e22 | |||
| 19973c24d8 | |||
| 9a4c8b703e | |||
| 968a25eaf1 | |||
| 13fb22c7ee | |||
| 6c867774f7 | |||
| 7c3e4a6e39 | |||
| 466a32a90a | |||
| cb4882e983 |
@@ -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
|
||||
@@ -0,0 +1,446 @@
|
||||
name: Build Debian Packages
|
||||
|
||||
on:
|
||||
# APT publishing is release-driven: we build + publish only on `v*` tag
|
||||
# pushes. PRs into master still build the .debs as a sanity check (no
|
||||
# publish). Manual dispatch is kept as an escape hatch.
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build-deb:
|
||||
name: Build .deb for ${{ matrix.target }}
|
||||
# PINNED, not ubuntu-latest: the binaries link against this runner's
|
||||
# (multiarch) glibc, so the runner sets the MINIMUM glibc the .debs demand
|
||||
# at install time. ubuntu-latest moved to 24.04/glibc 2.39 and the packages
|
||||
# stopped installing on Debian 12/RPi OS bookworm (glibc 2.36). 22.04 links
|
||||
# 2.35, which bookworm satisfies. The "enforce glibc floor" step below
|
||||
# turns any future violation into a red build instead of a fleet-wide apt
|
||||
# failure — if this pin ever has to move past bookworm's glibc, that step
|
||||
# is the contract to renegotiate first.
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
arch: amd64
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
arch: arm64
|
||||
- target: armv7-unknown-linux-gnueabihf
|
||||
arch: armhf
|
||||
- target: riscv64gc-unknown-linux-gnu
|
||||
arch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y dpkg-dev
|
||||
|
||||
- name: Install cross-compilation tools (ARM64)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo dpkg --add-architecture arm64
|
||||
# Disable all existing sources and create new ones with proper arch specifications
|
||||
sudo mv /etc/apt/sources.list /etc/apt/sources.list.backup
|
||||
sudo mv /etc/apt/sources.list.d /etc/apt/sources.list.d.backup || true
|
||||
sudo mkdir -p /etc/apt/sources.list.d
|
||||
# Clear APT cache and lists
|
||||
sudo rm -rf /var/lib/apt/lists/*
|
||||
sudo mkdir -p /var/lib/apt/lists/partial
|
||||
# Create new sources.list with both amd64 and arm64
|
||||
cat << EOF | sudo tee /etc/apt/sources.list
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc) main universe restricted multiverse
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc)-updates main universe restricted multiverse
|
||||
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc)-backports main universe restricted multiverse
|
||||
deb [arch=amd64] http://security.ubuntu.com/ubuntu $(lsb_release -sc)-security main universe restricted multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc) main universe restricted multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-updates main universe restricted multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-backports main universe restricted multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-security main universe restricted multiverse
|
||||
EOF
|
||||
echo "=== Contents of /etc/apt/sources.list ==="
|
||||
cat /etc/apt/sources.list
|
||||
echo "=== Contents of /etc/apt/sources.list.d/ ==="
|
||||
ls -la /etc/apt/sources.list.d/ || true
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu libdrm-dev:arm64 libdrm-amdgpu1:arm64
|
||||
|
||||
- name: Install cross-compilation tools (ARMhf)
|
||||
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-arm-linux-gnueabihf
|
||||
|
||||
- name: Install cross-compilation tools (RISC-V)
|
||||
if: matrix.target == 'riscv64gc-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-riscv64-linux-gnu
|
||||
|
||||
- name: Install GPU libraries (x86_64)
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libdrm-dev libdrm-amdgpu1
|
||||
|
||||
- name: Configure cross-compilation (ARM64)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat >> .cargo/config.toml << EOF
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
EOF
|
||||
|
||||
- name: Configure cross-compilation (ARMhf)
|
||||
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat >> .cargo/config.toml << EOF
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
EOF
|
||||
|
||||
- name: Configure cross-compilation (RISC-V)
|
||||
if: matrix.target == 'riscv64gc-unknown-linux-gnu'
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat >> .cargo/config.toml << EOF
|
||||
[target.riscv64gc-unknown-linux-gnu]
|
||||
linker = "riscv64-linux-gnu-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache target directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: ${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build socktop .deb package
|
||||
run: |
|
||||
cargo deb --package socktop --target ${{ matrix.target }} --no-strip
|
||||
|
||||
- name: Build socktop_agent .deb package (with GPU support)
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo deb --package socktop_agent --target ${{ matrix.target }} --no-strip
|
||||
|
||||
- name: Build socktop_agent .deb package (without GPU support)
|
||||
if: matrix.target == 'armv7-unknown-linux-gnueabihf' || matrix.target == 'riscv64gc-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo deb --package socktop_agent --target ${{ matrix.target }} --no-strip --no-default-features
|
||||
|
||||
- name: Copy packages to debs directory
|
||||
run: |
|
||||
mkdir -p debs
|
||||
cp target/${{ matrix.target }}/debian/*.deb debs/
|
||||
|
||||
- name: Enforce glibc floor (Debian 12 / RPi OS bookworm fleet)
|
||||
run: |
|
||||
# The fleet's oldest supported glibc. A .deb that demands newer libc6
|
||||
# than this will not install on the Pis — fail HERE, not at apt time.
|
||||
FLOOR="2.36"
|
||||
fail=0
|
||||
for deb in debs/*.deb; do
|
||||
req=$(dpkg-deb -f "$deb" Depends | sed -n 's/.*libc6 (>= \([0-9.]*\)).*/\1/p' | head -1)
|
||||
echo "$deb -> libc6 >= ${req:-none}"
|
||||
if [ -n "$req" ] && [ "$(printf '%s\n' "$req" "$FLOOR" | sort -V | tail -1)" != "$FLOOR" ]; then
|
||||
echo "::error::$deb requires libc6 >= $req, exceeding the fleet floor $FLOOR (bookworm). The build runner's glibc is too new — see the runs-on pin comment."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: List generated packages
|
||||
run: ls -lh debs/
|
||||
|
||||
- name: Upload .deb packages as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debian-packages-${{ matrix.arch }}
|
||||
path: debs/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
# Combine all artifacts into a single downloadable archive
|
||||
combine-artifacts:
|
||||
name: Combine all .deb packages
|
||||
needs: build-deb
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download AMD64 packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: debian-packages-amd64
|
||||
path: all-debs
|
||||
|
||||
- name: Download ARM64 packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: debian-packages-arm64
|
||||
path: all-debs
|
||||
|
||||
- name: Download ARMhf packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: debian-packages-armhf
|
||||
path: all-debs
|
||||
|
||||
- name: Download RISC-V packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: debian-packages-riscv64
|
||||
path: all-debs
|
||||
|
||||
- name: List all packages
|
||||
run: |
|
||||
echo "All generated .deb packages:"
|
||||
ls -lh all-debs/
|
||||
|
||||
- name: Upload combined artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: all-debian-packages
|
||||
path: all-debs/*.deb
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd all-debs
|
||||
sha256sum *.deb > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: checksums
|
||||
path: all-debs/SHA256SUMS
|
||||
retention-days: 90
|
||||
|
||||
# Publish packages to gh-pages APT repository
|
||||
publish-apt-repo:
|
||||
name: Publish to APT Repository
|
||||
needs: combine-artifacts
|
||||
runs-on: ubuntu-latest
|
||||
# Publish only on `v*` release tags — keep gh-pages stable between
|
||||
# releases instead of overwriting same-version .debs on every commit.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: all-debian-packages
|
||||
path: debs
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y dpkg-dev gpg
|
||||
|
||||
- name: Checkout gh-pages branch
|
||||
run: |
|
||||
git fetch origin gh-pages:gh-pages || echo "gh-pages branch doesn't exist yet"
|
||||
if git show-ref --verify --quiet refs/heads/gh-pages; then
|
||||
git checkout gh-pages
|
||||
else
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf . 2>/dev/null || true
|
||||
# Create basic structure
|
||||
mkdir -p dists/stable/main/{binary-amd64,binary-arm64,binary-armhf,binary-riscv64}
|
||||
mkdir -p pool/main
|
||||
fi
|
||||
|
||||
- name: Copy packages to pool
|
||||
run: |
|
||||
mkdir -p pool/main
|
||||
cp debs/*.deb pool/main/
|
||||
ls -lh pool/main/
|
||||
|
||||
- name: Generate Packages files
|
||||
run: |
|
||||
for arch in amd64 arm64 armhf riscv64; do
|
||||
mkdir -p dists/stable/main/binary-$arch
|
||||
dpkg-scanpackages --arch $arch pool/main /dev/null > dists/stable/main/binary-$arch/Packages 2>/dev/null || true
|
||||
if [ -s dists/stable/main/binary-$arch/Packages ]; then
|
||||
gzip -9 -k -f dists/stable/main/binary-$arch/Packages
|
||||
echo "Generated Packages file for $arch"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Generate Release file
|
||||
run: |
|
||||
cat > dists/stable/Release << EOF
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf riscv64
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: $(date -Ru)
|
||||
EOF
|
||||
|
||||
# Add MD5Sum
|
||||
echo "MD5Sum:" >> dists/stable/Release
|
||||
for arch in amd64 arm64 armhf riscv64; do
|
||||
for file in dists/stable/main/binary-$arch/Packages*; do
|
||||
if [ -f "$file" ]; then
|
||||
md5sum "$file" | awk '{print " " $1, "'$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)'", "'"${file#dists/stable/}"'"}' >> dists/stable/Release
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Add SHA256
|
||||
echo "SHA256:" >> dists/stable/Release
|
||||
for arch in amd64 arm64 armhf riscv64; do
|
||||
for file in dists/stable/main/binary-$arch/Packages*; do
|
||||
if [ -f "$file" ]; then
|
||||
sha256sum "$file" | awk '{print " " $1, "'$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)'", "'"${file#dists/stable/}"'"}' >> dists/stable/Release
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
- name: Set GPG available flag
|
||||
id: check_gpg
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -n "$GPG_PRIVATE_KEY" ]; then
|
||||
echo "available=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "available=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import GPG key
|
||||
if: steps.check_gpg.outputs.available == 'true'
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
gpg --list-secret-keys
|
||||
|
||||
- name: Sign repository
|
||||
if: steps.check_gpg.outputs.available == 'true'
|
||||
env:
|
||||
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
if [ -n "$GPG_PASSPHRASE" ]; then
|
||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
-abs -o dists/stable/Release.gpg dists/stable/Release
|
||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
--clearsign -o dists/stable/InRelease dists/stable/Release
|
||||
else
|
||||
gpg --batch --yes --no-tty --pinentry-mode loopback \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
-abs -o dists/stable/Release.gpg dists/stable/Release
|
||||
gpg --batch --yes --no-tty --pinentry-mode loopback \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
--clearsign -o dists/stable/InRelease dists/stable/Release
|
||||
fi
|
||||
gpg --armor --export "$GPG_KEY_ID" > KEY.gpg
|
||||
echo "✓ Repository signed"
|
||||
|
||||
- name: Create unsigned repository notice
|
||||
if: steps.check_gpg.outputs.available == 'false'
|
||||
run: |
|
||||
echo "⚠️ Warning: GPG_PRIVATE_KEY not set. Repository will be UNSIGNED."
|
||||
echo "⚠️ Add GPG secrets to sign the repository automatically."
|
||||
echo "To add secrets: Settings → Secrets and variables → Actions → Repository secrets"
|
||||
|
||||
- name: Copy index.html if exists
|
||||
run: |
|
||||
git checkout ${{ github.ref_name }} -- index.html 2>/dev/null || echo "No index.html in source branch"
|
||||
|
||||
- name: Commit and push to gh-pages
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
git add .
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
COMMIT_MSG="Update APT repository"
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
COMMIT_MSG="$COMMIT_MSG - Release ${{ github.ref_name }}"
|
||||
else
|
||||
COMMIT_MSG="$COMMIT_MSG - $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
fi
|
||||
git commit -m "$COMMIT_MSG"
|
||||
git push origin gh-pages
|
||||
echo "✓ Published to gh-pages"
|
||||
fi
|
||||
|
||||
# Optional: Create a release with the .deb files if this is a tag
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
needs: combine-artifacts
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: all-debian-packages
|
||||
path: release-debs
|
||||
|
||||
- name: Download checksums
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: checksums
|
||||
path: release-debs
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: release-debs/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,18 +2,128 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
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 (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdrm-dev libdrm-amdgpu1
|
||||
- name: Cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -D warnings
|
||||
- name: Build
|
||||
run: cargo build --release --workspace
|
||||
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 (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p dist
|
||||
cp target/release/socktop dist/
|
||||
cp target/release/socktop_agent dist/
|
||||
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
|
||||
|
||||
- name: Package artifacts (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
Copy-Item target\release\socktop.exe dist\
|
||||
Copy-Item target\release\socktop_agent.exe dist\
|
||||
Compress-Archive -Path dist\* -DestinationPath socktop-${{ matrix.os }}.zip -Force
|
||||
|
||||
- name: Upload build artifacts (ephemeral)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: socktop-${{ matrix.os }}
|
||||
path: |
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
- name: Upload to rolling GitHub Release (main only)
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: latest
|
||||
name: Latest build
|
||||
prerelease: true
|
||||
draft: false
|
||||
files: |
|
||||
*.tar.gz
|
||||
*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
/target
|
||||
# Any crate's build directory, including standalone sub-crates
|
||||
# (zellij_socktop_plugin, socktop_wasm_test) that live outside the workspace.
|
||||
target/
|
||||
.vscode/
|
||||
/.cargo/
|
||||
|
||||
# Documentation files from development sessions (context-specific, not for public repo)
|
||||
/OPTIMIZATION_PROCESS_DETAILS.md
|
||||
/THREAD_SUPPORT.md
|
||||
|
||||
# APT Repository - Safety: Never commit private keys!
|
||||
*.asc
|
||||
*-private.key
|
||||
*-secret.key
|
||||
gpg-private-backup.key
|
||||
secring.gpg
|
||||
# Note: Release.gpg, InRelease, and KEY.gpg (public) ARE safe to commit
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## 1.60.1 — unreleased
|
||||
|
||||
Identical to 1.60.0 plus rebuilt Debian packages: the 1.60.0 debs were linked
|
||||
against glibc 2.39 (a GitHub runner migration) and would not install on
|
||||
Debian 12 / Raspberry Pi OS bookworm. CI now pins the build environment and
|
||||
gates every package against the fleet's glibc floor. 1.60.0 was never
|
||||
published to crates.io.
|
||||
|
||||
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.1 together.
|
||||
|
||||
### Security
|
||||
|
||||
- **Certificate pinning is now real.** With `--verify-hostname` off (the default), the client previously accepted *any* server certificate — the `--tls-ca` file was never consulted. The presented certificate must now be byte-identical to one in the pinned PEM (multi-cert files supported for rotation). If you use TLS, update the client: earlier versions are MITM-able despite the pinning documentation. (housekeeping-p2)
|
||||
- `key.pem` is created with mode 0600 (was world-readable 0644); agents also tighten existing keys on startup. (housekeeping-p2)
|
||||
- The agent's per-PID caches now evict (60s age / 64 entries); previously they grew without bound. (housekeeping-p2)
|
||||
|
||||
### Performance
|
||||
|
||||
- Agent CPU on GPU machines cut ~6× (measured 23.5 → 4.0 ms/s at default polling): GPU collection moved to a dedicated worker thread that keeps the NVML session open instead of re-initializing it every 1.5 s on the async runtime. (housekeeping-p2)
|
||||
- `journalctl` no longer blocks the agent's async workers. (housekeeping-p2)
|
||||
- Cached "no temp sensor / no GPU" results count as fresh — no more per-request rescans on hosts without them. (housekeeping-p2)
|
||||
- Nagle disabled on all connection paths (small request/response frames). (housekeeping-p2)
|
||||
|
||||
### TUI
|
||||
|
||||
- **Compact layout for small windows**: when the window is too short for the Disks pane, Disks is dropped, Memory/Swap go side by side, GPU collapses to one line (omitted if absent), and the reclaimed rows keep the CPU graph and per-core bars visible. `--compact` pins it. (#37)
|
||||
- **Width-aware text**: header, CPU title, and process table shed detail by priority as the terminal narrows instead of overwriting each other; process Name column is now the last to go, not the first. Fixed sort-header clicks landing up to 4 columns off. (#38)
|
||||
- **Responsive input**: keys and mouse are handled within ~30 ms instead of queueing for a full metrics interval. (housekeeping-p2)
|
||||
- **No more freezes**: all requests carry a 5 s timeout; a dead connection shows the reconnect modal (with working `q`) instead of hanging the UI. Consecutive timeouts surface a persistent "agent not responding" error. (housekeeping-p2)
|
||||
- Old agents without the per-process endpoints once again show "Agent Update Required" instead of a reconnect loop. (housekeeping-p2)
|
||||
- Journal pane distinguishes "no entries" from "no journal access" (e.g. user-run/demo agents) and shows journalctl's hint plus the fix. (housekeeping-p2)
|
||||
- Scatter-plot axes align correctly for large CPU-time values. (housekeeping-p2)
|
||||
- Demo mode explains how to install `socktop_agent` when the binary is missing. (#36)
|
||||
|
||||
### Correctness
|
||||
|
||||
- Process/child CPU times were sent as ms but displayed as µs — values rendered 1000× too small in the details modal. (housekeeping-p2)
|
||||
- Non-Linux per-process CPU% no longer truncates multi-core usage (clamp after divide). (housekeeping-p2)
|
||||
- Journal timestamps are real RFC 3339 UTC with numeric sorting (additive `timestamp_us`). (housekeeping-p2)
|
||||
- Partition detection uses `/sys/block` on Linux — whole-disk filesystems (`nvme0n1`, `zram1`) are no longer misclassified as partitions. (housekeeping-p2)
|
||||
- Network rates use agent-side sample timestamps (additive `sampled_at_ms`), eliminating rate sawtooth from TTL-cached snapshots; falls back to the client clock with older agents. (housekeeping-p2)
|
||||
- The details modal's Command/exe/cwd fields are populated again (dropped by an earlier refresh optimization). (housekeeping-p2)
|
||||
- Non-ASCII device names no longer panic the disk pane. (housekeeping-p2)
|
||||
|
||||
### Wire format (additive only — old/new client-agent pairs keep working)
|
||||
|
||||
- `Metrics.sampled_at_ms` (epoch ms of actual collection)
|
||||
- `JournalEntry.timestamp_us` (epoch µs), `JournalEntry.timestamp` now RFC 3339
|
||||
- `JournalResponse.notice` (journal-access hint)
|
||||
|
||||
### Internal / packaging
|
||||
|
||||
- ratatui 0.28 → 0.30 (#33); aws-lc-rs advisories patched (#34); Debian packaging for the agent (#25); assorted dependabot bumps.
|
||||
- ~3,100 lines of dead code removed, including an orphaned pre-refactor copy of the connector.
|
||||
- `socktop` consumes `socktop_connector` via a path+version dep — connector changes are testable in-repo before publishing.
|
||||
- wasm examples build against the in-repo connector; note `zellij_socktop_plugin` has pre-existing compile errors and needs its own rework.
|
||||
|
||||
### Process kill (PR #40)
|
||||
|
||||
- **Kill a local process from the TUI** (`t` on a selected process, or inside Process Details): btop-style Terminate/Force-kill confirmation. Local agents only — the signal is sent by socktop itself with its own privileges, never over the wire; remote agents never show the option. PID-reuse guarded (the confirmed name must still own the PID at signal time).
|
||||
- **Agent no longer reports dead processes**: a long-lived sysinfo `System` accumulated every process ever seen (21k+ entries on a 289-process host), inflating memory, per-poll work, and the process count — and keeping killed processes on screen forever. Update agent and client together on machines where the kill feature will be used.
|
||||
- Killed rows leave the list when the process actually exits and cannot be resurrected by cached agent snapshots; details views for dead processes close themselves, including through parent-navigation chains.
|
||||
- Selection hint no longer vanishes for long process names; confirmation/info dialogs size to their content.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Release/publish order**: `socktop_connector` → `socktop` → agent packages.
|
||||
- Clients older than 1.60 work against 1.60 agents and vice versa; the security fix is client-side, so prioritize client updates where TLS is used.
|
||||
@@ -1,35 +1,51 @@
|
||||
[workspace]
|
||||
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"] }
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
unicode-width = "0.2"
|
||||
|
||||
# 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"
|
||||
@@ -0,0 +1,156 @@
|
||||
# Debian Packaging Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented Debian packaging for socktop using `cargo-deb`, with GitHub Actions automation for building packages for both AMD64 and ARM64 architectures.
|
||||
|
||||
## Branches Created
|
||||
|
||||
1. **`feature/debian-packaging`** - Main branch with debian packaging implementation
|
||||
2. **`feature/man-pages`** - Separate branch for man pages work (to be researched further)
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. Cargo.toml Updates
|
||||
|
||||
Both `socktop/Cargo.toml` and `socktop_agent/Cargo.toml` were updated with:
|
||||
- `[package.metadata.deb]` sections
|
||||
- Package metadata (maintainer, description, dependencies)
|
||||
- Asset definitions (binaries, documentation)
|
||||
- Systemd service configuration (agent only)
|
||||
|
||||
### 2. Systemd Service
|
||||
|
||||
**File**: `socktop_agent/socktop-agent.service`
|
||||
- Runs as `socktop` user/group
|
||||
- Listens on port 3000 by default
|
||||
- Security hardening enabled
|
||||
- Disabled by default (user must explicitly enable)
|
||||
|
||||
### 3. Maintainer Scripts
|
||||
|
||||
**Directory**: `socktop_agent/debian/`
|
||||
|
||||
- **`postinst`**: Creates `socktop` user/group, sets up `/var/lib/socktop` directory
|
||||
- **`postrm`**: Cleanup on package removal/purge
|
||||
|
||||
### 4. GitHub Actions Workflow
|
||||
|
||||
**File**: `.github/workflows/build-deb.yml`
|
||||
|
||||
Features:
|
||||
- Builds for both x86_64 and ARM64
|
||||
- Triggered on:
|
||||
- Push to `master` or `feature/debian-packaging`
|
||||
- Pull requests to `master`
|
||||
- Version tags (v*)
|
||||
- Manual workflow dispatch
|
||||
- Creates artifacts:
|
||||
- `debian-packages-amd64`
|
||||
- `debian-packages-arm64`
|
||||
- `all-debian-packages` (combined)
|
||||
- `checksums` (SHA256SUMS)
|
||||
- Automatic GitHub releases for version tags
|
||||
|
||||
### 5. Documentation
|
||||
|
||||
**File**: `docs/DEBIAN_PACKAGING.md`
|
||||
|
||||
Comprehensive guide covering:
|
||||
- Building packages locally
|
||||
- Cross-compilation for ARM64
|
||||
- Installation and configuration
|
||||
- Using GitHub Actions artifacts
|
||||
- Creating local APT repositories
|
||||
- Troubleshooting
|
||||
|
||||
## Package Details
|
||||
|
||||
### socktop (TUI Client)
|
||||
- **Binary**: `/usr/bin/socktop`
|
||||
- **Size**: ~3.5 MB (x86_64)
|
||||
- **Dependencies**: Auto-detected
|
||||
|
||||
### socktop_agent (Daemon)
|
||||
- **Binary**: `/usr/bin/socktop_agent`
|
||||
- **Service**: `socktop-agent.service`
|
||||
- **User/Group**: `socktop` (created automatically)
|
||||
- **State directory**: `/var/lib/socktop`
|
||||
- **Size**: ~6.7 MB (x86_64)
|
||||
- **Dependencies**: Auto-detected
|
||||
|
||||
## Testing
|
||||
|
||||
Both packages successfully built locally:
|
||||
```
|
||||
✓ socktop_1.50.0-1_amd64.deb
|
||||
✓ socktop-agent_1.50.1-1_amd64.deb
|
||||
```
|
||||
|
||||
Verified:
|
||||
- Package contents (dpkg -c)
|
||||
- Package metadata (dpkg -I)
|
||||
- Systemd service file inclusion
|
||||
- Maintainer scripts inclusion
|
||||
- Documentation inclusion
|
||||
|
||||
## Usage
|
||||
|
||||
### For Users
|
||||
|
||||
Download pre-built packages from GitHub Actions artifacts:
|
||||
1. Go to Actions tab
|
||||
2. Select latest "Build Debian Packages" run
|
||||
3. Download architecture-specific artifact
|
||||
4. Install: `sudo dpkg -i socktop*.deb`
|
||||
|
||||
### For Developers
|
||||
|
||||
Build locally:
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
```
|
||||
|
||||
Cross-compile for ARM64:
|
||||
```bash
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
cargo deb --package socktop --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
To get packages in official APT repositories:
|
||||
|
||||
1. **Short term**: Host packages on GitHub Releases (automated)
|
||||
2. **Medium term**: Create PPA for Ubuntu users
|
||||
3. **Long term**: Submit to Debian/Ubuntu official repositories
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
```
|
||||
Modified:
|
||||
socktop/Cargo.toml
|
||||
socktop_agent/Cargo.toml
|
||||
|
||||
Created:
|
||||
.github/workflows/build-deb.yml
|
||||
docs/DEBIAN_PACKAGING.md
|
||||
socktop_agent/socktop-agent.service
|
||||
socktop_agent/debian/postinst
|
||||
socktop_agent/debian/postrm
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
532ed16 Add Debian packaging support with cargo-deb
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
|
||||
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
|
||||
- Full documentation in `docs/DEBIAN_PACKAGING.md`
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Witty One Off
|
||||
|
||||
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.
|
||||
@@ -1,201 +1,424 @@
|
||||
# socktop
|
||||
|
||||
**socktop** is a remote system monitor with a rich TUI interface, inspired by `top` and `btop`, that communicates with a lightweight remote agent over WebSockets.
|
||||
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
|
||||
|
||||
It lets you watch CPU, memory, disks, network, temperatures, and processes on another machine in real-time — from the comfort of your terminal.
|
||||
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||

|
||||
[socktop.io](https://www.socktop.io)
|
||||
|
||||
<img src="./docs/socktop_demo.apng" width="100%">
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 📡 **Remote monitoring** via WebSocket — lightweight agent sends JSON metrics
|
||||
- 🖥 **Rich TUI** built with [ratatui](https://github.com/ratatui-org/ratatui)
|
||||
- 🔍 **Detailed CPU view** — per-core history, current load, and trends
|
||||
- 📊 **Memory, Swap, Disk usage** — human-readable units, color-coded
|
||||
- 🌡 **Temperatures** — CPU temperature with visual indicators
|
||||
- 📈 **Network throughput** — live sparkline graphs with peak tracking
|
||||
- 🏷 **Top processes table** — PID, name, CPU%, memory, and memory%
|
||||
- 🎨 Color-coded load, zebra striping for readability
|
||||
- ⌨ **Keyboard shortcuts**:
|
||||
- `q` / `Esc` → Quit
|
||||
- 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
|
||||
- Accurate per-process CPU% (Linux /proc deltas), normalized to 0–100%
|
||||
- Memory/Swap gauges with human units
|
||||
- Disks: per-device usage
|
||||
- Network: per-interface throughput with sparklines and peak markers
|
||||
- Temperatures: CPU (optional)
|
||||
- Top processes (top 50)
|
||||
- PID, name, CPU%, memory, and memory%
|
||||
- Click-to-sort by CPU% or Mem (descending)
|
||||
- Scrollbar and mouse/keyboard scrolling
|
||||
- Total process count shown in the header
|
||||
- Only top-level processes listed (threads hidden) — matches btop/top
|
||||
- Optional GPU metrics (can be disabled)
|
||||
- Optional auth token for the agent
|
||||
- Compact layout for small windows: automatically drops the panes that no longer fit so
|
||||
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites: Install Rust (rustup)
|
||||
|
||||
Rust is fast, safe, and cross‑platform. Installing it will make your machine better. Consider yourself privileged.
|
||||
|
||||
Linux/macOS:
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
# load cargo for this shell
|
||||
source "$HOME/.cargo/env"
|
||||
# ensure stable is up to date
|
||||
rustup update stable
|
||||
rustc --version
|
||||
cargo --version
|
||||
# after install you may need to reload your shell, e.g.:
|
||||
exec bash # or: exec zsh / exec fish
|
||||
```
|
||||
|
||||
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, you’ll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
|
||||
|
||||
### Raspberry Pi / Ubuntu / PopOS (required for GPU support)
|
||||
|
||||
**Note:** GPU monitoring is only supported on x86_64 and aarch64 (64-bit ARM) platforms. ARMv7 (32-bit) and RISC-V builds do not include GPU support.
|
||||
|
||||
For 64-bit systems with GPU support:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
For ARMv7 (32-bit Raspberry Pi), build with `--no-default-features` to disable GPU support:
|
||||
|
||||
```bash
|
||||
cargo build --release -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
_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
|
||||
|
||||
`socktop` has **two components**:
|
||||
Two components:
|
||||
|
||||
1. **Agent** (remote side)
|
||||
A small Rust WebSocket server that runs on the target machine and gathers metrics via [sysinfo](https://crates.io/crates/sysinfo).
|
||||
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 side)
|
||||
The TUI app (`socktop`) that connects to the agent’s `/ws` endpoint, receives JSON metrics, and renders them.
|
||||
|
||||
The two communicate over a persistent WebSocket connection.
|
||||
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
|
||||
|
||||
---
|
||||
|
||||
## Adaptive (idle-aware) sampling
|
||||
## Quick start
|
||||
|
||||
The socktop agent now samples system metrics only when at least one WebSocket client is connected. When idle (no clients), the sampler sleeps and CPU usage drops to ~0%.
|
||||
- Build both binaries:
|
||||
|
||||
How it works
|
||||
- The WebSocket handler increments/decrements a client counter in `AppState` on connect/disconnect.
|
||||
- A background sampler wakes when the counter transitions from 0 → >0 and sleeps when it returns to 0.
|
||||
- The most recent metrics snapshot is cached as JSON for fast responses.
|
||||
|
||||
Cold start behavior
|
||||
- If a client requests metrics while the cache is empty (e.g., just started or after a long idle), the agent performs a one-off synchronous collection to respond immediately.
|
||||
|
||||
Tuning
|
||||
- Sampling interval (active): update `spawn_sampler(state, Duration::from_millis(500))` in `socktop_agent/src/main.rs`.
|
||||
- Always-on or low-frequency idle sampling: replace the “sleep when idle” logic in `socktop_agent/src/sampler.rs` with a low-frequency interval. Example sketch:
|
||||
|
||||
```rust
|
||||
// In sampler.rs (sketch): sample every 10s when idle, 500ms when active
|
||||
let idle_period = Duration::from_secs(10);
|
||||
loop {
|
||||
let active = state.client_count.load(Ordering::Relaxed) > 0;
|
||||
let period = if active { Duration::from_millis(500) } else { idle_period };
|
||||
let mut ticker = tokio::time::interval(period);
|
||||
ticker.tick().await;
|
||||
if !active {
|
||||
// wake early if a client connects
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => {},
|
||||
_ = state.wake_sampler.notified() => continue,
|
||||
}
|
||||
}
|
||||
let m = collect_metrics(&state).await;
|
||||
if let Ok(js) = serde_json::to_string(&m) {
|
||||
*state.last_json.write().await = js;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Rust 1.75+ (recommended latest stable)
|
||||
- Cargo package manager
|
||||
|
||||
### Build from source
|
||||
```bash
|
||||
git clone https://github.com/YOURNAME/socktop.git
|
||||
git clone https://github.com/jasonwitty/socktop.git
|
||||
cd socktop
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Install as a cargo binary
|
||||
- Start the agent on the target machine (default port 3000):
|
||||
|
||||
```bash
|
||||
cargo install --path .
|
||||
./target/release/socktop_agent --port 3000
|
||||
```
|
||||
This will install the `socktop` binary into `~/.cargo/bin`.
|
||||
|
||||
- Connect with the TUI from your local machine:
|
||||
|
||||
```bash
|
||||
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Running
|
||||
## Install (from crates.io)
|
||||
|
||||
### 1. Start the agent on the remote machine
|
||||
The agent binary listens on a TCP port and serves `/ws`:
|
||||
You don’t need to clone this repo to use socktop. Install the published binaries with cargo:
|
||||
|
||||
```bash
|
||||
remote_agent 0.0.0.0:8080
|
||||
# TUI (client)
|
||||
cargo install socktop
|
||||
# Agent (server)
|
||||
cargo install socktop_agent
|
||||
```
|
||||
|
||||
> **Tip:** You can run the agent under `systemd`, inside a Docker container, or just in a tmux/screen session.
|
||||
This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
|
||||
|
||||
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.
|
||||
|
||||
System-wide agent (Linux)
|
||||
|
||||
### 2. Connect with the client
|
||||
From your local machine:
|
||||
```bash
|
||||
socktop ws://REMOTE_HOST:8080/ws
|
||||
# If you installed with cargo, binaries are in ~/.cargo/bin
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
|
||||
# Install and enable the systemd service (example unit in docs/)
|
||||
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
socktop ws://192.168.1.50:8080/ws
|
||||
|
||||
# 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
|
||||
|
||||
When connected, `socktop` displays:
|
||||
Agent (server):
|
||||
|
||||
**Left column:**
|
||||
- **CPU avg graph** — sparkline of recent overall CPU usage
|
||||
- **Memory gauge** — total and used RAM
|
||||
- **Swap gauge** — total and used swap
|
||||
- **Disks** — usage per device (only devices with available space > 0)
|
||||
- **Network Download/Upload** — sparkline in KB/s, with current & peak values
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
**Right column:**
|
||||
- **Per-core history & trends** — each core’s recent load, current %, and trend arrow
|
||||
- **Top processes table** — top 20 processes with PID, name, CPU%, memory usage, and memory%
|
||||
Client (TUI):
|
||||
|
||||
```bash
|
||||
socktop ws://HOST:3000/ws
|
||||
# with token:
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
# TLS with pinned server certificate (recommended over the internet):
|
||||
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# (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
|
||||
# force the small-window layout at any terminal size (normally automatic):
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Intervals (client-driven):
|
||||
- Fast metrics: ~500 ms
|
||||
- Processes: ~2 s
|
||||
- Disks: ~5 s
|
||||
|
||||
The agent stays idle unless queried. When queried, it collects just what’s needed.
|
||||
|
||||
---
|
||||
|
||||
## Configuring the agent port
|
||||
## Compact mode
|
||||
|
||||
The agent listens on TCP port 3000 by default. You can override this via a CLI flag, a positional port argument, or an environment variable:
|
||||
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
|
||||
are the first things to collapse — exactly the panes you are most likely watching. Once
|
||||
the window is too short for the Disks pane to show even one disk, socktop switches to a
|
||||
compact layout:
|
||||
|
||||
- CLI flag:
|
||||
- socktop_agent --port 8080
|
||||
- socktop_agent -p 8080
|
||||
- Positional:
|
||||
- socktop_agent 8080
|
||||
- Environment variable:
|
||||
- SOCKTOP_PORT=8080 socktop_agent
|
||||
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
|
||||
- **Memory and Swap move side by side** into the row Disks vacated.
|
||||
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
|
||||
with no GPU the pane disappears entirely.
|
||||
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
|
||||
below the size where they used to vanish.
|
||||
|
||||
Help:
|
||||
- socktop_agent --help
|
||||
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
|
||||
layout at any window size:
|
||||
|
||||
The TUI should point to ws://HOST:PORT/ws, e.g.:
|
||||
- cargo run -p socktop -- ws://127.0.0.1:8080/ws
|
||||
```bash
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
## Connection Profiles (Named)
|
||||
|
||||
| Key | Action |
|
||||
|-------------|------------|
|
||||
| `q` or `Esc`| Quit |
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Security (optional token)
|
||||
By default, the agent exposes metrics over an unauthenticated WebSocket. For untrusted networks, set an auth token and pass it in the client URL:
|
||||
## Updating
|
||||
|
||||
- Server:
|
||||
- SOCKTOP_TOKEN=changeme socktop_agent --port 3000
|
||||
- Client:
|
||||
- socktop ws://HOST:3000/ws?token=changeme
|
||||
Update the agent (systemd):
|
||||
|
||||
```bash
|
||||
# on the server running the agent
|
||||
cargo install socktop_agent --force
|
||||
sudo systemctl stop socktop-agent
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
# if you changed the unit file:
|
||||
# sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
# sudo systemctl daemon-reload
|
||||
sudo systemctl start socktop-agent
|
||||
sudo systemctl status socktop-agent --no-pager
|
||||
# logs:
|
||||
# journalctl -u socktop-agent -f
|
||||
```
|
||||
|
||||
Update the TUI (client):
|
||||
```bash
|
||||
cargo install socktop --force
|
||||
socktop ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
- Linux x86_64/AMD/Intel: fully supported.
|
||||
- Raspberry Pi:
|
||||
- 64-bit: rustup target add aarch64-unknown-linux-gnu; build on-device for simplicity.
|
||||
- 32-bit: rustup target add armv7-unknown-linux-gnueabihf.
|
||||
- Windows:
|
||||
- TUI and agent build/run with stable Rust. Use PowerShell:
|
||||
- cargo run -p socktop_agent -- --port 3000
|
||||
- cargo run -p socktop -- ws://127.0.0.1:3000/ws
|
||||
- CPU temperature may be unavailable; display will show N/A.
|
||||
## Configuration (agent)
|
||||
|
||||
- Port:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## Keyboard & Mouse
|
||||
|
||||
- Quit: q or Esc
|
||||
- Processes pane:
|
||||
- Click “CPU %” to sort by CPU descending
|
||||
- Click “Mem” to sort by memory descending
|
||||
- Mouse wheel: scroll
|
||||
- Drag scrollbar: scroll
|
||||
- Arrow/PageUp/PageDown/Home/End: scroll
|
||||
|
||||
---
|
||||
|
||||
## Example agent JSON
|
||||
`socktop` expects the agent to send metrics in this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"sampled_at_ms": 1786752000123,
|
||||
"cpu_total": 12.4,
|
||||
"cpu_per_core": [11.2, 15.7, ...],
|
||||
"cpu_per_core": [11.2, 15.7],
|
||||
"mem_total": 33554432,
|
||||
"mem_used": 18321408,
|
||||
"swap_total": 0,
|
||||
@@ -207,42 +430,165 @@ By default, the agent exposes metrics over an unauthenticated WebSocket. For unt
|
||||
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
|
||||
"top_processes": [
|
||||
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
|
||||
]
|
||||
],
|
||||
"gpus": null
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- process_count is merged into the main metrics on the client when processes are polled.
|
||||
- top_processes are the current top 50 (sorting in the TUI is client-side).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Set a token on the agent and pass it as a query param from the client:
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
|
||||
```
|
||||
|
||||
Client:
|
||||
|
||||
```bash
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
```
|
||||
|
||||
### TLS / WSS
|
||||
|
||||
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
|
||||
|
||||
Server (generates 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; instead the client PINS the certificate: the agent must present a cert byte-identical to one in your `--tls-ca` file (expiry is ignored in this mode — you pinned that exact cert). Use `--verify-hostname` to switch to strict chain + SAN validation instead.
|
||||
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
|
||||
|
||||
---
|
||||
|
||||
## Using tmux to monitor multiple hosts
|
||||
|
||||
You can use tmux to show multiple socktop instances in a single terminal.
|
||||
|
||||

|
||||
monitoring 4 Raspberry Pis using Tmux
|
||||
|
||||
Prerequisites:
|
||||
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
|
||||
|
||||
Key bindings (defaults):
|
||||
- Split left/right: Ctrl-b %
|
||||
- Split top/bottom: Ctrl-b "
|
||||
- Move between panes: Ctrl-b + Arrow keys
|
||||
- Show pane numbers: Ctrl-b q
|
||||
- Close a pane: Ctrl-b x
|
||||
- Detach from session: Ctrl-b d
|
||||
|
||||
Two panes (left/right)
|
||||
- This creates a session named "socktop", splits it horizontally, and starts two socktops.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-layout even-horizontal \; \
|
||||
attach
|
||||
```
|
||||
|
||||
Four panes (top-left, top-right, bottom-left, bottom-right)
|
||||
- This creates a 2x2 grid with one socktop per pane.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
|
||||
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
|
||||
select-layout tiled \; \
|
||||
attach
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Replace HOST1..HOST4 (and ports) with your targets.
|
||||
- Reattach later: `tmux attach -t socktop`
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Linux: fully supported (agent and client).
|
||||
- Raspberry Pi:
|
||||
- 64-bit: aarch64-unknown-linux-gnu
|
||||
- 32-bit: armv7-unknown-linux-gnueabihf
|
||||
- Windows:
|
||||
- TUI + agent can build with stable Rust; bring your own MSVC. You’re on Windows; you know the drill.
|
||||
- CPU temperature may be unavailable.
|
||||
- binary exe for both available in build artifacts under actions.
|
||||
- macOS:
|
||||
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Run in debug mode:
|
||||
```bash
|
||||
cargo run -- ws://127.0.0.1:8080/ws
|
||||
```
|
||||
|
||||
### Code formatting & lint:
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
cargo clippy --all-targets --all-features
|
||||
cargo run -p socktop -- ws://127.0.0.1:3000/ws
|
||||
# TLS (dev): first run will create certs under ~/.config/socktop_agent/tls/
|
||||
cargo run -p socktop_agent -- --enableSSL --port 8443
|
||||
```
|
||||
|
||||
### 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
|
||||
- [ ] Configurable refresh interval
|
||||
- [ ] Filter/sort top processes in the TUI
|
||||
|
||||
- [x] Agent authentication (token)
|
||||
- [x] Hide per-thread entries; only show processes
|
||||
- [x] Sort top processes in the TUI
|
||||
- [x] Configurable refresh intervals (client)
|
||||
- [ ] Export metrics to file
|
||||
- [ ] TLS / WSS support
|
||||
- [ ] Agent authentication
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
MIT License — see [LICENSE](LICENSE).
|
||||
|
||||
MIT — see LICENSE.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
- [`ratatui`](https://github.com/ratatui-org/ratatui) for terminal UI rendering
|
||||
- [`sysinfo`](https://crates.io/crates/sysinfo) for system metrics
|
||||
- [`tokio-tungstenite`](https://crates.io/crates/tokio-tungstenite) for WebSocket client/server
|
||||
|
||||
- ratatui for the TUI
|
||||
- sysinfo for system metrics
|
||||
- tokio-tungstenite for WebSockets
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGNBGkih7QBDADgX6sYMx2Lp6qcZxeCCizcy4TFsxcRJfp5mfbMplVES0hQToIP
|
||||
EMC11JqPwQdLliXKjUr8Z2kgM2oqvH+dkdgzUGrw6kTK8YHc+qs37iJAOVS9D72X
|
||||
tTld282NrtFwzb74nS2GKPkpWI7aSKBpHtWFPX/1ONsc56qGqFd3wwikEvCz8MeJ
|
||||
HwCD1JZ9F+2DyyXWsTJNgDwPloJSUbtyVuk2gd6PeTg7AQdx92Pk/mggmYbHtP8N
|
||||
wy072ku1g8K/hplmwIOGpSx1JWvAQkDU/Bb/jSqrYg2wSHO7IQnYE8I3x/zglYBl
|
||||
FYNh47TVQr0zPVSYR1MQkHU5YLBTDc5UgDvtcsYUiTtq4D/m8HWmKja0/UKGxvDJ
|
||||
P5sUPcp4dk77RdoCtUe5HImYGS8lo5N3+t0lz8sd9rYmRiIO4f7FJaJqJeHbUJyn
|
||||
iw/GCQh5D5/D571dICrEq/QhL+k5KhJljPGoVMGPFXJIc7q+CxvGp2oOo5fOlbOn
|
||||
3kSrM93AJPwT8FMAEQEAAbRFSmFzb24gV2l0dHkgKHNvY2t0b3AgYXB0IHNpZ25p
|
||||
bmcga2V5KSA8amFzb25wd2l0dHkrc29ja3RvcEBwcm90b24ubWU+iQHOBBMBCgA4
|
||||
FiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkih7QCGwMFCwkIBwIGFQoJCAsCBBYC
|
||||
AwECHgECF4AACgkQESwaeYRl+/KV+gwAzfZVZEhO7MQV2EmNeKVK1GycFSm2oUAl
|
||||
ZbwNIEHu6+tOzqXJb8o65BtGlbLSGavsMpgRCK2SL83DdLOkutG1ahQiJr+5GaXC
|
||||
zbQgX+VWqGPZtQ+I6/rVoYZPMTCrqpAmFgvVpqv0xod7w8/wny8/XmhQ37KY2/0l
|
||||
B38oNTvdA7C8jzSrI6kr3XqurvQRW7z+MnC+nCp9Ob9bYtY0kpd4U3NrVdb8m32U
|
||||
d5LVFwD1OGvzLOSqyJ33IKjSJc4KLvW+aEsHXe+fHO9UEzH8Nbo5MmVvX3QIHiyq
|
||||
jD4zN16AGsGYqCK4irtQCiD3wBOdsG/RVkgIcdlmAH3EGEp7Ux8+7v1PXYI+UrSs
|
||||
XE7f1xFTJ2r5TMex6W3he073Em4qhQsrnMF5syTZsM6N+5UqXVOM1RuDVVXr7929
|
||||
hC3G8pK/A2W5Lwpxl2yzock2CxhvUn7M/xm4VbcPlWTCUd/QzU8VtsgaGHcuhi5e
|
||||
xHY1AU07STLB9RinjBVf2bmk4oDQcmB6uQGNBGkih7QBDACrjE+xSWP92n931/5t
|
||||
+tXcujwFlIpSZdbSQFr0B0YyjPRUP4FSzEGu8vuM5ChUfWKhmN1dDr5C4qFo9NgQ
|
||||
6oCN2HubajSGyXNwnOMlMb5ck79Ubmy9yDV9/ZLqpJJiozGap2/EnNoDhaANlmUg
|
||||
rfqUHpIB8XC2IZ0Itt05tp/u78dJiB+R6ReZn/bVUafNV4jIqYZfLRzI3FTJ4xvK
|
||||
FGs/ER+JajAdJQ8LPfazmDQSGw0huguxhopZwKQ/qWZMn1OHq/ZaPvCqbQt3irLw
|
||||
dLPDC4pEaYGRyADYeyuarG0DVyUQ9XRc/NufKDvOAn33LpBPBpcvNQAsVhWTCYl7
|
||||
ogQ+suVYVN8Tu7v4bUSHKwzXKvLN/ojJX/Fh7eTW4TPsgLHNHAEDUkSQozIe9vO6
|
||||
o+vydDqRxuXJgdkR7lqP6PQDYrhRYZGJf57eKf6VtTKYFaMbiMWPU+vcHeB0/iDe
|
||||
Pv81qro2LD2PG5WCzDpNETBceCTjykb9r0VHx4/JsiojKmsAEQEAAYkBtgQYAQoA
|
||||
IBYhBB51VqgFObg5S8KCDREsGnmEZfvyBQJpIoe0AhsMAAoJEBEsGnmEZfvyNp8M
|
||||
AIH+6+hGB3qADdnhNgb+3fN0511eK9Uk82lxgGARLcD8GN1UP0HlvEqkxCHy3PUe
|
||||
tHcsuYVz7i8pmpEGdFx9zv7MelenUsJniUQ++OZKx6iUG/MYqz//NxY+5lyRmcu2
|
||||
aYvUxhkgf9zgxXTkTyV2VV32mX//cHcwc+c/089QAPzCMaSrHdNK+ED9+k8uquJ1
|
||||
lSL9Bm15z/EV42v9Q/4KTM5OBLHpNw0Rvn9C0iuZVwHXBrrA/HSGXpA54AqNUMpZ
|
||||
kRPgLQcy5yVE2y1aXLXt2XdTn6YPzrAjNoazYYuCWHYIZU7dGkIswpsDirDLKHdD
|
||||
onb3VShmSpemYjsuFiqhfi6qwCkeHsz/CpQAp70SZ+z9oB8H80PJVKPbPIP3zEf3
|
||||
i7bcsqHA7stF+8sJclXgxBUBeDJ3O2jN/scBOcvNA6xoRp7+oJbnjDRuxBmh+fVg
|
||||
TIuw2++vTF2Ml0EMv7ePTpr7b1DofuJRNYGkuAIMVXHjLTqMiTJUce3OUy003zMg
|
||||
Dg==
|
||||
=AaPQ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -0,0 +1,38 @@
|
||||
# socktop APT Repository
|
||||
|
||||
This repository contains Debian packages for socktop and socktop-agent.
|
||||
|
||||
## Adding this repository
|
||||
|
||||
Add the repository to your system:
|
||||
|
||||
```bash
|
||||
# Add the GPG key
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
# Add the repository
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Update and install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
```
|
||||
|
||||
## Manual Installation
|
||||
|
||||
You can also download and install packages manually from the `pool/main/` directory.
|
||||
|
||||
```bash
|
||||
wget https://jasonwitty.github.io/socktop/pool/main/socktop_VERSION_ARCH.deb
|
||||
sudo dpkg -i socktop_VERSION_ARCH.deb
|
||||
```
|
||||
|
||||
## Supported Architectures
|
||||
|
||||
- amd64 (x86_64)
|
||||
- arm64 (aarch64)
|
||||
- armhf (32-bit ARM)
|
||||
|
||||
## Building from Source
|
||||
|
||||
See the main repository at https://github.com/jasonwitty/socktop
|
||||
@@ -0,0 +1,32 @@
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: Sun, 23 Nov 2025 04:05:21 +0000
|
||||
MD5Sum:
|
||||
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
|
||||
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
|
||||
SHA256:
|
||||
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
|
||||
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
|
||||
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAEBCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAYACgkQESwaeYRl
|
||||
+/KBsAv/eYhnK/XrNtPhLyw/zX2cGfUtBsBZrypFhV/n+TvudAIwQaqxDEvLlBUn
|
||||
HBAhMKDQXGs7V45+nOgDX4rKWUqJh4SPbJgNbVte2PX7U+hsMpZBsYp3vkjApgTO
|
||||
pq2CCkViyBXgTY+6vUigtvfJ9afTTWI6Qm4dLXZ7hxErBxgHQyowOoO/sF92cNOu
|
||||
AosBMpE+qSy7sVqJU5g/JXJh0kddKFotXHSGA1kFMzJafJC/n5nLrusDzFJRQqyH
|
||||
Io+6inYWjlb5o79z0tJzAvG1mgplLRppMBjoVJ/RJ+gT+QE70kokR6wvsgDqsKNd
|
||||
mvB0TNj0zY0g6Is6V3XMyf0u+6BtLTbua913HPiqBfErgeV58vzsst+y0It42TXi
|
||||
aw+UF2Kw/YhPq1rZFxgnAVcMja3qlXWpH57gmgIPovBCsPsiywWiHLsSHRzAI22b
|
||||
zeTsUST/4toR/ruZVbUZvWoWAR4tzsSuwXJFx/hhinTQQTNHErXASOX986UaL9L7
|
||||
o2/pTKLe
|
||||
=IeBY
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,14 @@
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: Sun, 23 Nov 2025 04:05:21 +0000
|
||||
MD5Sum:
|
||||
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
|
||||
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
|
||||
SHA256:
|
||||
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
|
||||
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
|
||||
@@ -0,0 +1,14 @@
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAABCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAEACgkQESwaeYRl
|
||||
+/KzeAv+OUIbxud5FboerwpAJULV+rS3+VX4kvwg/daVZ3yX3tJNrsyNCHgmWLVu
|
||||
fLeEFFc2Ax9GvFW4jrbxRAGD+3TXQEEFkb5lGzYyDjlgVzR6wLiVTTrmzWoK+cbB
|
||||
4DMozqeLiZFfQjq4UFn3+mwiYFX9Dj7PVF0M60XAUJSObbJFmaEPZIfx6wcZfkiL
|
||||
lLLk1eeU5MPiyudPOhVGgaD76KrUCw+8DBNKoCKIEcCY0LvuKtUK8mWYXRSPSved
|
||||
4Znd3QZz063Z6R+Lj1XlGLoTPResna28T/Nca+2JgLhbrihsLMcHoFxmrvFP9FpT
|
||||
MChKngj7NnGt0yqHH5J16hdwMra/vvhmF0yoQ0loIcy+q06tYEqOcau8tvAjfbId
|
||||
k3rgQgnxxVE8WUmV9Bugp7jhNMO+ImKWMwzEr6wGd9ZHqpknUlAaWeO73VP+qtAN
|
||||
6mEqWhkqvXGg+srH6qp3Sg0W28dYG29X3Kx8jOp7HeyvA/gLZRN7L+bq/XaA7WFA
|
||||
1hba6LIY
|
||||
=QoLf
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,38 @@
|
||||
Package: socktop
|
||||
Version: 1.50.0-1
|
||||
Architecture: amd64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 3459
|
||||
Filename: pool/main/socktop_1.50.0-1_amd64.deb
|
||||
Size: 1278940
|
||||
MD5sum: 0215e178e306d9379669065e8c78582b
|
||||
SHA1: 04e0416389f5cecd584fd1f6b3568711f2645eee
|
||||
SHA256: 69eb04b1de48541c95950a97b16357fcd9c51ffaceb143f63de4a9d758fad297
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Homepage: https://github.com/jasonwitty/socktop
|
||||
Description: Remote system monitor over WebSocket, TUI like top
|
||||
socktop is a remote system monitor with a rich terminal user interface (TUI)
|
||||
that connects to remote hosts running the socktop_agent over WebSocket. It
|
||||
provides real-time monitoring of CPU, memory, processes, and more with an
|
||||
interface similar to the traditional 'top' command.
|
||||
|
||||
Package: socktop-agent
|
||||
Version: 1.50.2-1
|
||||
Architecture: amd64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 6793
|
||||
Filename: pool/main/socktop-agent_1.50.2-1_amd64.deb
|
||||
Size: 1896272
|
||||
MD5sum: 22e78d03e83dcf84d6ec4a009b285902
|
||||
SHA1: 26a9f4fedfdba06a047044027223f2944cf72ba6
|
||||
SHA256: 11922af475146f60347a9c52cff4bbce1ce524bdb4293b2c436f3c71876e17d5
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Homepage: https://github.com/jasonwitty/socktop
|
||||
Description: Socktop agent daemon. Serves host metrics over WebSocket.
|
||||
socktop_agent is the daemon component that runs on remote hosts to collect and
|
||||
serve system metrics over WebSocket. It gathers CPU, memory, disk, network,
|
||||
GPU, and process information that can be monitored remotely by the socktop TUI
|
||||
client.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Archive: stable
|
||||
Component: main
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Architecture: amd64
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>socktop APT Repository</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1 { color: #333; }
|
||||
h2 { color: #555; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>socktop APT Repository</h1>
|
||||
<p>System monitor with remote agent support for Linux systems.</p>
|
||||
|
||||
<h2>Adding this repository</h2>
|
||||
<pre><code># Add the GPG key
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
# Add the repository
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Update and install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent</code></pre>
|
||||
|
||||
<h2>Manual Installation</h2>
|
||||
<p>Download packages from <a href="pool/main/">pool/main/</a></p>
|
||||
|
||||
<h2>Supported Architectures</h2>
|
||||
<ul>
|
||||
<li>amd64 (x86_64)</li>
|
||||
<li>arm64 (aarch64)</li>
|
||||
<li>armhf (32-bit ARM)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Source Code</h2>
|
||||
<p>Visit the <a href="https://github.com/jasonwitty/socktop">GitHub repository</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 775 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,274 @@
|
||||
# Debian Packaging for socktop
|
||||
|
||||
This document describes how to build and use Debian packages for socktop and socktop_agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install `cargo-deb`:
|
||||
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
```
|
||||
|
||||
## Building Packages Locally
|
||||
|
||||
### Build for your current architecture (x86_64)
|
||||
|
||||
```bash
|
||||
# Build socktop TUI client
|
||||
cargo deb --package socktop
|
||||
|
||||
# Build socktop_agent daemon
|
||||
cargo deb --package socktop_agent
|
||||
```
|
||||
|
||||
The `.deb` files will be created in `target/debian/`.
|
||||
|
||||
### Cross-compile for ARM64 (Raspberry Pi, etc.)
|
||||
|
||||
First, install cross-compilation tools:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
```
|
||||
|
||||
Add the ARM64 target:
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Configure the linker by creating `.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
```
|
||||
|
||||
Build the packages:
|
||||
|
||||
```bash
|
||||
# Build for ARM64
|
||||
cargo deb --package socktop --target aarch64-unknown-linux-gnu
|
||||
cargo deb --package socktop_agent --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
## Installing Packages
|
||||
|
||||
### Install socktop TUI client
|
||||
|
||||
```bash
|
||||
sudo dpkg -i socktop_*.deb
|
||||
```
|
||||
|
||||
### Install socktop_agent daemon
|
||||
|
||||
```bash
|
||||
sudo dpkg -i socktop_agent_*.deb
|
||||
```
|
||||
|
||||
The agent package will:
|
||||
- Create a `socktop` system user and group
|
||||
- Install the binary to `/usr/bin/socktop_agent`
|
||||
- Install a systemd service file (disabled by default)
|
||||
- Create `/var/lib/socktop` for state files
|
||||
|
||||
### Enable and start the agent service
|
||||
|
||||
```bash
|
||||
# Enable to start on boot
|
||||
sudo systemctl enable socktop-agent
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# Check status
|
||||
sudo systemctl status socktop-agent
|
||||
```
|
||||
|
||||
### Configure the agent
|
||||
|
||||
Edit the systemd service to customize settings:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit socktop-agent
|
||||
```
|
||||
|
||||
Add configuration in the override section:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=SOCKTOP_PORT=8080
|
||||
Environment=SOCKTOP_TOKEN=your-secret-token
|
||||
Environment=RUST_LOG=info
|
||||
```
|
||||
|
||||
Then restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart socktop-agent
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
The project includes a GitHub Actions workflow (`.github/workflows/build-deb.yml`) that automatically builds `.deb` packages for both x86_64 and ARM64 architectures on every push to master or when tags are created.
|
||||
|
||||
### Downloading pre-built packages
|
||||
|
||||
1. Go to the [Actions tab](https://github.com/jasonwitty/socktop/actions)
|
||||
2. Click on the latest "Build Debian Packages" workflow run
|
||||
3. Download the artifacts:
|
||||
- `debian-packages-amd64` - x86_64 packages
|
||||
- `debian-packages-arm64` - ARM64 packages
|
||||
- `all-debian-packages` - All packages combined
|
||||
- `checksums` - SHA256 checksums
|
||||
|
||||
### Release packages
|
||||
|
||||
When you create a git tag starting with `v` (e.g., `v1.50.0`), the workflow will automatically create a GitHub Release with all `.deb` packages attached.
|
||||
|
||||
```bash
|
||||
git tag v1.50.0
|
||||
git push origin v1.50.0
|
||||
```
|
||||
|
||||
## Package Details
|
||||
|
||||
### socktop package
|
||||
|
||||
- **Binary**: `/usr/bin/socktop`
|
||||
- **Documentation**: `/usr/share/doc/socktop/`
|
||||
- **Size**: ~5-8 MB (depends on architecture)
|
||||
|
||||
### socktop_agent package
|
||||
|
||||
- **Binary**: `/usr/bin/socktop_agent`
|
||||
- **Service**: `socktop-agent.service`
|
||||
- **User/Group**: `socktop`
|
||||
- **State directory**: `/var/lib/socktop`
|
||||
- **Config directory**: `/etc/socktop` (created but empty by default)
|
||||
- **Documentation**: `/usr/share/doc/socktop_agent/`
|
||||
- **Size**: ~5-8 MB (depends on architecture)
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
# Remove packages but keep configuration
|
||||
sudo apt remove socktop socktop_agent
|
||||
|
||||
# Remove packages and all configuration (purge)
|
||||
sudo apt purge socktop socktop_agent
|
||||
```
|
||||
|
||||
When purging `socktop_agent`, the following are removed:
|
||||
- The `socktop` user and group
|
||||
- `/var/lib/socktop` directory
|
||||
- Empty `/etc/socktop` directory (if empty)
|
||||
|
||||
## Verifying Packages
|
||||
|
||||
Check package contents:
|
||||
|
||||
```bash
|
||||
dpkg -c socktop_*.deb
|
||||
dpkg -c socktop_agent_*.deb
|
||||
```
|
||||
|
||||
Check package information:
|
||||
|
||||
```bash
|
||||
dpkg -I socktop_*.deb
|
||||
dpkg -I socktop_agent_*.deb
|
||||
```
|
||||
|
||||
After installation, verify files:
|
||||
|
||||
```bash
|
||||
dpkg -L socktop
|
||||
dpkg -L socktop-agent
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service fails to start
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u socktop-agent -f
|
||||
```
|
||||
|
||||
Verify the socktop user exists:
|
||||
|
||||
```bash
|
||||
id socktop
|
||||
```
|
||||
|
||||
### Permission issues
|
||||
|
||||
Ensure the state directory has correct permissions:
|
||||
|
||||
```bash
|
||||
sudo chown -R socktop:socktop /var/lib/socktop
|
||||
sudo chmod 755 /var/lib/socktop
|
||||
```
|
||||
|
||||
### Missing dependencies
|
||||
|
||||
If installation fails due to missing dependencies:
|
||||
|
||||
```bash
|
||||
sudo apt --fix-broken install
|
||||
```
|
||||
|
||||
## Creating a Local APT Repository (Advanced)
|
||||
|
||||
To create your own APT repository for easy installation:
|
||||
|
||||
1. Install required tools:
|
||||
```bash
|
||||
sudo apt install dpkg-dev
|
||||
```
|
||||
|
||||
2. Create repository structure:
|
||||
```bash
|
||||
mkdir -p ~/socktop-repo/pool/main
|
||||
cp *.deb ~/socktop-repo/pool/main/
|
||||
```
|
||||
|
||||
3. Generate package index:
|
||||
```bash
|
||||
cd ~/socktop-repo
|
||||
dpkg-scanpackages pool/main /dev/null | gzip -9c > pool/main/Packages.gz
|
||||
```
|
||||
|
||||
4. Serve via HTTP (for testing):
|
||||
```bash
|
||||
cd ~/socktop-repo
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
|
||||
5. Add to sources on client machines:
|
||||
```bash
|
||||
echo "deb [trusted=yes] http://your-server:8000 pool/main/" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features that affect packaging:
|
||||
|
||||
1. Update `Cargo.toml` metadata in the `[package.metadata.deb]` section
|
||||
2. Add new assets to the `assets` array if needed
|
||||
3. Update maintainer scripts in `socktop_agent/debian/` if needed
|
||||
4. Test package building locally before committing
|
||||
5. Update this documentation
|
||||
|
||||
## References
|
||||
|
||||
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
|
||||
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
|
||||
- [systemd service files](https://www.freedesktop.org/software/systemd/man/systemd.service.html)
|
||||
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,207 @@
|
||||
# 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.
|
||||
|
||||
**Note:** GPU monitoring support is not available on ARMv7 (32-bit) and RISC-V architectures due to library limitations. When building for these platforms, the `--no-default-features` flag must be used to disable GPU support.
|
||||
|
||||
## 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)
|
||||
# Note: GPU support not available on armv7
|
||||
sudo apt update
|
||||
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
||||
```
|
||||
|
||||
### 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)
|
||||
# Note: GPU support not available on armv7
|
||||
sudo pacman -S arm-linux-gnueabihf-gcc
|
||||
```
|
||||
|
||||
### 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 (without GPU support)
|
||||
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 --no-default-features
|
||||
```
|
||||
|
||||
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 (with GPU support)
|
||||
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
|
||||
|
||||
# For 32-bit Raspberry Pi (without GPU support)
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
## 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) - 64-bit only
|
||||
# (32-bit armv7 builds don't require these)
|
||||
sudo apt update
|
||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||
|
||||
# For Arch Linux ARM - 64-bit only
|
||||
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
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 47 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
@@ -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,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build socktop + socktop_agent from source and install them.
|
||||
#
|
||||
# Works on Linux (x86_64, arm64/armv7, riscv64) and macOS. Handles fresh
|
||||
# installs and upgrades; if a systemd socktop-agent service is present, its
|
||||
# binary is replaced in place and the service restarted.
|
||||
#
|
||||
# ./scripts/install.sh # build HEAD of the repo you're in
|
||||
# ./scripts/install.sh --ref v1.60.0 # build a tag/branch (clones if needed)
|
||||
# ./scripts/install.sh --ref master # or any branch
|
||||
# ./scripts/install.sh --prefix ~/.local/bin --no-service
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://github.com/jasonwitty/socktop.git"
|
||||
REF=""
|
||||
PREFIX=""
|
||||
NO_SERVICE=0
|
||||
SRC_DIR="${SOCKTOP_SRC_DIR:-$HOME/.cache/socktop-src}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ref) REF="$2"; shift 2 ;;
|
||||
--prefix) PREFIX="$2"; shift 2 ;;
|
||||
--no-service) NO_SERVICE=1; shift ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# The entire remainder runs inside main(), invoked on the LAST line. This
|
||||
# makes the script safe against being MODIFIED WHILE RUNNING: when executed
|
||||
# from the clone it manages, the git checkout below replaces this very file,
|
||||
# and bash reads scripts lazily by byte offset — without this wrapper it
|
||||
# resumes parsing the NEW file at the OLD offset and executes an arbitrary
|
||||
# tail of it (observed: the fresh-service path ran on a host whose unit
|
||||
# already existed). With main(), the whole script is parsed before any of
|
||||
# it executes.
|
||||
main() {
|
||||
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
# ---------- toolchain ----------
|
||||
command -v git >/dev/null || die "git is required"
|
||||
if ! command -v cargo >/dev/null; then
|
||||
# rustup may be installed but not on PATH in this shell
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
fi
|
||||
if ! command -v cargo >/dev/null; then
|
||||
say "Rust toolchain not found — installing via rustup (stable, default profile)"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
. "$HOME/.cargo/env"
|
||||
fi
|
||||
command -v cc >/dev/null || warn "no C compiler found (apt: build-essential / brew: xcode-select --install) — the build may fail"
|
||||
case "$ARCH" in
|
||||
riscv64*)
|
||||
# protoc-bin-vendored ships no riscv64 binary; the build falls back to
|
||||
# the system protoc (see build.rs).
|
||||
command -v protoc >/dev/null || die "riscv64 needs a system protoc: sudo apt install protobuf-compiler"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------- source ----------
|
||||
# If run from inside a socktop checkout and no --ref given, build that tree
|
||||
# as-is (whatever is checked out, including local changes).
|
||||
if [ -z "$REF" ] && git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
&& grep -qs '^name = "socktop"' "$(git rev-parse --show-toplevel)/socktop/Cargo.toml" 2>/dev/null; then
|
||||
SRC_DIR="$(git rev-parse --show-toplevel)"
|
||||
say "Building the current checkout: $SRC_DIR ($(git -C "$SRC_DIR" describe --always --dirty 2>/dev/null))"
|
||||
else
|
||||
REF="${REF:-master}"
|
||||
if [ ! -d "$SRC_DIR/.git" ]; then
|
||||
say "Cloning $REPO_URL -> $SRC_DIR"
|
||||
git clone "$REPO_URL" "$SRC_DIR"
|
||||
fi
|
||||
say "Checking out $REF"
|
||||
git -C "$SRC_DIR" fetch --tags origin
|
||||
git -C "$SRC_DIR" checkout -q "$REF"
|
||||
# fast-forward when REF is a branch
|
||||
git -C "$SRC_DIR" merge --ff-only "origin/$REF" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ---------- build ----------
|
||||
say "Building release binaries (this can take a while on SBCs)"
|
||||
( cd "$SRC_DIR" && cargo build --release -p socktop -p socktop_agent )
|
||||
CLIENT="$SRC_DIR/target/release/socktop"
|
||||
AGENT="$SRC_DIR/target/release/socktop_agent"
|
||||
|
||||
# ---------- install ----------
|
||||
if [ -z "$PREFIX" ]; then
|
||||
PREFIX="/usr/local/bin"
|
||||
fi
|
||||
SUDO=""
|
||||
if [ ! -w "$PREFIX" ]; then
|
||||
if command -v sudo >/dev/null; then SUDO="sudo"; else
|
||||
PREFIX="$HOME/.local/bin"; mkdir -p "$PREFIX"
|
||||
warn "no sudo — installing to $PREFIX (ensure it is on your PATH)"
|
||||
fi
|
||||
fi
|
||||
say "Installing to $PREFIX"
|
||||
$SUDO install -m 755 "$CLIENT" "$PREFIX/socktop"
|
||||
$SUDO install -m 755 "$AGENT" "$PREFIX/socktop_agent"
|
||||
|
||||
# Update every other copy on PATH as well. A stale `cargo install` in
|
||||
# ~/.cargo/bin would otherwise SHADOW the fresh binary (~/.cargo/bin
|
||||
# usually precedes /usr/local/bin on PATH), leaving `socktop --version`
|
||||
# stuck on the old release after a "successful" install.
|
||||
update_path_copies() {
|
||||
local name="$1" src="$2" copy dir
|
||||
# type -ap lists every match on PATH (bash builtin, symlinks not resolved)
|
||||
for copy in $(type -ap "$name" | sort -u); do
|
||||
[ "$copy" = "$PREFIX/$name" ] && continue
|
||||
dir="$(dirname "$copy")"
|
||||
say "Updating additional copy on PATH: $copy"
|
||||
if [ -w "$copy" ] || [ -w "$dir" ]; then
|
||||
install -m 755 "$src" "$copy"
|
||||
else
|
||||
# Non-fatal: an un-updatable extra copy shouldn't kill the install,
|
||||
# but the user must know it may shadow the fresh binary.
|
||||
$SUDO install -m 755 "$src" "$copy" || warn "could not update $copy — it may shadow $PREFIX/$name"
|
||||
fi
|
||||
done
|
||||
}
|
||||
update_path_copies socktop "$CLIENT"
|
||||
update_path_copies socktop_agent "$AGENT"
|
||||
|
||||
# ---------- systemd service (Linux only) ----------
|
||||
# System-level operations (unit files, users, service control) need root no
|
||||
# matter where the binaries were installed — decide independently of PREFIX.
|
||||
SYS_SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if command -v sudo >/dev/null; then SYS_SUDO="sudo"; else SYS_SUDO="__none__"; fi
|
||||
fi
|
||||
if [ "$SYS_SUDO" = "__none__" ] && [ "$NO_SERVICE" -eq 0 ]; then
|
||||
warn "no sudo available — skipping systemd service management"
|
||||
NO_SERVICE=1
|
||||
fi
|
||||
if [ "$OS" = "Linux" ] && [ "$NO_SERVICE" -eq 0 ] && command -v systemctl >/dev/null; then
|
||||
if systemctl cat socktop-agent.service >/dev/null 2>&1; then
|
||||
# UPGRADE: the unit file is the operator's (SSL, tokens, ports may be
|
||||
# configured there) — never overwrite it. Only the binary it points at
|
||||
# is replaced, then the service is restarted.
|
||||
say "Existing socktop-agent.service found — preserving unit file, refreshing binary"
|
||||
UNIT_BIN="$(systemctl show -p ExecStart socktop-agent.service 2>/dev/null \
|
||||
| sed -n 's/.*path=\([^ ;]*\).*/\1/p' | head -1)"
|
||||
if [ -n "$UNIT_BIN" ] && [ "$UNIT_BIN" != "$PREFIX/socktop_agent" ]; then
|
||||
$SYS_SUDO systemctl stop socktop-agent.service
|
||||
$SYS_SUDO install -m 755 "$AGENT" "$UNIT_BIN"
|
||||
$SYS_SUDO systemctl start socktop-agent.service
|
||||
else
|
||||
$SYS_SUDO systemctl restart socktop-agent.service
|
||||
fi
|
||||
else
|
||||
# FRESH INSTALL: unit + the system user it runs as + its state dir,
|
||||
# then enable and start. Mirrors the deb package's postinst and
|
||||
# https://www.socktop.io/assets/docs/installation/agent-service.html
|
||||
say "No socktop-agent.service found — installing and enabling it"
|
||||
|
||||
if ! getent group socktop >/dev/null; then
|
||||
$SYS_SUDO groupadd --system socktop
|
||||
fi
|
||||
if ! getent passwd socktop >/dev/null; then
|
||||
NOLOGIN="$(command -v nologin || echo /usr/sbin/nologin)"
|
||||
$SYS_SUDO useradd --system -g socktop -d /var/lib/socktop -M -s "$NOLOGIN" socktop
|
||||
fi
|
||||
$SYS_SUDO mkdir -p /var/lib/socktop
|
||||
$SYS_SUDO chown socktop:socktop /var/lib/socktop
|
||||
$SYS_SUDO chmod 755 /var/lib/socktop
|
||||
|
||||
UNIT_TMP="$(mktemp)"
|
||||
if [ -f "$SRC_DIR/docs/socktop-agent.service" ]; then
|
||||
cp "$SRC_DIR/docs/socktop-agent.service" "$UNIT_TMP"
|
||||
else
|
||||
# Fallback for refs that predate docs/socktop-agent.service
|
||||
cat > "$UNIT_TMP" <<'UNIT'
|
||||
[Unit]
|
||||
Description=Socktop agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/socktop_agent --port 3000
|
||||
Environment=RUST_LOG=info
|
||||
# Optional auth:
|
||||
# Environment=SOCKTOP_TOKEN=changeme
|
||||
# TLS (self-signed cert on first run, default port 8443):
|
||||
# Environment=SOCKTOP_ENABLE_SSL=1
|
||||
Restart=on-failure
|
||||
User=socktop
|
||||
Group=socktop
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
fi
|
||||
# Pick the agent port: 3000 by default, but NEVER bind onto a port that
|
||||
# something else already holds (e.g. Gitea/Umami and friends love 3000)
|
||||
# — that puts the fresh service straight into a crash-restart loop.
|
||||
AGENT_PORT=""
|
||||
for p in 3000 3001 3010 3231 3232; do
|
||||
if ! ss -tln 2>/dev/null | awk '{print $4}' | grep -q ":${p}\$"; then
|
||||
AGENT_PORT="$p"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$AGENT_PORT" ]; then
|
||||
AGENT_PORT=3000
|
||||
warn "no free port among the defaults — using 3000; edit the unit if the service fails to start"
|
||||
elif [ "$AGENT_PORT" != "3000" ]; then
|
||||
warn "port 3000 is already in use by another service — configuring the agent on port $AGENT_PORT"
|
||||
fi
|
||||
|
||||
# Point ExecStart at wherever this run installed the agent, on the chosen port.
|
||||
sed -i.bak -e "s|^ExecStart=[^ ]*socktop_agent|ExecStart=$PREFIX/socktop_agent|" \
|
||||
-e "s|--port [0-9]*|--port $AGENT_PORT|" "$UNIT_TMP"
|
||||
rm -f "$UNIT_TMP.bak"
|
||||
|
||||
$SYS_SUDO install -o root -g root -m 0644 "$UNIT_TMP" /etc/systemd/system/socktop-agent.service
|
||||
rm -f "$UNIT_TMP"
|
||||
$SYS_SUDO systemctl daemon-reload
|
||||
$SYS_SUDO systemctl enable --now socktop-agent.service
|
||||
say "Service installed — agent URL: ws://$(hostname):$AGENT_PORT/ws"
|
||||
say "To enable TLS or a token, edit /etc/systemd/system/socktop-agent.service, then: sudo systemctl daemon-reload && sudo systemctl restart socktop-agent"
|
||||
fi
|
||||
sleep 1
|
||||
systemctl --no-pager -l status socktop-agent.service | head -5 || true
|
||||
fi
|
||||
|
||||
say "Installed:"
|
||||
"$PREFIX/socktop" --version
|
||||
"$PREFIX/socktop_agent" --version
|
||||
say "Active on PATH: $(type -p socktop || true) / $(type -p socktop_agent || true)"
|
||||
socktop --version
|
||||
|
||||
}
|
||||
|
||||
# exit in the same parse unit as the call: after main returns, bash must not
|
||||
# read another byte from this (possibly replaced) file.
|
||||
main "$@"; exit $?
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Sync this repo to the 'gitea' remote as a mirror.
|
||||
# - Mirrors ALL refs (branches, tags) and prunes removed ones.
|
||||
# - This makes the Gitea repo match GitHub exactly.
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "Error: not inside a git repo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git remote get-url gitea >/dev/null 2>&1; then
|
||||
echo "Missing 'gitea' remote. Add it with:" >&2
|
||||
echo " git remote add gitea https://gt.wittyoneoff.com/jason/socktop.git" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching from origin (pruning)..."
|
||||
git fetch origin --prune --tags
|
||||
|
||||
echo "Pushing mirror to gitea..."
|
||||
git push gitea --mirror
|
||||
|
||||
echo "Done: Gitea should now match origin (GitHub)."
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
[package]
|
||||
name = "socktop"
|
||||
version = "0.1.0"
|
||||
version = "1.60.1"
|
||||
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"
|
||||
homepage = "https://github.com/jasonwitty/socktop"
|
||||
repository = "https://github.com/jasonwitty/socktop"
|
||||
|
||||
[dependencies]
|
||||
# socktop connector for agent communication
|
||||
socktop_connector = { version = "1.60.1", path = "../socktop_connector" }
|
||||
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
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 }
|
||||
unicode-width = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
# Local process signalling only (src/proc_kill.rs). The TUI never gathers its
|
||||
# own metrics — everything on screen comes from the agent over the connector.
|
||||
sysinfo = { workspace = true }
|
||||
dirs-next = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
|
||||
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
|
||||
license-file = ["../LICENSE", "4"]
|
||||
extended-description = """\
|
||||
socktop is a remote system monitor with a rich terminal user interface (TUI) \
|
||||
that connects to remote hosts running the socktop_agent over WebSocket. \
|
||||
It provides real-time monitoring of CPU, memory, processes, and more with \
|
||||
an interface similar to the traditional 'top' command."""
|
||||
depends = "$auto"
|
||||
section = "admin"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/socktop", "usr/bin/", "755"],
|
||||
["../README.md", "usr/share/doc/socktop/", "644"],
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,22 +2,34 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||
if dq.len() == cap {
|
||||
dq.pop_front();
|
||||
}
|
||||
/// Push a value into a capped deque. Returns the evicted front element if any.
|
||||
/// Callers maintaining a running sum can use this to update the sum without
|
||||
/// re-iterating the whole deque.
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> {
|
||||
let evicted = if dq.len() == cap {
|
||||
dq.pop_front()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
dq.push_back(v);
|
||||
evicted
|
||||
}
|
||||
|
||||
// Keeps a history deque per core with a fixed capacity
|
||||
// Keeps a history deque per core with a fixed capacity.
|
||||
// Storage is u64 so sparkline rendering can hand the slice directly to
|
||||
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
|
||||
// allocation or widening conversion.
|
||||
pub struct PerCoreHistory {
|
||||
pub deques: Vec<VecDeque<u16>>,
|
||||
pub deques: Vec<VecDeque<u64>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl PerCoreHistory {
|
||||
pub fn new(cap: usize) -> Self {
|
||||
Self { deques: Vec::new(), cap }
|
||||
Self {
|
||||
deques: Vec::new(),
|
||||
cap,
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have one deque per core; resize on CPU topology changes
|
||||
@@ -32,8 +44,8 @@ impl PerCoreHistory {
|
||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||
self.ensure_cores(samples.len());
|
||||
for (i, v) in samples.iter().enumerate() {
|
||||
let val = v.clamp(0.0, 100.0).round() as u16;
|
||||
let val = v.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.deques[i], val, self.cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Detection of whether the connected agent is running on this same machine.
|
||||
//!
|
||||
//! Process-kill is only offered for *local* agents. The reasoning is a
|
||||
//! security one: the PIDs shown in the UI are reported by the agent, and when
|
||||
//! the user asks to kill one, socktop sends the signal with its OWN local OS
|
||||
//! privileges (a direct syscall — never over the network; see [`crate::proc_kill`]).
|
||||
//! A PID is therefore only meaningful — and only safe to act on — when the
|
||||
//! agent lives on this machine. If we acted on a remote agent's PIDs we would
|
||||
//! be signalling whatever unrelated *local* process happened to share that
|
||||
//! number.
|
||||
//!
|
||||
//! An address is considered local when it is loopback, or when we can bind an
|
||||
//! ephemeral socket to it: a bind only succeeds for an address assigned to one
|
||||
//! of this host's own network interfaces, so it also covers the case of an
|
||||
//! agent reached over this machine's LAN IP. Detection fails closed — any
|
||||
//! parse/resolution failure, or any resolved address that is not local,
|
||||
//! disables the feature.
|
||||
|
||||
use std::net::{IpAddr, ToSocketAddrs, UdpSocket};
|
||||
|
||||
/// Returns true only if the agent reached at `ws_url` is on this machine.
|
||||
pub fn agent_is_local(ws_url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(ws_url) else {
|
||||
return false;
|
||||
};
|
||||
match parsed.host() {
|
||||
// IP literals can be checked directly without any name resolution.
|
||||
Some(url::Host::Ipv4(ip)) => ip_is_local(IpAddr::V4(ip)),
|
||||
Some(url::Host::Ipv6(ip)) => ip_is_local(IpAddr::V6(ip)),
|
||||
// A hostname (e.g. "localhost", or a LAN name) must resolve, and every
|
||||
// address it resolves to must be local. ws=80, wss=443 are the known
|
||||
// default ports; an explicit port in the URL is honored.
|
||||
Some(url::Host::Domain(domain)) => {
|
||||
let port = parsed.port_or_known_default().unwrap_or(0);
|
||||
match (domain, port).to_socket_addrs() {
|
||||
Ok(addrs) => {
|
||||
let mut saw_any = false;
|
||||
for addr in addrs {
|
||||
saw_any = true;
|
||||
if !ip_is_local(addr.ip()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
saw_any
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An address is local if it is loopback, or if we can bind an ephemeral
|
||||
/// socket to it (only possible for an address on one of our own interfaces).
|
||||
/// Port 0 requests an ephemeral port and sends no traffic.
|
||||
fn ip_is_local(ip: IpAddr) -> bool {
|
||||
ip.is_loopback() || UdpSocket::bind((ip, 0)).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::agent_is_local;
|
||||
|
||||
#[test]
|
||||
fn loopback_hosts_are_local() {
|
||||
assert!(agent_is_local("ws://127.0.0.1:3000/ws"));
|
||||
assert!(agent_is_local("ws://localhost:3000/ws"));
|
||||
assert!(agent_is_local("ws://[::1]:3000/ws"));
|
||||
assert!(agent_is_local("wss://127.0.0.1/ws"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_addresses_are_not_local() {
|
||||
// 8.8.8.8 is not assigned to any local interface.
|
||||
assert!(!agent_is_local("ws://8.8.8.8:3000/ws"));
|
||||
// Documentation-range address, guaranteed not bound locally.
|
||||
assert!(!agent_is_local("ws://203.0.113.1:3000/ws"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_fails_closed() {
|
||||
assert!(!agent_is_local("not a url"));
|
||||
assert!(!agent_is_local(""));
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,509 @@
|
||||
|
||||
mod app;
|
||||
mod history;
|
||||
mod local;
|
||||
mod proc_kill;
|
||||
mod profiles;
|
||||
mod retry;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod ws;
|
||||
mod ui; // pure retry timing logic
|
||||
|
||||
use std::env;
|
||||
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,
|
||||
compact: 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;
|
||||
let mut compact = 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] [--compact] [--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;
|
||||
}
|
||||
"--compact" => {
|
||||
// Force the small-window layout at any terminal size. Without it the
|
||||
// layout switches on its own once the window gets too short.
|
||||
compact = 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] [--compact] [ws://HOST:PORT/ws]"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ParsedArgs {
|
||||
url,
|
||||
tls_ca,
|
||||
profile,
|
||||
save,
|
||||
demo,
|
||||
dry_run,
|
||||
metrics_interval_ms,
|
||||
processes_interval_ms,
|
||||
verify_hostname,
|
||||
compact,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} ws://HOST:PORT/ws", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let url = args[1].clone();
|
||||
let parsed = match parse_args(env::args()) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
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(), parsed.compact).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(), parsed.compact).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(), parsed.compact).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=");
|
||||
// Only enable local process-kill when the agent is verified to be on this
|
||||
// machine; otherwise on-screen PIDs refer to a remote host and acting on
|
||||
// them locally would signal the wrong process. See local::agent_is_local.
|
||||
let is_local = local::agent_is_local(&url);
|
||||
let mut app = App::new()
|
||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||
.with_status(is_tls, has_token)
|
||||
.with_compact(parsed.compact)
|
||||
.with_local(is_local);
|
||||
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>,
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
let child = match spawn_demo_agent(port) {
|
||||
Ok(child) => child,
|
||||
// The agent ships as its own binary, so a missing one is a setup problem,
|
||||
// not a crash: tell the user how to fix it instead of dumping an io error.
|
||||
Err(e @ DemoAgentError::NotFound(_)) => {
|
||||
eprintln!("{e}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// Demo mode runs the real agent on loopback, so its PIDs are real local
|
||||
// processes — enable the local process-kill feature, gated the same way as
|
||||
// the normal connect path (loopback resolves local).
|
||||
let mut app = App::new()
|
||||
.with_compact(compact)
|
||||
.with_local(local::agent_is_local(&url));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
enum DemoAgentError {
|
||||
/// The socktop_agent executable could not be located.
|
||||
NotFound(std::path::PathBuf),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DemoAgentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(candidate) => write!(
|
||||
f,
|
||||
"Could not start demo mode: '{}' was not found{}.\n\
|
||||
\n\
|
||||
Demo mode runs a local agent, which is shipped as a separate binary\n\
|
||||
and is not installed alongside the socktop TUI. Install it with:\n\
|
||||
\n cargo install socktop_agent\n\n\
|
||||
then run socktop again. See {} for other install options.",
|
||||
candidate.display(),
|
||||
// A bare file name means find_agent_executable() fell back to a PATH lookup.
|
||||
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
|
||||
" on your PATH"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
env!("CARGO_PKG_HOMEPAGE"),
|
||||
),
|
||||
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DemoAgentError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::NotFound(_) => None,
|
||||
Self::Io(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
|
||||
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().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
|
||||
_ => DemoAgentError::Io(e),
|
||||
})?;
|
||||
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,181 @@
|
||||
//! Local process termination.
|
||||
//!
|
||||
//! Signals are sent by socktop itself, using this process's own OS privileges,
|
||||
//! via a direct `sysinfo` call. Nothing is transmitted to the agent — the
|
||||
//! agent and connector have no kill capability at all. This code path is only
|
||||
//! reachable once the agent has been verified to be local (see
|
||||
//! [`crate::local`]), which guarantees the PID refers to a process on this
|
||||
//! machine.
|
||||
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, Signal, System};
|
||||
|
||||
/// The signals socktop can send. Deliberately limited to the two btop-style
|
||||
/// primaries; no arbitrary-signal chooser.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KillSignal {
|
||||
/// SIGTERM — polite request to terminate.
|
||||
Term,
|
||||
/// SIGKILL — forceful, cannot be caught.
|
||||
Kill,
|
||||
}
|
||||
|
||||
impl KillSignal {
|
||||
fn as_sysinfo(self) -> Signal {
|
||||
match self {
|
||||
KillSignal::Term => Signal::Term,
|
||||
KillSignal::Kill => Signal::Kill,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-facing label for confirmation/result messages.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
KillSignal::Term => "SIGTERM",
|
||||
KillSignal::Kill => "SIGKILL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `pid` still a live local process?
|
||||
///
|
||||
/// A zombie counts as gone: after a kill the entry can linger until the parent
|
||||
/// reaps it, and showing a row for a process that no longer runs is exactly the
|
||||
/// staleness this check exists to avoid.
|
||||
pub fn process_exists(pid: u32) -> bool {
|
||||
let spid = sysinfo::Pid::from_u32(pid);
|
||||
let mut sys = System::new();
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[spid]),
|
||||
false,
|
||||
ProcessRefreshKind::nothing(),
|
||||
);
|
||||
match sys.process(spid) {
|
||||
Some(p) => p.status() != sysinfo::ProcessStatus::Zombie,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `signal` to local process `pid`. Returns `Ok(())` on success, or an
|
||||
/// `Err` with a human-readable reason (process gone, PID reused, permission
|
||||
/// denied, signal unsupported on this platform).
|
||||
///
|
||||
/// `expected_name`, when given, is compared against the process that owns the
|
||||
/// PID **right now**: the PID came from an agent snapshot and the confirmation
|
||||
/// dialog can sit open indefinitely, so by signal time the kernel may have
|
||||
/// recycled the number for an unrelated process. Both names come from the
|
||||
/// same sysinfo source, so a live, unchanged target compares equal.
|
||||
pub fn kill_local_process(
|
||||
pid: u32,
|
||||
expected_name: Option<&str>,
|
||||
signal: KillSignal,
|
||||
) -> Result<(), String> {
|
||||
let spid = sysinfo::Pid::from_u32(pid);
|
||||
|
||||
// Refresh just this one PID — we don't need a full process scan to signal it.
|
||||
let mut sys = System::new();
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[spid]),
|
||||
false,
|
||||
ProcessRefreshKind::nothing(),
|
||||
);
|
||||
|
||||
let Some(proc_) = sys.process(spid) else {
|
||||
return Err(format!("Process {pid} no longer exists"));
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_name {
|
||||
let current = proc_.name().to_string_lossy();
|
||||
if current != expected {
|
||||
return Err(format!(
|
||||
"PID {pid} now belongs to \"{current}\", not \"{expected}\" — \
|
||||
not signalling. Reselect the process and try again."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match proc_.kill_with(signal.as_sysinfo()) {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) => Err(format!(
|
||||
"Could not send {} to PID {pid} (permission denied?)",
|
||||
signal.label()
|
||||
)),
|
||||
None => Err(format!(
|
||||
"{} is not supported on this platform",
|
||||
signal.label()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The path that matters: a real, live, local process must actually receive
|
||||
/// the signal. Exercises the `refresh_processes_specifics` lookup as well —
|
||||
/// if that call does not populate the process map, `sys.process()` returns
|
||||
/// None and a live PID is reported as "no longer exists".
|
||||
#[test]
|
||||
fn signals_a_real_child_process() {
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.expect("spawn sleep for the test");
|
||||
let pid = child.id();
|
||||
|
||||
let result = kill_local_process(pid, Some("sleep"), KillSignal::Term);
|
||||
|
||||
// Reap on every path before asserting, so a failing assert cannot leak a
|
||||
// 30s sleep and cannot trip clippy's zombie_processes lint.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut exited = false;
|
||||
while Instant::now() < deadline {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
exited = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
if !exited {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = child.wait();
|
||||
|
||||
assert!(result.is_ok(), "kill_local_process returned {result:?}");
|
||||
assert!(
|
||||
exited,
|
||||
"SIGTERM was reported sent but the child never exited"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reuse guard: a live PID whose owner does not match the name the
|
||||
/// user confirmed must NOT be signalled. This also proves the name is
|
||||
/// populated under ProcessRefreshKind::nothing() — if it weren't, the
|
||||
/// matching-name test above would fail instead.
|
||||
#[test]
|
||||
fn refuses_a_pid_owned_by_a_different_process() {
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
let pid = child.id();
|
||||
|
||||
let result = kill_local_process(pid, Some("firefox"), KillSignal::Term);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
let err = result.expect_err("signalled a process under the wrong name");
|
||||
assert!(err.contains("firefox") && err.contains("sleep"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_a_pid_that_is_gone() {
|
||||
let mut child = Command::new("true").spawn().expect("spawn true");
|
||||
let pid = child.id();
|
||||
child.wait().expect("reap");
|
||||
// The PID is now free; signalling it must fail cleanly, not panic.
|
||||
assert!(kill_local_process(pid, None, KillSignal::Term).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Pure retry timing logic (decoupled from App state / UI) for testability.
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Result of computing retry timing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetryTiming {
|
||||
pub should_retry_now: bool,
|
||||
/// Seconds until next retry (Some(0) means ready now); None means inactive/no countdown.
|
||||
pub seconds_until_retry: Option<u64>,
|
||||
}
|
||||
|
||||
/// Compute retry timing given connection state inputs.
|
||||
///
|
||||
/// Inputs:
|
||||
/// - `disconnected`: true when connection_state == Disconnected.
|
||||
/// - `modal_active`: requires the connection error modal be visible to show countdown / trigger auto retry.
|
||||
/// - `original_disconnect_time`: time we first noticed disconnect.
|
||||
/// - `last_auto_retry`: time we last performed an automatic retry.
|
||||
/// - `now`: current time (injected for determinism / tests).
|
||||
/// - `interval`: retry interval duration.
|
||||
pub(crate) fn compute_retry_timing(
|
||||
disconnected: bool,
|
||||
modal_active: bool,
|
||||
original_disconnect_time: Option<Instant>,
|
||||
last_auto_retry: Option<Instant>,
|
||||
now: Instant,
|
||||
interval: Duration,
|
||||
) -> RetryTiming {
|
||||
if !disconnected || !modal_active {
|
||||
return RetryTiming {
|
||||
should_retry_now: false,
|
||||
seconds_until_retry: None,
|
||||
};
|
||||
}
|
||||
|
||||
let baseline = match last_auto_retry.or(original_disconnect_time) {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
return RetryTiming {
|
||||
should_retry_now: false,
|
||||
seconds_until_retry: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let elapsed = now.saturating_duration_since(baseline);
|
||||
if elapsed >= interval {
|
||||
RetryTiming {
|
||||
should_retry_now: true,
|
||||
seconds_until_retry: Some(0),
|
||||
}
|
||||
} else {
|
||||
let remaining = interval - elapsed;
|
||||
RetryTiming {
|
||||
should_retry_now: false,
|
||||
seconds_until_retry: Some(remaining.as_secs()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inactive_when_not_disconnected() {
|
||||
let now = Instant::now();
|
||||
let rt = compute_retry_timing(false, true, Some(now), None, now, Duration::from_secs(30));
|
||||
assert!(!rt.should_retry_now);
|
||||
assert_eq!(rt.seconds_until_retry, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn countdown_progress_and_ready() {
|
||||
let base = Instant::now();
|
||||
let rt1 = compute_retry_timing(
|
||||
true,
|
||||
true,
|
||||
Some(base),
|
||||
None,
|
||||
base + Duration::from_secs(10),
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
assert!(!rt1.should_retry_now);
|
||||
assert_eq!(rt1.seconds_until_retry, Some(20));
|
||||
let rt2 = compute_retry_timing(
|
||||
true,
|
||||
true,
|
||||
Some(base),
|
||||
None,
|
||||
base + Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
assert!(rt2.should_retry_now);
|
||||
assert_eq!(rt2.seconds_until_retry, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_last_auto_retry_as_baseline() {
|
||||
let base: Instant = Instant::now();
|
||||
let last = base + Duration::from_secs(30); // one prior retry
|
||||
// 10s after last retry => 20s remaining
|
||||
let rt = compute_retry_timing(
|
||||
true,
|
||||
true,
|
||||
Some(base),
|
||||
Some(last),
|
||||
last + Duration::from_secs(10),
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
assert!(!rt.should_retry_now);
|
||||
assert_eq!(rt.seconds_until_retry, Some(20));
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,4 @@
|
||||
//! Types that mirror the agent's JSON schema.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Disk {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Network {
|
||||
// cumulative totals; client diffs to compute rates
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
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 process_count: usize,
|
||||
pub hostname: String,
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
pub disks: Vec<Disk>,
|
||||
pub networks: Vec<Network>,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
// Re-export commonly used types from socktop_connector
|
||||
pub use socktop_connector::Metrics;
|
||||
|
||||
@@ -1,66 +1,427 @@
|
||||
//! CPU average sparkline + per-core mini bars.
|
||||
|
||||
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
|
||||
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||
widgets::{
|
||||
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
||||
},
|
||||
};
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
use crate::history::PerCoreHistory;
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::fit::{cols, pick_pair};
|
||||
|
||||
/// Columns kept clear between the CPU title and the temperature readout.
|
||||
const TITLE_GAP: u16 = 2;
|
||||
|
||||
/// State for dragging the scrollbar thumb
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PerCoreScrollDrag {
|
||||
pub active: bool,
|
||||
pub start_y: u16, // mouse row where drag started
|
||||
pub start_top: usize, // thumb top (in track rows) at drag start
|
||||
}
|
||||
|
||||
/// Returns the content area for per-core CPU bars, excluding borders and reserving space for scrollbar.
|
||||
pub fn per_core_content_area(area: Rect) -> Rect {
|
||||
// Inner minus borders
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
// Reserve 1 column on the right for a gutter and 1 for the scrollbar
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles key events for per-core CPU bars.
|
||||
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
|
||||
match key.code {
|
||||
KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::PageUp => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_add(step);
|
||||
}
|
||||
KeyCode::Home => *scroll_offset = 0,
|
||||
KeyCode::End => *scroll_offset = usize::MAX, // draw() clamps to max
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles mouse wheel over the content.
|
||||
pub fn per_core_handle_mouse(
|
||||
scroll_offset: &mut usize,
|
||||
mouse: MouseEvent,
|
||||
content_area: Rect,
|
||||
page_size: usize,
|
||||
) {
|
||||
let inside = mouse.column >= content_area.x
|
||||
&& mouse.column < content_area.x + content_area.width
|
||||
&& mouse.row >= content_area.y
|
||||
&& mouse.row < content_area.y + content_area.height;
|
||||
|
||||
if !inside {
|
||||
return;
|
||||
}
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
MouseEventKind::ScrollDown => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
// Optional paging via horizontal wheel
|
||||
MouseEventKind::ScrollLeft => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
}
|
||||
MouseEventKind::ScrollRight => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_add(step);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles mouse interaction with the scrollbar itself (click arrows/page/drag).
|
||||
pub fn per_core_handle_scrollbar_mouse(
|
||||
scroll_offset: &mut usize,
|
||||
drag: &mut Option<PerCoreScrollDrag>,
|
||||
mouse: MouseEvent,
|
||||
per_core_area: Rect,
|
||||
total_rows: usize,
|
||||
) {
|
||||
// Geometry
|
||||
let inner = Rect {
|
||||
x: per_core_area.x + 1,
|
||||
y: per_core_area.y + 1,
|
||||
width: per_core_area.width.saturating_sub(2),
|
||||
height: per_core_area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height < 3 || inner.width < 1 {
|
||||
return;
|
||||
}
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
};
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
let viewport_rows = content.height as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
let mut offset = (*scroll_offset).min(max_off);
|
||||
|
||||
// Track and current thumb
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
if track == 0 {
|
||||
return;
|
||||
}
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let top_for_offset = |off: usize| -> usize {
|
||||
((track - thumb_len) * off + max_off / 2)
|
||||
.checked_div(max_off)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let thumb_top = top_for_offset(offset);
|
||||
|
||||
let inside_scrollbar = mouse.column == scroll_area.x
|
||||
&& mouse.row >= scroll_area.y
|
||||
&& mouse.row < scroll_area.y + scroll_area.height;
|
||||
|
||||
// Helper to page
|
||||
let page_up = || offset.saturating_sub(view.max(1));
|
||||
let page_down = || offset.saturating_add(view.max(1));
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) if inside_scrollbar => {
|
||||
// Where within the track?
|
||||
let row = mouse.row;
|
||||
if row == scroll_area.y {
|
||||
// Top arrow
|
||||
offset = offset.saturating_sub(1);
|
||||
} else if row + 1 == scroll_area.y + scroll_area.height {
|
||||
// Bottom arrow
|
||||
offset = offset.saturating_add(1);
|
||||
} else {
|
||||
// Inside track
|
||||
let rel = (row - (scroll_area.y + 1)) as usize;
|
||||
let thumb_end = thumb_top + thumb_len;
|
||||
if rel < thumb_top {
|
||||
// Page up
|
||||
offset = page_up();
|
||||
} else if rel >= thumb_end {
|
||||
// Page down
|
||||
offset = page_down();
|
||||
} else {
|
||||
// Start dragging
|
||||
*drag = Some(PerCoreScrollDrag {
|
||||
active: true,
|
||||
start_y: row,
|
||||
start_top: thumb_top,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(mut d) = drag.take()
|
||||
&& 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 = (new_top * max_off + denom / 2)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0);
|
||||
} else {
|
||||
offset = 0;
|
||||
}
|
||||
// Keep dragging
|
||||
d.start_top = new_top;
|
||||
d.start_y = mouse.row;
|
||||
*drag = Some(d);
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
// End drag
|
||||
*drag = None;
|
||||
}
|
||||
// Also allow wheel scrolling when cursor is over the scrollbar
|
||||
MouseEventKind::ScrollUp if inside_scrollbar => {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
MouseEventKind::ScrollDown if inside_scrollbar => {
|
||||
offset = offset.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Clamp and write back
|
||||
if offset > max_off {
|
||||
offset = max_off;
|
||||
}
|
||||
*scroll_offset = offset;
|
||||
}
|
||||
|
||||
/// Clamp scroll offset to the valid range given content and viewport.
|
||||
pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_rows: usize) {
|
||||
let max_offset = total_rows.saturating_sub(viewport_rows);
|
||||
if *scroll_offset > max_offset {
|
||||
*scroll_offset = max_offset;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the CPU average sparkline graph.
|
||||
///
|
||||
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
|
||||
/// fold the (up to 600-element) deque on every frame.
|
||||
pub fn draw_cpu_avg_graph(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
hist: &mut std::collections::VecDeque<u64>,
|
||||
hist_sum: u64,
|
||||
m: Option<&Metrics>,
|
||||
) {
|
||||
let title = if let Some(mm) = m { format!("CPU avg (now: {:>5.1}%)", mm.cpu_total) } else { "CPU avg".into() };
|
||||
let avg_cpu = if hist.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
hist_sum as f64 / hist.len() as f64
|
||||
};
|
||||
|
||||
let (title, top_right_info) = cpu_title_for_width(
|
||||
m.map(|mm| mm.cpu_total),
|
||||
avg_cpu,
|
||||
m.and_then(|mm| mm.cpu_temp_c),
|
||||
area.width,
|
||||
);
|
||||
|
||||
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||
// us skip the per-frame Vec allocation .collect() used to do.
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
|
||||
// Temperature overlays the top border, right-aligned inside the corner. The title
|
||||
// above is sized so the two cannot collide.
|
||||
if !top_right_info.is_empty() {
|
||||
let w = cols(&top_right_info);
|
||||
let info_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(w + 1),
|
||||
y: area.y,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
let info_line = Line::from(Span::raw(top_right_info));
|
||||
f.render_widget(Paragraph::new(info_line), info_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Health glyph for a CPU temperature.
|
||||
fn temp_icon(t: f32) -> &'static str {
|
||||
if t < 50.0 {
|
||||
"😎"
|
||||
} else if t < 85.0 {
|
||||
"⚠️"
|
||||
} else {
|
||||
"🔥"
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the CPU pane's title and its right-aligned temperature readout for a pane
|
||||
/// `width` columns wide.
|
||||
///
|
||||
/// Both are painted onto the pane's top border, so without a shared budget the
|
||||
/// temperature simply overwrites the tail of the title on a narrow pane. Detail is given
|
||||
/// up in this order: the `CPU Temp:` label, then the `now:`/`avg:` labels, then the
|
||||
/// average reading, then the decimal on the temperature, and only last the temperature
|
||||
/// itself — the readings are what the pane is for, but a thermal warning is worth more
|
||||
/// than a second decimal place.
|
||||
fn cpu_title_for_width(
|
||||
cpu_now: Option<f32>,
|
||||
avg_cpu: f64,
|
||||
temp_c: Option<f32>,
|
||||
width: u16,
|
||||
) -> (String, String) {
|
||||
let Some(now) = cpu_now else {
|
||||
return ("CPU avg".into(), String::new());
|
||||
};
|
||||
|
||||
// Two borders, plus a column of breathing room at each end of the title.
|
||||
let budget = width.saturating_sub(4);
|
||||
|
||||
let labelled = format!("CPU (now: {now:>5.1}% | avg: {avg_cpu:>5.1}%)");
|
||||
let bare = format!("CPU ({now:.1}% | {avg_cpu:.1}%)");
|
||||
let now_only = format!("CPU ({now:.1}%)");
|
||||
|
||||
let (temp_labelled, temp_plain, temp_coarse) = match temp_c {
|
||||
Some(t) => {
|
||||
let icon = temp_icon(t);
|
||||
(
|
||||
format!("CPU Temp: {t:.1}°C {icon}"),
|
||||
format!("{t:.1}°C {icon}"),
|
||||
format!("{t:.0}°C {icon}"),
|
||||
)
|
||||
}
|
||||
None => ("CPU Temp: N/A".into(), "N/A".into(), "N/A".into()),
|
||||
};
|
||||
|
||||
let ladder = [
|
||||
(labelled.as_str(), temp_labelled.as_str()),
|
||||
(labelled.as_str(), temp_plain.as_str()),
|
||||
(bare.as_str(), temp_plain.as_str()),
|
||||
(bare.as_str(), temp_coarse.as_str()),
|
||||
(now_only.as_str(), temp_coarse.as_str()),
|
||||
(now_only.as_str(), ""),
|
||||
];
|
||||
let (title, temp) = pick_pair(budget, TITLE_GAP, &ladder);
|
||||
(title.to_string(), temp.to_string())
|
||||
}
|
||||
|
||||
/// Draws the per-core CPU bars with sparklines and trends.
|
||||
pub fn draw_per_core_bars(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
per_core_hist: &PerCoreHistory,
|
||||
per_core_hist: &mut PerCoreHistory,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
f.render_widget(Block::default().borders(Borders::ALL).title("Per-core"), area);
|
||||
let Some(mm) = m else { return; };
|
||||
f.render_widget(
|
||||
Block::default().borders(Borders::ALL).title("Per-core"),
|
||||
area,
|
||||
);
|
||||
let Some(mm) = m else {
|
||||
return;
|
||||
};
|
||||
|
||||
let inner = Rect { x: area.x + 1, y: area.y + 1, width: area.width.saturating_sub(2), height: area.height.saturating_sub(2) };
|
||||
if inner.height == 0 { return; }
|
||||
// Compute inner rect and content area
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height == 0 || inner.width <= 2 {
|
||||
return;
|
||||
}
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
};
|
||||
|
||||
let total_rows = mm.cpu_per_core.len();
|
||||
let viewport_rows = content.height as usize;
|
||||
let max_offset = total_rows.saturating_sub(viewport_rows);
|
||||
let offset = scroll_offset.min(max_offset);
|
||||
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||
|
||||
let rows = inner.height as usize;
|
||||
let show_n = rows.min(mm.cpu_per_core.len());
|
||||
let constraints: Vec<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect();
|
||||
let vchunks = Layout::default().direction(Direction::Vertical).constraints(constraints).split(inner);
|
||||
let vchunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(content);
|
||||
|
||||
for i in 0..show_n {
|
||||
let idx = offset + i;
|
||||
let rect = vchunks[i];
|
||||
let hchunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
||||
.split(rect);
|
||||
|
||||
let curr = mm.cpu_per_core[i].clamp(0.0, 100.0);
|
||||
let older = per_core_hist.deques.get(i)
|
||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||
let older = per_core_hist
|
||||
.deques
|
||||
.get(idx)
|
||||
.and_then(|d| d.iter().rev().nth(20).copied())
|
||||
.map(|v| v as f32)
|
||||
.unwrap_or(curr);
|
||||
let trend = if curr > older + 0.2 { "↑" }
|
||||
else if curr + 0.2 < older { "↓" }
|
||||
else { "╌" };
|
||||
|
||||
// Trend indicator. Various Unicode glyphs we tried for the "flat"
|
||||
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
|
||||
// coverage; combined with the next column being `100.0` they read as
|
||||
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
|
||||
// for the flat case — no character, no fallback, no confusion.
|
||||
let trend = if curr > older + 0.2 {
|
||||
"↑"
|
||||
} else if curr + 0.2 < older {
|
||||
"↓"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
|
||||
let fg = match curr {
|
||||
x if x < 25.0 => Color::Green,
|
||||
@@ -68,24 +429,250 @@ pub fn draw_per_core_bars(
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
let hist: Vec<u64> = per_core_hist
|
||||
.deques
|
||||
.get(i)
|
||||
.map(|d| {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
d.iter().skip(start).map(|&v| v as u64).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
||||
// to Sparkline without allocating a fresh Vec each frame.
|
||||
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
let slice = &d.make_contiguous()[start..];
|
||||
let spark = Sparkline::default()
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
}
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.data(&hist)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
|
||||
let label = format!("cpu{:<2}{}{:>5.1}%", i, trend, curr);
|
||||
let line = Line::from(Span::styled(label, Style::default().fg(fg).add_modifier(Modifier::BOLD)));
|
||||
// Hard space between the trend mark and the number — even if the
|
||||
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
||||
// them, this space prevents the trend mark from visually joining
|
||||
// `100.0` to look like a negative value.
|
||||
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
||||
let line = Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
||||
// content fits in the viewport, matching the previous behaviour.
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.thumb_symbol("█")
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_style(Style::default().fg(SB_THUMB))
|
||||
.track_style(Style::default().fg(SB_TRACK))
|
||||
.begin_style(Style::default().fg(SB_ARROW))
|
||||
.end_style(Style::default().fg(SB_ARROW));
|
||||
let mut state = ScrollbarState::new(max_off).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_tests {
|
||||
use super::*;
|
||||
|
||||
/// The defect this replaces: the temperature was painted over the title's tail on a
|
||||
/// narrow pane. Whatever the width, the two must fit side by side on the border.
|
||||
#[test]
|
||||
fn title_and_temperature_never_overlap() {
|
||||
for width in 0..=200u16 {
|
||||
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||
let budget = width.saturating_sub(4);
|
||||
if temp.is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
|
||||
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current CPU reading is the one thing the pane must always show.
|
||||
#[test]
|
||||
fn the_current_reading_always_survives() {
|
||||
for width in 20..=200u16 {
|
||||
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||
assert!(
|
||||
title.contains("3.4"),
|
||||
"width {width}: lost the reading ({title:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder from the design: temp label, then now/avg labels, then the average,
|
||||
/// then the temperature's decimal, then the temperature.
|
||||
#[test]
|
||||
fn detail_is_dropped_in_priority_order() {
|
||||
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
|
||||
|
||||
let (title, temp) = at(80);
|
||||
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
|
||||
|
||||
// The "CPU Temp:" label goes first; the readings keep their labels.
|
||||
let (title, temp) = at(50);
|
||||
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||
assert_eq!(temp, "43.0°C 😎");
|
||||
|
||||
// Then the now:/avg: labels.
|
||||
let (title, temp) = at(40);
|
||||
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||
assert_eq!(temp, "43.0°C 😎");
|
||||
|
||||
// Then the temperature's decimal.
|
||||
let (title, temp) = at(31);
|
||||
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||
assert_eq!(temp, "43°C 😎");
|
||||
|
||||
// Then the average reading.
|
||||
let (title, temp) = at(26);
|
||||
assert_eq!(title, "CPU (0.7%)");
|
||||
assert_eq!(temp, "43°C 😎");
|
||||
|
||||
// Last of all, the temperature itself.
|
||||
let (title, temp) = at(15);
|
||||
assert_eq!(title, "CPU (0.7%)");
|
||||
assert_eq!(temp, "");
|
||||
}
|
||||
|
||||
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
|
||||
/// reading at every tier that shows a temperature at all.
|
||||
#[test]
|
||||
fn the_thermal_glyph_tracks_the_temperature() {
|
||||
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
|
||||
for width in 26..=80u16 {
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
|
||||
assert!(
|
||||
temp.contains(icon),
|
||||
"width {width} at {t}°C: expected {icon} in {temp:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent that reports no temperature must not leave a stray label behind.
|
||||
#[test]
|
||||
fn a_missing_temperature_degrades_to_nothing() {
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
|
||||
assert_eq!(temp, "CPU Temp: N/A");
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
|
||||
assert_eq!(temp, "");
|
||||
}
|
||||
|
||||
/// Before the first payload arrives there are no readings to show.
|
||||
#[test]
|
||||
fn no_metrics_yet_shows_the_placeholder() {
|
||||
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
|
||||
assert_eq!(title, "CPU avg");
|
||||
assert!(temp.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use socktop_connector::Metrics;
|
||||
|
||||
fn fake_metrics(cores: Vec<f32>) -> Metrics {
|
||||
Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: cores,
|
||||
mem_total: 1024,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: "t".into(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
process_count: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump(terminal: &Terminal<TestBackend>) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area().height {
|
||||
for x in 0..buf.area().width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
|
||||
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
|
||||
/// coverage. When a core sat at exactly 100% the label rendered as
|
||||
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
|
||||
/// literal space for the flat case AND insert a hard space between every
|
||||
/// trend mark and the number, so no glyph substitution can produce a
|
||||
/// "-100" substring. We assert that across flat AND transitioning cores.
|
||||
#[test]
|
||||
fn percore_label_never_renders_as_negative() {
|
||||
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
|
||||
let mut hist = PerCoreHistory::new(60);
|
||||
hist.ensure_cores(4);
|
||||
// First sample: history is empty, no trend on first frame.
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
// Second sample: identical values → flat trend (the user's complaint).
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---flat 100% render---\n{out}");
|
||||
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
|
||||
|
||||
// Decreasing trend at saturation: hist was high, current drops a bit.
|
||||
let mut hist2 = PerCoreHistory::new(60);
|
||||
hist2.ensure_cores(4);
|
||||
for _ in 0..25 {
|
||||
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
|
||||
}
|
||||
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
|
||||
hist2.push_samples(&m2.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---decreasing render---\n{out}");
|
||||
assert!(
|
||||
!out.contains("-100"),
|
||||
"found '-100' in decreasing-trend render"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("-80"),
|
||||
"found '-80' in decreasing-trend render"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
//! Disk cards with per-device gauge and title line.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::fit::truncate_middle_cols;
|
||||
use crate::ui::util::{disk_icon, human};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Gauge},
|
||||
};
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::{human, truncate_middle, disk_icon};
|
||||
|
||||
pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
f.render_widget(Block::default().borders(Borders::ALL).title("Disks"), area);
|
||||
let Some(mm) = m else { return; };
|
||||
let Some(mm) = m else {
|
||||
return;
|
||||
};
|
||||
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
@@ -18,12 +21,19 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height < 3 { return; }
|
||||
if inner.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplication is performed once on the App side when fresh disk data
|
||||
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
||||
// here would rebuild a HashSet ~10x per refresh for no reason).
|
||||
let per_disk_h = 3u16;
|
||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||
|
||||
let constraints: Vec<Constraint> = (0..max_cards).map(|_| Constraint::Length(per_disk_h)).collect();
|
||||
let constraints: Vec<Constraint> = (0..max_cards)
|
||||
.map(|_| Constraint::Length(per_disk_h))
|
||||
.collect();
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
@@ -32,30 +42,62 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
for (i, slot) in rows.iter().enumerate() {
|
||||
let d = &mm.disks[i];
|
||||
let used = d.total.saturating_sub(d.available);
|
||||
let ratio = if d.total > 0 { used as f64 / d.total as f64 } else { 0.0 };
|
||||
let ratio = if d.total > 0 {
|
||||
used as f64 / d.total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pct = (ratio * 100.0).round() as u16;
|
||||
|
||||
let color = if pct < 70 { ratatui::style::Color::Green } else if pct < 90 { ratatui::style::Color::Yellow } else { ratatui::style::Color::Red };
|
||||
let color = if pct < 70 {
|
||||
ratatui::style::Color::Green
|
||||
} else if pct < 90 {
|
||||
ratatui::style::Color::Yellow
|
||||
} else {
|
||||
ratatui::style::Color::Red
|
||||
};
|
||||
|
||||
// Add indentation for partitions
|
||||
let indent = if d.is_partition { "└─" } else { "" };
|
||||
|
||||
// Add temperature if available
|
||||
let temp_str = d
|
||||
.temperature
|
||||
.map(|t| format!(" {}°C", t.round() as i32))
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = format!(
|
||||
"{} {} {} / {} ({}%)",
|
||||
"{}{}{}{} {} / {} ({}%)",
|
||||
indent,
|
||||
disk_icon(&d.name),
|
||||
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||
truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2),
|
||||
temp_str,
|
||||
human(used),
|
||||
human(d.total),
|
||||
pct
|
||||
);
|
||||
|
||||
// Indent the entire card (block) for partitions to align with └─ prefix (4 chars)
|
||||
let card_indent = if d.is_partition { 4 } else { 0 };
|
||||
let card_rect = Rect {
|
||||
x: slot.x + card_indent,
|
||||
y: slot.y,
|
||||
width: slot.width.saturating_sub(card_indent),
|
||||
height: slot.height,
|
||||
};
|
||||
|
||||
let card = Block::default().borders(Borders::ALL).title(title);
|
||||
f.render_widget(card, *slot);
|
||||
f.render_widget(card, card_rect);
|
||||
|
||||
let inner_card = Rect {
|
||||
x: slot.x + 1,
|
||||
y: slot.y + 1,
|
||||
width: slot.width.saturating_sub(2),
|
||||
height: slot.height.saturating_sub(2),
|
||||
x: card_rect.x + 1,
|
||||
y: card_rect.y + 1,
|
||||
width: card_rect.width.saturating_sub(2),
|
||||
height: card_rect.height.saturating_sub(2),
|
||||
};
|
||||
if inner_card.height == 0 { continue; }
|
||||
if inner_card.height == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let gauge_rect = Rect {
|
||||
x: inner_card.x,
|
||||
@@ -70,4 +112,4 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
|
||||
f.render_widget(g, gauge_rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Fitting text to the columns actually available.
|
||||
//!
|
||||
//! Several panes paint two independent pieces of text onto one row — a left title and a
|
||||
//! right-aligned readout. Nothing reserves space for the right piece, so on a narrow
|
||||
//! terminal the right one is simply painted over the tail of the left one and the title
|
||||
//! is clobbered mid-word. The helpers here let a caller measure in real terminal columns
|
||||
//! and pick the richest wording that still fits, so the two never overlap.
|
||||
//!
|
||||
//! Note that `str::len()` is a byte count and must not be used for this: `⏱` is three
|
||||
//! bytes wide but one column, and `🔒` is four bytes but two columns.
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Terminal columns `s` occupies, saturating at `u16::MAX`.
|
||||
pub fn cols(s: &str) -> u16 {
|
||||
UnicodeWidthStr::width(s).min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
/// Shortens `s` to at most `max` columns, marking the cut with `…`.
|
||||
///
|
||||
/// Cuts on character boundaries and accounts for wide characters, so the result never
|
||||
/// exceeds `max` columns and never splits a multi-byte character.
|
||||
pub fn truncate_cols(s: &str, max: u16) -> String {
|
||||
if cols(s) <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// Reserve one column for the ellipsis.
|
||||
let budget = max.saturating_sub(1);
|
||||
let mut used = 0u16;
|
||||
let mut out = String::new();
|
||||
for ch in s.chars() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > budget {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
out.push(ch);
|
||||
}
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// Shortens `s` to at most `max` columns by cutting the MIDDLE, marking the
|
||||
/// cut with `…` — device names like `/dev/nvme0n1p1` keep their distinctive
|
||||
/// prefix and suffix. Column- and char-boundary-safe; the byte-slicing
|
||||
/// predecessor in `util.rs` panicked on non-ASCII names.
|
||||
pub fn truncate_middle_cols(s: &str, max: u16) -> String {
|
||||
if cols(s) <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max <= 1 {
|
||||
return truncate_cols(s, max);
|
||||
}
|
||||
// Reserve one column for the ellipsis; split the rest left/right.
|
||||
let left_budget = (max - 1) / 2;
|
||||
let right_budget = max - 1 - left_budget;
|
||||
|
||||
let mut left_end = 0; // byte index
|
||||
let mut used = 0u16;
|
||||
for (i, ch) in s.char_indices() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > left_budget {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
left_end = i + ch.len_utf8();
|
||||
}
|
||||
|
||||
let mut right_start = s.len();
|
||||
let mut used = 0u16;
|
||||
for (i, ch) in s.char_indices().rev() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > right_budget || i < left_end {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
right_start = i;
|
||||
}
|
||||
|
||||
format!("{}…{}", &s[..left_end], &s[right_start..])
|
||||
}
|
||||
|
||||
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
|
||||
/// with at least `gap` columns between them.
|
||||
///
|
||||
/// Candidates are ordered most- to least-detailed; the last one is the floor and is
|
||||
/// returned even if it does not fit, so callers always get something to render.
|
||||
pub fn pick_pair<'a>(
|
||||
width: u16,
|
||||
gap: u16,
|
||||
candidates: &[(&'a str, &'a str)],
|
||||
) -> (&'a str, &'a str) {
|
||||
let fits = |left: &str, right: &str| {
|
||||
let needed = cols(left)
|
||||
.saturating_add(cols(right))
|
||||
.saturating_add(if right.is_empty() { 0 } else { gap });
|
||||
needed <= width
|
||||
};
|
||||
for &(left, right) in candidates {
|
||||
if fits(left, right) {
|
||||
return (left, right);
|
||||
}
|
||||
}
|
||||
candidates.last().copied().unwrap_or(("", ""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bug these helpers exist to prevent: byte length overstates the width of the
|
||||
/// glyphs socktop puts in its header, which is what pushed the right-hand text into
|
||||
/// the title in the first place.
|
||||
#[test]
|
||||
fn cols_counts_columns_not_bytes() {
|
||||
assert_eq!(cols("abc"), 3);
|
||||
// Stopwatch: 3 bytes, 1 column.
|
||||
assert_eq!("⏱".len(), 3);
|
||||
assert_eq!(cols("⏱"), 1);
|
||||
// Lock: 4 bytes, 2 columns.
|
||||
assert_eq!("🔒".len(), 4);
|
||||
assert_eq!(cols("🔒"), 2);
|
||||
assert_eq!(cols("⏱ 500ms metrics | 2000ms procs"), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_respects_the_column_budget() {
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 20), "cachyos-gaming");
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 14), "cachyos-gaming");
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 10), "cachyos-g…");
|
||||
assert_eq!(cols(&truncate_cols("cachyos-gaming", 10)), 10);
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 1), "…");
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 0), "");
|
||||
}
|
||||
|
||||
/// Truncation must never land mid-character or overrun the budget on wide glyphs.
|
||||
#[test]
|
||||
fn truncate_handles_wide_and_multibyte_characters() {
|
||||
for max in 0..12u16 {
|
||||
let out = truncate_cols("🔒🔒🔒 TLS", max);
|
||||
assert!(cols(&out) <= max, "{out:?} exceeds {max} columns");
|
||||
assert!(out.chars().all(|c| c != '\u{fffd}'), "{out:?} split a char");
|
||||
}
|
||||
// A wide glyph that cannot fit beside the ellipsis is dropped whole.
|
||||
assert_eq!(truncate_cols("🔒ab", 2), "…");
|
||||
}
|
||||
|
||||
/// Middle truncation keeps both ends — the parts that identify a device —
|
||||
/// and must never exceed the budget or split a character.
|
||||
#[test]
|
||||
fn truncate_middle_keeps_both_ends_within_budget() {
|
||||
assert_eq!(truncate_middle_cols("/dev/nvme0n1p1", 20), "/dev/nvme0n1p1");
|
||||
let out = truncate_middle_cols("/dev/nvme0n1p1", 9);
|
||||
assert_eq!(cols(&out), 9);
|
||||
assert!(out.starts_with("/dev"), "{out}");
|
||||
assert!(out.ends_with("1p1"), "{out}");
|
||||
assert!(out.contains('…'), "{out}");
|
||||
// Non-ASCII names must not panic (the old byte-slicing version did).
|
||||
for max in 0..12u16 {
|
||||
let out = truncate_middle_cols("диск-🗄️-данные", max);
|
||||
assert!(cols(&out) <= max.max(1), "{out:?} exceeds {max}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_pair_takes_the_richest_that_fits() {
|
||||
let candidates = [
|
||||
("full left text", "full right text"),
|
||||
("left text", "right text"),
|
||||
("left", "right"),
|
||||
];
|
||||
assert_eq!(pick_pair(80, 2, &candidates), candidates[0]);
|
||||
assert_eq!(pick_pair(24, 2, &candidates), candidates[1]);
|
||||
assert_eq!(pick_pair(12, 2, &candidates), candidates[2]);
|
||||
// Below the floor the last candidate is still returned.
|
||||
assert_eq!(pick_pair(1, 2, &candidates), candidates[2]);
|
||||
}
|
||||
|
||||
/// The gap is what keeps the two pieces from touching; it must not be charged when
|
||||
/// there is no right-hand piece to separate.
|
||||
#[test]
|
||||
fn pick_pair_only_charges_the_gap_when_both_sides_are_present() {
|
||||
let candidates = [("0123456789", "x"), ("0123456789", "")];
|
||||
assert_eq!(pick_pair(11, 2, &candidates), candidates[1]);
|
||||
assert_eq!(pick_pair(13, 2, &candidates), candidates[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
};
|
||||
|
||||
use crate::types::Metrics;
|
||||
|
||||
fn fmt_bytes(b: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
let fb = b as f64;
|
||||
|
||||
if fb >= GB {
|
||||
format!("{:.1}G", fb / GB)
|
||||
} else if fb >= MB {
|
||||
format!("{:.1}M", fb / MB)
|
||||
} else if fb >= KB {
|
||||
format!("{:.1}K", fb / KB)
|
||||
} else {
|
||||
format!("{b}B")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let mut area = area;
|
||||
let block = Block::default().borders(Borders::ALL).title("GPU");
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Guard: need some space inside the block
|
||||
if area.height <= 2 || area.width <= 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inner padding consistent with the rest of the app
|
||||
area.y += 1;
|
||||
area.height = area.height.saturating_sub(2);
|
||||
area.x += 1;
|
||||
area.width = area.width.saturating_sub(2);
|
||||
|
||||
let Some(metrics) = m else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(gpus) = metrics.gpus.as_ref() else {
|
||||
f.render_widget(Paragraph::new("No GPUs"), area);
|
||||
return;
|
||||
};
|
||||
if gpus.is_empty() {
|
||||
f.render_widget(Paragraph::new("No GPUs"), area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show 3 rows per GPU: name, util bar, vram bar.
|
||||
if area.height < 3 {
|
||||
return;
|
||||
}
|
||||
let per_gpu_rows: u16 = 3;
|
||||
let max_gpus = (area.height / per_gpu_rows) as usize;
|
||||
let count = gpus.len().min(max_gpus);
|
||||
|
||||
let constraints = vec![Constraint::Length(1); count * per_gpu_rows as usize];
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
// Per bar horizontal layout: [gauge] [value]
|
||||
let split_bar = |r: Rect| {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(8), // gauge column
|
||||
Constraint::Length(24), // value column
|
||||
])
|
||||
.split(r)
|
||||
};
|
||||
|
||||
for i in 0..count {
|
||||
let g = &gpus[i];
|
||||
|
||||
// Row 1: GPU name
|
||||
let name_text = g.name.as_deref().unwrap_or("GPU");
|
||||
let name_p = Paragraph::new(Span::raw(name_text)).style(Style::default().fg(Color::Gray));
|
||||
f.render_widget(name_p, rows[i * 3]);
|
||||
|
||||
// Row 2: Utilization bar + right label
|
||||
let util_cols = split_bar(rows[i * 3 + 1]);
|
||||
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
|
||||
let util_gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Green))
|
||||
.label(Span::raw(""))
|
||||
.ratio(util as f64 / 100.0);
|
||||
f.render_widget(util_gauge, util_cols[0]);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::raw(format!("util: {util}%")))
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
util_cols[1],
|
||||
);
|
||||
|
||||
// Row 3: VRAM bar + right label
|
||||
let mem_cols = split_bar(rows[i * 3 + 2]);
|
||||
let used = g.mem_used.unwrap_or(0);
|
||||
let total = g.mem_total.unwrap_or(1);
|
||||
let mem_ratio = used as f64 / total as f64;
|
||||
let mem_pct = (mem_ratio * 100.0).round() as u16;
|
||||
|
||||
let mem_gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::LightMagenta))
|
||||
.label(Span::raw(""))
|
||||
.ratio(mem_ratio);
|
||||
f.render_widget(mem_gauge, mem_cols[0]);
|
||||
let used_s = fmt_bytes(used);
|
||||
let total_s = fmt_bytes(total);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::raw(format!("vram: {used_s}/{total_s} ({mem_pct}%)")))
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
mem_cols[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line GPU strip for compact mode: no device name (it is the first thing to lose
|
||||
/// value when rows are scarce), just utilisation and VRAM on the single content row
|
||||
/// between the block borders. Only the first GPU fits; the title says so when there are
|
||||
/// more.
|
||||
pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let gpus = m.and_then(|mm| mm.gpus.as_ref());
|
||||
let count = gpus.map(|g| g.len()).unwrap_or(0);
|
||||
let title = if count > 1 {
|
||||
format!("GPU (1/{count})")
|
||||
} else {
|
||||
"GPU".to_string()
|
||||
};
|
||||
f.render_widget(Block::default().borders(Borders::ALL).title(title), area);
|
||||
|
||||
if area.height < 3 || area.width <= 2 {
|
||||
return;
|
||||
}
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width - 2,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let Some(g) = gpus.and_then(|v| v.first()) else {
|
||||
f.render_widget(Paragraph::new("No GPUs"), inner);
|
||||
return;
|
||||
};
|
||||
|
||||
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
|
||||
let used = g.mem_used.unwrap_or(0);
|
||||
let total = g.mem_total.unwrap_or(1);
|
||||
let mem_ratio = if total > 0 {
|
||||
(used as f64 / total as f64).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let util_label = format!("util: {util}%");
|
||||
let mem_label = format!(
|
||||
"vram: {}/{} ({}%)",
|
||||
fmt_bytes(used),
|
||||
fmt_bytes(total),
|
||||
(mem_ratio * 100.0).round() as u16
|
||||
);
|
||||
|
||||
// Bars are sized explicitly rather than left to stretch: an idle bar renders as
|
||||
// empty cells, so a full-width one turns into a long blank run between two labels.
|
||||
const MIN_GAUGE_W: u16 = 6;
|
||||
const MAX_GAUGE_W: u16 = 24;
|
||||
let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side
|
||||
let gauge_w = inner
|
||||
.width
|
||||
.saturating_sub(labels_w)
|
||||
.min(2 * MAX_GAUGE_W)
|
||||
.div_euclid(2);
|
||||
|
||||
// Too narrow for bars worth drawing: keep the numbers, drop the bars.
|
||||
if gauge_w < MIN_GAUGE_W {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::raw(format!("{util_label} {mem_label}")))
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous
|
||||
// on a single line: with an idle bar rendering empty, the next pair's fill ends up
|
||||
// flush against the previous pair's text and reads as belonging to it.
|
||||
let mut x = inner.x;
|
||||
let mut place = |w: u16| {
|
||||
let r = Rect {
|
||||
x,
|
||||
y: inner.y,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
x += w;
|
||||
r
|
||||
};
|
||||
let util_rect = place(util_label.len() as u16 + 2);
|
||||
let util_bar = place(gauge_w);
|
||||
let mem_rect = place(mem_label.len() as u16 + 2);
|
||||
let mem_bar = place(gauge_w);
|
||||
|
||||
let label = |text: &str| {
|
||||
Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray))
|
||||
};
|
||||
|
||||
f.render_widget(label(&util_label), util_rect);
|
||||
f.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Green))
|
||||
.label(Span::raw(""))
|
||||
.ratio(util as f64 / 100.0),
|
||||
util_bar,
|
||||
);
|
||||
f.render_widget(label(&mem_label), mem_rect);
|
||||
f.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::LightMagenta))
|
||||
.label(Span::raw(""))
|
||||
.ratio(mem_ratio),
|
||||
mem_bar,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use socktop_connector::{GpuInfo, Metrics};
|
||||
|
||||
fn gpu(name: &str) -> GpuInfo {
|
||||
GpuInfo {
|
||||
name: Some(name.into()),
|
||||
vendor: None,
|
||||
utilization: Some(42.0),
|
||||
mem_used: Some(4_724_464_025),
|
||||
mem_total: Some(17_070_817_280),
|
||||
temp: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
|
||||
Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 1024,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: "t".into(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus,
|
||||
process_count: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(width: u16, m: &Metrics) -> String {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
|
||||
terminal
|
||||
.draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m)))
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area().height {
|
||||
for x in 0..buf.area().width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compact mode drops the device name — the row is one line and the numbers are
|
||||
/// what the space is for.
|
||||
#[test]
|
||||
fn compact_strip_omits_the_device_name() {
|
||||
let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")]));
|
||||
let out = render(80, &m);
|
||||
assert!(
|
||||
!out.contains("NVIDIA"),
|
||||
"name leaked into compact strip:\n{out}"
|
||||
);
|
||||
assert!(out.contains("util: 42%"), "{out}");
|
||||
assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}");
|
||||
}
|
||||
|
||||
/// A second GPU cannot fit on one line, so the title has to say the strip is partial
|
||||
/// rather than silently showing only the first card.
|
||||
#[test]
|
||||
fn multiple_gpus_are_flagged_in_the_title() {
|
||||
let one = render(80, &metrics(Some(vec![gpu("a")])));
|
||||
assert!(one.contains("GPU") && !one.contains("1/"), "{one}");
|
||||
|
||||
let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")])));
|
||||
assert!(two.contains("GPU (1/2)"), "{two}");
|
||||
}
|
||||
|
||||
/// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must
|
||||
/// never drop the numbers.
|
||||
#[test]
|
||||
fn narrow_strip_keeps_the_numbers() {
|
||||
let m = metrics(Some(vec![gpu("a")]));
|
||||
for width in [20u16, 30, 40, 47, 48, 80, 200] {
|
||||
let out = render(width, &m);
|
||||
if width >= 40 {
|
||||
assert!(out.contains("util: 42%"), "width {width}:\n{out}");
|
||||
}
|
||||
// No panic, and the block always closes on the last row.
|
||||
assert_eq!(out.lines().count(), 3, "width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_gpu_payload_does_not_panic() {
|
||||
assert!(render(80, &metrics(None)).contains("No GPUs"));
|
||||
assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs"));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,232 @@
|
||||
//! Top header with hostname and CPU temperature indicator.
|
||||
//! Top header with hostname, connection status and polling intervals.
|
||||
//!
|
||||
//! The row carries two pieces of text — session identity on the left, polling intervals
|
||||
//! on the right — and both matter. Rather than let the right one overwrite the left when
|
||||
//! they no longer both fit, the header drops detail in priority order: the hostname and
|
||||
//! the intervals are what survive longest, because they are what tells you *which* host
|
||||
//! you are looking at and how fresh the numbers are.
|
||||
|
||||
use crate::ui::fit::{cols, pick_pair, truncate_cols};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use crate::types::Metrics;
|
||||
|
||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let title = if let Some(mm) = m {
|
||||
let temp = mm.cpu_temp_c.map(|t| {
|
||||
let icon = if t < 50.0 { "😎" } else if t < 85.0 { "⚠️" } else { "🔥" };
|
||||
format!("CPU Temp: {:.1}°C {}", t, icon)
|
||||
}).unwrap_or_else(|| "CPU Temp: N/A".into());
|
||||
format!("socktop — host: {} | {} (press 'q' to quit)", mm.hostname, temp)
|
||||
/// Columns kept clear between the left and right halves.
|
||||
const GAP: u16 = 2;
|
||||
/// Never shorten the hostname below this before dropping the intervals instead.
|
||||
const HOSTNAME_FLOOR: u16 = 8;
|
||||
|
||||
/// Session state the header renders.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct HeaderState<'a> {
|
||||
pub hostname: Option<&'a str>,
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
pub metrics_ms: u128,
|
||||
pub procs_ms: u128,
|
||||
}
|
||||
|
||||
/// Builds the left and right halves of the header for a row `width` columns wide.
|
||||
///
|
||||
/// Detail is dropped in this order as the row narrows: the key hints, then the TLS/token
|
||||
/// badges, then the `socktop — host:` prefix (leaving the bare hostname), then the
|
||||
/// `metrics`/`procs` words, and only then is the hostname itself shortened. The two
|
||||
/// halves are always sized to sit side by side, so neither can paint over the other.
|
||||
///
|
||||
/// Callers cache the result and rebuild it only when the state or the width changes.
|
||||
pub fn build_header(state: HeaderState<'_>, width: u16) -> (String, String) {
|
||||
let host = state.hostname.unwrap_or("connecting...");
|
||||
let tls = if state.is_tls {
|
||||
"🔒 TLS"
|
||||
} else {
|
||||
"socktop — connecting... (press 'q' to quit)".into()
|
||||
"🔒✗ TLS"
|
||||
};
|
||||
let badges = if state.has_token {
|
||||
format!("{tls} | 🔑 token")
|
||||
} else {
|
||||
tls.to_string()
|
||||
};
|
||||
|
||||
let named = format!("socktop — host: {host}");
|
||||
let with_badges = format!("{named} | {badges}");
|
||||
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
|
||||
|
||||
let intervals = format!(
|
||||
"⏱ {}ms metrics | {}ms procs",
|
||||
state.metrics_ms, state.procs_ms
|
||||
);
|
||||
let intervals_short = format!("⏱ {}ms | {}ms", state.metrics_ms, state.procs_ms);
|
||||
|
||||
// Richest first. The bare hostname is reached before the intervals lose their
|
||||
// labels, and the hostname is only shortened once nothing else is left to give.
|
||||
let ladder = [
|
||||
(with_keys.as_str(), intervals.as_str()),
|
||||
(with_badges.as_str(), intervals.as_str()),
|
||||
(named.as_str(), intervals.as_str()),
|
||||
(host, intervals.as_str()),
|
||||
(host, intervals_short.as_str()),
|
||||
];
|
||||
let (left, right) = pick_pair(width, GAP, &ladder);
|
||||
if cols(left) + cols(right) + GAP <= width {
|
||||
return (left.to_string(), right.to_string());
|
||||
}
|
||||
|
||||
// Past the floor of the ladder: shorten the hostname, and give up the intervals only
|
||||
// if even a stub of a hostname will not fit beside them.
|
||||
let room = width
|
||||
.saturating_sub(cols(&intervals_short))
|
||||
.saturating_sub(GAP);
|
||||
if room >= HOSTNAME_FLOOR {
|
||||
return (truncate_cols(host, room), intervals_short);
|
||||
}
|
||||
(truncate_cols(host, width), String::new())
|
||||
}
|
||||
|
||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
}
|
||||
|
||||
if intervals.is_empty() {
|
||||
return;
|
||||
}
|
||||
let intervals_width = cols(intervals);
|
||||
if area.width >= intervals_width {
|
||||
let right_area = Rect {
|
||||
x: area.x + area.width - intervals_width,
|
||||
y: area.y,
|
||||
width: intervals_width,
|
||||
height: 1,
|
||||
};
|
||||
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(hostname: Option<&str>) -> HeaderState<'_> {
|
||||
HeaderState {
|
||||
hostname,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
metrics_ms: 500,
|
||||
procs_ms: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The defect this replaces: the two halves were painted independently, so below
|
||||
/// ~105 columns the right half landed on top of the title. Whatever the width, they
|
||||
/// must now fit side by side.
|
||||
#[test]
|
||||
fn halves_never_overlap_at_any_width() {
|
||||
for width in 0..=200u16 {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||
let used = cols(&left) + cols(&right);
|
||||
if right.is_empty() {
|
||||
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
|
||||
} else {
|
||||
assert!(
|
||||
used + GAP <= width,
|
||||
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostname and intervals are the two things worth keeping; everything else is
|
||||
/// context that can go.
|
||||
#[test]
|
||||
fn hostname_and_intervals_survive_longest() {
|
||||
for width in 34..=200u16 {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||
assert!(
|
||||
left.contains("cachyos-gaming"),
|
||||
"width {width}: lost the hostname ({left:?})"
|
||||
);
|
||||
assert!(
|
||||
right.contains("500ms") && right.contains("2000ms"),
|
||||
"width {width}: lost the intervals ({right:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder from the design: key hints, then badges, then the prefix, then the
|
||||
/// interval labels, then the hostname itself.
|
||||
#[test]
|
||||
fn detail_is_dropped_in_priority_order() {
|
||||
let s = state(Some("cachyos-gaming"));
|
||||
|
||||
let (left, right) = build_header(s, 120);
|
||||
assert_eq!(
|
||||
left,
|
||||
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
|
||||
);
|
||||
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||
|
||||
// Key hints go first.
|
||||
let (left, _) = build_header(s, 80);
|
||||
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
|
||||
|
||||
// Then the badges.
|
||||
let (left, _) = build_header(s, 70);
|
||||
assert_eq!(left, "socktop — host: cachyos-gaming");
|
||||
|
||||
// Then the prefix, leaving the bare hostname.
|
||||
let (left, right) = build_header(s, 50);
|
||||
assert_eq!(left, "cachyos-gaming");
|
||||
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||
|
||||
// Then the interval labels.
|
||||
let (left, right) = build_header(s, 34);
|
||||
assert_eq!(left, "cachyos-gaming");
|
||||
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||
|
||||
// Only then is the hostname itself shortened.
|
||||
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
|
||||
let (left, right) = build_header(s, 30);
|
||||
assert_eq!(left, "cachyos-gam…");
|
||||
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||
}
|
||||
|
||||
/// A long hostname must not push the intervals off the row.
|
||||
#[test]
|
||||
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
|
||||
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
|
||||
for width in 30..=100u16 {
|
||||
let (left, right) = build_header(state(Some(long)), width);
|
||||
assert!(!right.is_empty(), "width {width}: intervals were dropped");
|
||||
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Widths too small for both: the hostname is the last thing standing.
|
||||
#[test]
|
||||
fn hostname_is_the_final_survivor() {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
|
||||
assert!(right.is_empty(), "intervals should have been dropped");
|
||||
assert!(!left.is_empty());
|
||||
assert!(cols(&left) <= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_and_token_badges_appear_when_there_is_room() {
|
||||
let s = HeaderState {
|
||||
hostname: Some("host"),
|
||||
is_tls: true,
|
||||
has_token: true,
|
||||
metrics_ms: 500,
|
||||
procs_ms: 2000,
|
||||
};
|
||||
let (left, _) = build_header(s, 200);
|
||||
assert!(left.contains("🔒 TLS"), "{left}");
|
||||
assert!(left.contains("🔑 token"), "{left}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_hostname_reads_as_connecting() {
|
||||
let (left, _) = build_header(state(None), 120);
|
||||
assert!(left.contains("connecting"), "{left}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Root layout computation, shared by the draw path and the input hit-testing paths.
|
||||
//!
|
||||
//! Two modes:
|
||||
//!
|
||||
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
|
||||
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
|
||||
//! network graphs next to the process table.
|
||||
//!
|
||||
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
|
||||
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
|
||||
//! by side into the space it vacated, the GPU collapses to a single full-width line
|
||||
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
|
||||
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
|
||||
//! long before the rest of the panes stop being useful.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Which of the two layouts [`compute`] produced.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LayoutMode {
|
||||
Normal,
|
||||
Compact,
|
||||
}
|
||||
|
||||
impl LayoutMode {
|
||||
pub fn is_compact(self) -> bool {
|
||||
matches!(self, LayoutMode::Compact)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows the Disks pane needs before it can show one disk card: the card itself is
|
||||
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
|
||||
const DISKS_MIN_H: u16 = 5;
|
||||
|
||||
/// Header line.
|
||||
const HEADER_H: u16 = 1;
|
||||
/// Memory and Swap gauges: 1 content row between borders.
|
||||
const GAUGE_H: u16 = 3;
|
||||
/// A network graph at its preferred height.
|
||||
const NET_H: u16 = 5;
|
||||
|
||||
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
|
||||
// borders) before the network graphs are allowed to shrink, because restoring the CPU
|
||||
// panes is the entire point of the mode.
|
||||
const TOP_MIN_H: u16 = 5;
|
||||
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
|
||||
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
|
||||
|
||||
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AppLayout {
|
||||
pub mode: LayoutMode,
|
||||
pub header: Rect,
|
||||
pub cpu: Rect,
|
||||
pub per_core: Rect,
|
||||
pub gpu: Option<Rect>,
|
||||
pub mem: Rect,
|
||||
pub swap: Rect,
|
||||
pub disks: Option<Rect>,
|
||||
pub download: Rect,
|
||||
pub upload: Rect,
|
||||
pub procs: Rect,
|
||||
}
|
||||
|
||||
/// Splits `area` into pane rects.
|
||||
///
|
||||
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
|
||||
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
|
||||
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
|
||||
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
|
||||
if force_compact {
|
||||
return compact(area, has_gpu);
|
||||
}
|
||||
let normal = normal(area);
|
||||
match normal.disks {
|
||||
Some(d) if d.height >= DISKS_MIN_H => normal,
|
||||
_ => compact(area, has_gpu),
|
||||
}
|
||||
}
|
||||
|
||||
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
|
||||
Layout::default()
|
||||
.direction(dir)
|
||||
.constraints(constraints)
|
||||
.split(area)
|
||||
}
|
||||
|
||||
/// 66/34 split used by every full-width row in the normal layout.
|
||||
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
|
||||
split(
|
||||
area,
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(66), Constraint::Percentage(34)],
|
||||
)
|
||||
}
|
||||
|
||||
fn normal(area: Rect) -> AppLayout {
|
||||
let rows = split(
|
||||
area,
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Length(HEADER_H), // header
|
||||
Constraint::Ratio(1, 3), // top row
|
||||
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
|
||||
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
|
||||
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
|
||||
],
|
||||
);
|
||||
|
||||
let top = left_right(rows[1]);
|
||||
let mem_lr = left_right(rows[2]);
|
||||
let swap_lr = left_right(rows[3]);
|
||||
|
||||
// GPU spans the same vertical space as Memory + Swap.
|
||||
let gpu = Rect {
|
||||
x: mem_lr[1].x,
|
||||
y: mem_lr[1].y,
|
||||
width: mem_lr[1].width,
|
||||
height: mem_lr[1].height + swap_lr[1].height,
|
||||
};
|
||||
|
||||
let bottom = split(
|
||||
rows[4],
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||
);
|
||||
let left_stack = split(
|
||||
bottom[0],
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Min(4), // disks absorbs the slack
|
||||
Constraint::Length(NET_H), // download
|
||||
Constraint::Length(NET_H), // upload
|
||||
],
|
||||
);
|
||||
|
||||
AppLayout {
|
||||
mode: LayoutMode::Normal,
|
||||
header: rows[0],
|
||||
cpu: top[0],
|
||||
per_core: top[1],
|
||||
gpu: Some(gpu),
|
||||
mem: mem_lr[0],
|
||||
swap: swap_lr[0],
|
||||
disks: Some(left_stack[0]),
|
||||
download: left_stack[1],
|
||||
upload: left_stack[2],
|
||||
procs: bottom[1],
|
||||
}
|
||||
}
|
||||
|
||||
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
|
||||
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
|
||||
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
|
||||
|
||||
// Give the top row its floor first, then share any surplus with the bottom so the
|
||||
// process table keeps growing with the window instead of staying pinned at 13 rows.
|
||||
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
|
||||
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
|
||||
(top, avail - top)
|
||||
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
|
||||
(TOP_MIN_H, avail - TOP_MIN_H)
|
||||
} else {
|
||||
// Smaller than both floors: the network graphs are already at their minimum, so
|
||||
// the top row takes what is left (panes clip below this point).
|
||||
let bottom = BOTTOM_MIN_H.min(avail);
|
||||
(avail - bottom, bottom)
|
||||
};
|
||||
|
||||
let rows = split(
|
||||
area,
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Length(HEADER_H),
|
||||
Constraint::Length(top_h),
|
||||
Constraint::Length(gpu_h),
|
||||
Constraint::Length(bottom_h),
|
||||
],
|
||||
);
|
||||
|
||||
let top = left_right(rows[1]);
|
||||
|
||||
let bottom = split(
|
||||
rows[3],
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||
);
|
||||
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
|
||||
let left_stack = split(
|
||||
bottom[0],
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Length(GAUGE_H),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
],
|
||||
);
|
||||
let gauges = split(
|
||||
left_stack[0],
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(50), Constraint::Percentage(50)],
|
||||
);
|
||||
|
||||
AppLayout {
|
||||
mode: LayoutMode::Compact,
|
||||
header: rows[0],
|
||||
cpu: top[0],
|
||||
per_core: top[1],
|
||||
gpu: has_gpu.then_some(rows[2]),
|
||||
mem: gauges[0],
|
||||
swap: gauges[1],
|
||||
disks: None,
|
||||
download: left_stack[1],
|
||||
upload: left_stack[2],
|
||||
procs: bottom[1],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn area(w: u16, h: u16) -> Rect {
|
||||
Rect::new(0, 0, w, h)
|
||||
}
|
||||
|
||||
/// The height where the normal layout still fits a full disk card. Below it the CPU
|
||||
/// panes are the ones that collapse, which is what compact mode exists to prevent.
|
||||
#[test]
|
||||
fn tall_window_stays_normal() {
|
||||
let l = compute(area(120, 40), false, true);
|
||||
assert_eq!(l.mode, LayoutMode::Normal);
|
||||
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_window_switches_to_compact() {
|
||||
let l = compute(area(120, 24), false, true);
|
||||
assert_eq!(l.mode, LayoutMode::Compact);
|
||||
assert!(l.disks.is_none());
|
||||
}
|
||||
|
||||
/// The switch happens exactly when Disks can no longer show one card, and never
|
||||
/// oscillates: every height above the crossover is normal, every height below is
|
||||
/// compact.
|
||||
#[test]
|
||||
fn mode_is_monotonic_in_height() {
|
||||
let mut first_normal = None;
|
||||
for h in 10..=60u16 {
|
||||
let mode = compute(area(120, h), false, true).mode;
|
||||
match (mode, first_normal) {
|
||||
(LayoutMode::Normal, None) => first_normal = Some(h),
|
||||
(LayoutMode::Compact, Some(prev)) => {
|
||||
panic!("height {h} went back to compact after normal at {prev}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(first_normal.is_some(), "never reached the normal layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_compact_overrides_a_tall_window() {
|
||||
let l = compute(area(200, 80), true, true);
|
||||
assert_eq!(l.mode, LayoutMode::Compact);
|
||||
assert!(l.disks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_drops_the_gpu_row_without_a_gpu() {
|
||||
let with = compute(area(120, 24), true, true);
|
||||
let without = compute(area(120, 24), true, false);
|
||||
assert!(with.gpu.is_some());
|
||||
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
|
||||
assert!(without.gpu.is_none());
|
||||
// The rows a GPU-less host saves are shared between the CPU panes and the
|
||||
// bottom half, and none of them are left as a gap.
|
||||
assert!(without.cpu.height > with.cpu.height);
|
||||
assert!(without.procs.height > with.procs.height);
|
||||
assert_eq!(without.procs.y + without.procs.height, 24);
|
||||
}
|
||||
|
||||
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
|
||||
/// content rows inside their borders.
|
||||
#[test]
|
||||
fn compact_keeps_the_cpu_panes_drawable() {
|
||||
for h in 18..=32u16 {
|
||||
let l = compute(area(120, h), false, true);
|
||||
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
|
||||
assert!(
|
||||
l.cpu.height >= TOP_MIN_H,
|
||||
"height {h}: cpu pane only {} rows",
|
||||
l.cpu.height
|
||||
);
|
||||
assert_eq!(l.per_core.height, l.cpu.height);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
|
||||
/// left the top row with no drawable interior at all.
|
||||
#[test]
|
||||
fn compact_beats_the_fixed_layout_at_18_rows() {
|
||||
let compact = compute(area(120, 18), false, true);
|
||||
let fixed = normal(area(120, 18));
|
||||
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
|
||||
assert!(compact.cpu.height > fixed.cpu.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_panes_tile_the_area_without_gaps() {
|
||||
for h in 16..=32u16 {
|
||||
for has_gpu in [true, false] {
|
||||
let l = compute(area(120, h), true, has_gpu);
|
||||
assert_eq!(l.header.y, 0);
|
||||
assert_eq!(l.cpu.y, l.header.y + l.header.height);
|
||||
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
|
||||
|
||||
let after_cpu = l.cpu.y + l.cpu.height;
|
||||
let bottom_y = match l.gpu {
|
||||
Some(g) => {
|
||||
assert_eq!(g.y, after_cpu);
|
||||
assert_eq!(g.width, 120, "gpu strip spans the full width");
|
||||
g.y + g.height
|
||||
}
|
||||
None => after_cpu,
|
||||
};
|
||||
assert_eq!(l.mem.y, bottom_y);
|
||||
// Memory and Swap sit side by side on one row.
|
||||
assert_eq!(l.swap.y, l.mem.y);
|
||||
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
|
||||
assert_eq!(l.mem.height, GAUGE_H);
|
||||
assert_eq!(l.download.y, l.mem.y + l.mem.height);
|
||||
assert_eq!(l.upload.y, l.download.y + l.download.height);
|
||||
assert_eq!(l.procs.y, bottom_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate size must not panic or produce rects outside the frame.
|
||||
#[test]
|
||||
fn tiny_windows_stay_inside_the_frame() {
|
||||
for h in 0..=16u16 {
|
||||
for w in [0u16, 1, 20, 80] {
|
||||
let l = compute(area(w, h), false, true);
|
||||
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
|
||||
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
|
||||
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
//! Memory gauge.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::human;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge},
|
||||
};
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::human;
|
||||
|
||||
pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let (used, total, pct) = if let Some(mm) = m {
|
||||
let pct = if mm.mem_total > 0 { (mm.mem_used as f64 / mm.mem_total as f64 * 100.0) as u16 } else { 0 };
|
||||
let pct = if mm.mem_total > 0 {
|
||||
(mm.mem_used as f64 / mm.mem_total as f64 * 100.0) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(mm.mem_used, mm.mem_total, pct)
|
||||
} else { (0, 0, 0) };
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
let g = Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title("Memory"))
|
||||
@@ -20,4 +26,4 @@ pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
.percent(pct)
|
||||
.label(format!("{} / {}", human(used), human(total)));
|
||||
f.render_widget(g, area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
//! UI module root: exposes drawing functions for individual panels.
|
||||
|
||||
pub mod header;
|
||||
pub mod cpu;
|
||||
pub mod mem;
|
||||
pub mod swap;
|
||||
pub mod disks;
|
||||
pub mod fit;
|
||||
pub mod gpu;
|
||||
pub mod header;
|
||||
pub mod layout;
|
||||
pub mod mem;
|
||||
pub mod modal;
|
||||
pub mod modal_connection;
|
||||
pub mod modal_format;
|
||||
pub mod modal_process;
|
||||
pub mod modal_types;
|
||||
pub mod net;
|
||||
pub mod processes;
|
||||
pub mod util;
|
||||
pub mod swap;
|
||||
pub mod theme;
|
||||
pub mod util;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Connection error modal rendering
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::modal_format::format_duration;
|
||||
use super::theme::{
|
||||
BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT,
|
||||
BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER,
|
||||
ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE,
|
||||
LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG,
|
||||
MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, MODAL_RETRY_LABEL_FG,
|
||||
MODAL_TITLE_FG,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use super::modal::{ModalButton, ModalManager};
|
||||
|
||||
impl ModalManager {
|
||||
pub(super) fn render_connection_error(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
message: &str,
|
||||
disconnected_at: Instant,
|
||||
retry_count: u32,
|
||||
auto_retry_countdown: Option<u64>,
|
||||
) {
|
||||
let duration_text = format_duration(disconnected_at.elapsed());
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(area);
|
||||
let block = Block::default()
|
||||
.title(
|
||||
Line::from(ICON_WARNING_TITLE).style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||
.style(Style::default().bg(MODAL_BG).fg(MODAL_FG));
|
||||
f.render_widget(block, area);
|
||||
|
||||
let content_area = chunks[1];
|
||||
let max_w = content_area.width.saturating_sub(15) as usize;
|
||||
let clean_message = if message.to_lowercase().contains("hostname verification")
|
||||
|| message.contains("socktop_connector")
|
||||
{
|
||||
"Connection failed - hostname verification disabled".to_string()
|
||||
} else if message.contains("Failed to fetch metrics:") {
|
||||
if let Some(p) = message.find(':') {
|
||||
let ess = message[p + 1..].trim();
|
||||
if ess.len() > max_w {
|
||||
format!("{}...", &ess[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
ess.to_string()
|
||||
}
|
||||
} else {
|
||||
"Connection error".to_string()
|
||||
}
|
||||
} else if message.starts_with("Retry failed:") {
|
||||
if let Some(p) = message.find(':') {
|
||||
let ess = message[p + 1..].trim();
|
||||
if ess.len() > max_w {
|
||||
format!("{}...", &ess[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
ess.to_string()
|
||||
}
|
||||
} else {
|
||||
"Retry failed".to_string()
|
||||
}
|
||||
} else if message.len() > max_w {
|
||||
format!("{}...", &message[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
let truncate = |s: &str| {
|
||||
if s.len() > max_w {
|
||||
format!("{}...", &s[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let agent_text = truncate("📡 Cannot connect to socktop agent");
|
||||
let message_text = truncate(&clean_message);
|
||||
let duration_display = truncate(&duration_text);
|
||||
let retry_display = truncate(&retry_count.to_string());
|
||||
let countdown_text = auto_retry_countdown.map(|c| {
|
||||
if c == 0 {
|
||||
"Auto retry now...".to_string()
|
||||
} else {
|
||||
format!("{c}s")
|
||||
}
|
||||
});
|
||||
|
||||
// Determine if we have enough space (height + width) to show large centered icon
|
||||
let icon_max_width = LARGE_ERROR_ICON
|
||||
.iter()
|
||||
.map(|l| l.trim().chars().count())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8)
|
||||
&& content_area.width >= icon_max_width + 6; // small margin for borders/padding
|
||||
let mut icon_lines: Vec<Line> = Vec::new();
|
||||
if large_allowed {
|
||||
for &raw in LARGE_ERROR_ICON.iter() {
|
||||
let trimmed = raw.trim();
|
||||
icon_lines.push(Line::from(
|
||||
trimmed
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch == '!' {
|
||||
Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if ch == '/' || ch == '\\' || ch == '_' {
|
||||
// keep outline in pink
|
||||
Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default()
|
||||
.fg(MODAL_ICON_PINK)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if ch == ' ' {
|
||||
Span::raw(" ")
|
||||
} else {
|
||||
Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
}
|
||||
icon_lines.push(Line::from("")); // blank spacer line below icon
|
||||
}
|
||||
|
||||
let mut info_lines: Vec<Line> = Vec::new();
|
||||
if !large_allowed {
|
||||
info_lines.push(Line::from(vec![Span::styled(
|
||||
ICON_CLUSTER,
|
||||
Style::default().fg(MODAL_ICON_PINK),
|
||||
)]));
|
||||
info_lines.push(Line::from(""));
|
||||
}
|
||||
info_lines.push(Line::from(vec![Span::styled(
|
||||
&agent_text,
|
||||
Style::default().fg(MODAL_AGENT_FG),
|
||||
)]));
|
||||
info_lines.push(Line::from(""));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)),
|
||||
Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)),
|
||||
]));
|
||||
info_lines.push(Line::from(""));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
ICON_OFFLINE_LABEL,
|
||||
Style::default().fg(MODAL_OFFLINE_LABEL_FG),
|
||||
),
|
||||
Span::styled(
|
||||
&duration_display,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)),
|
||||
Span::styled(
|
||||
&retry_display,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
if let Some(cd) = &countdown_text {
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
ICON_COUNTDOWN_LABEL,
|
||||
Style::default().fg(MODAL_COUNTDOWN_LABEL_FG),
|
||||
),
|
||||
Span::styled(
|
||||
cd,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let constrained = Rect {
|
||||
x: content_area.x + 2,
|
||||
y: content_area.y,
|
||||
width: content_area.width.saturating_sub(4),
|
||||
height: content_area.height,
|
||||
};
|
||||
if large_allowed {
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(icon_lines.len() as u16),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(constrained);
|
||||
// Center the icon block; each line already trimmed so per-line centering keeps shape
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(icon_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: false }),
|
||||
split[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(info_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
split[1],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(info_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
constrained,
|
||||
);
|
||||
}
|
||||
|
||||
let button_area = Rect {
|
||||
x: chunks[2].x,
|
||||
y: chunks[2].y,
|
||||
width: chunks[2].width,
|
||||
height: chunks[2].height.saturating_sub(1),
|
||||
};
|
||||
self.render_connection_error_buttons(f, button_area);
|
||||
}
|
||||
|
||||
fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) {
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(30),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(30),
|
||||
])
|
||||
.split(area);
|
||||
let retry_style = if self.active_button == ModalButton::Retry {
|
||||
Style::default()
|
||||
.bg(BTN_RETRY_BG_ACTIVE)
|
||||
.fg(BTN_RETRY_FG_ACTIVE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(BTN_RETRY_FG_INACTIVE)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
let exit_style = if self.active_button == ModalButton::Exit {
|
||||
Style::default()
|
||||
.bg(BTN_EXIT_BG_ACTIVE)
|
||||
.fg(BTN_EXIT_FG_ACTIVE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(BTN_EXIT_FG_INACTIVE)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(Line::from(vec![Span::styled(
|
||||
BTN_RETRY_TEXT,
|
||||
retry_style,
|
||||
)])))
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(Line::from(vec![Span::styled(
|
||||
BTN_EXIT_TEXT,
|
||||
exit_style,
|
||||
)])))
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[3],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Formatting utilities for process details modal
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Format uptime in human-readable form
|
||||
pub fn format_uptime(secs: u64) -> String {
|
||||
let days = secs / 86400;
|
||||
let hours = (secs % 86400) / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
let seconds = secs % 60;
|
||||
|
||||
if days > 0 {
|
||||
format!("{days}d {hours}h {minutes}m")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h {minutes}m {seconds}s")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}m {seconds}s")
|
||||
} else {
|
||||
format!("{seconds}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format duration in human-readable form
|
||||
pub fn format_duration(duration: Duration) -> String {
|
||||
let total = duration.as_secs();
|
||||
let h = total / 3600;
|
||||
let m = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if h > 0 {
|
||||
format!("{h}h {m}m {s}s")
|
||||
} else if m > 0 {
|
||||
format!("{m}m {s}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize CPU usage to 0-100% by dividing by thread count
|
||||
pub fn normalize_cpu_usage(cpu_usage: f32, thread_count: u32) -> f32 {
|
||||
let threads = thread_count.max(1) as f32;
|
||||
(cpu_usage / threads).min(100.0)
|
||||
}
|
||||
|
||||
/// Calculate dynamic Y-axis maximum in 10% increments
|
||||
pub fn calculate_dynamic_y_max(max_value: f64) -> f64 {
|
||||
((max_value / 10.0).ceil() * 10.0).clamp(10.0, 100.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_seconds() {
|
||||
assert_eq!(format_uptime(45), "45s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_minutes() {
|
||||
assert_eq!(format_uptime(125), "2m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_hours() {
|
||||
assert_eq!(format_uptime(3665), "1h 1m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_days() {
|
||||
assert_eq!(format_uptime(90061), "1d 1h 1m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_single_thread() {
|
||||
assert_eq!(normalize_cpu_usage(50.0, 1), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_multi_thread() {
|
||||
assert_eq!(normalize_cpu_usage(400.0, 4), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_zero_threads() {
|
||||
// Should default to 1 thread to avoid division by zero
|
||||
assert_eq!(normalize_cpu_usage(100.0, 0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_caps_at_100() {
|
||||
assert_eq!(normalize_cpu_usage(150.0, 1), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_y_max_rounds_up() {
|
||||
assert_eq!(calculate_dynamic_y_max(15.0), 20.0);
|
||||
assert_eq!(calculate_dynamic_y_max(25.0), 30.0);
|
||||
assert_eq!(calculate_dynamic_y_max(5.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_y_max_minimum() {
|
||||
assert_eq!(calculate_dynamic_y_max(0.0), 10.0);
|
||||
assert_eq!(calculate_dynamic_y_max(3.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_y_max_caps_at_100() {
|
||||
assert_eq!(calculate_dynamic_y_max(95.0), 100.0);
|
||||
assert_eq!(calculate_dynamic_y_max(100.0), 100.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Type definitions for modal system
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// History data for process metrics rendering
|
||||
pub struct ProcessHistoryData<'a> {
|
||||
pub cpu: &'a std::collections::VecDeque<f32>,
|
||||
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
|
||||
pub cpu_sum: f32,
|
||||
pub mem: &'a std::collections::VecDeque<u64>,
|
||||
pub io_read: &'a std::collections::VecDeque<u64>,
|
||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||
}
|
||||
|
||||
/// Process data for modal rendering
|
||||
pub struct ProcessModalData<'a> {
|
||||
pub details: Option<&'a socktop_connector::ProcessMetricsResponse>,
|
||||
pub journal: Option<&'a socktop_connector::JournalResponse>,
|
||||
pub history: ProcessHistoryData<'a>,
|
||||
pub max_mem_bytes: u64,
|
||||
pub unsupported: bool,
|
||||
/// Whether the agent is on this machine. Only used to decide whether the
|
||||
/// `t` kill hint is shown — the kill itself is gated in `App`.
|
||||
pub is_local: bool,
|
||||
}
|
||||
|
||||
/// Parameters for rendering scatter plot
|
||||
pub(super) struct ScatterPlotParams<'a> {
|
||||
pub process: &'a socktop_connector::DetailedProcessInfo,
|
||||
pub main_user_ms: f64,
|
||||
pub main_system_ms: f64,
|
||||
pub max_user: f64,
|
||||
pub max_system: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModalType {
|
||||
ConnectionError {
|
||||
message: String,
|
||||
disconnected_at: Instant,
|
||||
retry_count: u32,
|
||||
auto_retry_countdown: Option<u64>,
|
||||
},
|
||||
ProcessDetails {
|
||||
pid: u32,
|
||||
},
|
||||
About,
|
||||
Help,
|
||||
#[allow(dead_code)]
|
||||
Confirmation {
|
||||
title: String,
|
||||
message: String,
|
||||
confirm_text: String,
|
||||
cancel_text: String,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Info {
|
||||
title: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ModalAction {
|
||||
None, // Modal didn't handle the key, pass to main window
|
||||
Handled, // Modal handled the key, don't pass to main window
|
||||
RetryConnection,
|
||||
ExitApp,
|
||||
Confirm,
|
||||
/// Confirmation modal's second affirmative: the same action, escalated.
|
||||
/// Used by the kill prompt for SIGKILL, where `Confirm` means SIGTERM.
|
||||
ConfirmForce,
|
||||
Cancel,
|
||||
Dismiss,
|
||||
SwitchToParentProcess(u32), // Switch to viewing parent process details
|
||||
/// `t` pressed while viewing a process's details — the app decides whether
|
||||
/// the agent is local and, if so, raises the kill confirmation.
|
||||
KillSelected(u32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ModalButton {
|
||||
Retry,
|
||||
Exit,
|
||||
Confirm,
|
||||
/// Escalated affirmative on a Confirmation modal (SIGKILL for the kill
|
||||
/// prompt). Separate button rather than a separate keybinding so the
|
||||
/// destructive option has to be selected deliberately.
|
||||
ConfirmForce,
|
||||
Cancel,
|
||||
Ok,
|
||||
}
|
||||
@@ -1,26 +1,30 @@
|
||||
//! Network sparklines (download/upload).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Sparkline},
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn draw_net_spark(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
hist: &VecDeque<u64>,
|
||||
hist: &mut VecDeque<u64>,
|
||||
color: Color,
|
||||
) {
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title.to_string()))
|
||||
.data(&data)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string()),
|
||||
)
|
||||
.data(slice)
|
||||
.style(Style::default().fg(color));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
//! Swap gauge.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::human;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge},
|
||||
};
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::human;
|
||||
|
||||
pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let (used, total, pct) = if let Some(mm) = m {
|
||||
let pct = if mm.swap_total > 0 { (mm.swap_used as f64 / mm.swap_total as f64 * 100.0) as u16 } else { 0 };
|
||||
let pct = if mm.swap_total > 0 {
|
||||
(mm.swap_used as f64 / mm.swap_total as f64 * 100.0) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(mm.swap_used, mm.swap_total, pct)
|
||||
} else { (0, 0, 0) };
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
let g = Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title("Swap"))
|
||||
@@ -20,4 +26,4 @@ pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
.percent(pct)
|
||||
.label(format!("{} / {}", human(used), human(total)));
|
||||
f.render_widget(g, area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Shared UI theme constants.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
// Scrollbar colors (same look as before)
|
||||
pub const SB_ARROW: Color = Color::Rgb(170, 170, 180);
|
||||
pub const SB_TRACK: Color = Color::Rgb(170, 170, 180);
|
||||
pub const SB_THUMB: Color = Color::Rgb(170, 170, 180);
|
||||
|
||||
// Modal palette
|
||||
pub const MODAL_DIM_BG: Color = Color::Rgb(15, 15, 25);
|
||||
pub const MODAL_BG: Color = Color::Rgb(26, 26, 46);
|
||||
pub const MODAL_FG: Color = Color::Rgb(230, 230, 230);
|
||||
pub const MODAL_TITLE_FG: Color = Color::Rgb(255, 102, 102); // soft red for title text
|
||||
pub const MODAL_BORDER_FG: Color = Color::Rgb(204, 51, 51); // darker red border
|
||||
|
||||
pub const MODAL_ICON_PINK: Color = Color::Rgb(255, 182, 193); // light pink icons line
|
||||
pub const MODAL_AGENT_FG: Color = Color::Rgb(220, 220, 255); // pale periwinkle
|
||||
pub const MODAL_HINT_FG: Color = Color::Rgb(255, 215, 0); // gold for message icon
|
||||
pub const MODAL_OFFLINE_LABEL_FG: Color = Color::Rgb(135, 206, 235); // sky blue label
|
||||
pub const MODAL_RETRY_LABEL_FG: Color = Color::Rgb(255, 165, 0); // orange label
|
||||
pub const MODAL_COUNTDOWN_LABEL_FG: Color = Color::Rgb(255, 192, 203); // pink label for countdown
|
||||
|
||||
// Buttons
|
||||
pub const BTN_RETRY_BG_ACTIVE: Color = Color::Rgb(46, 204, 113); // modern green
|
||||
pub const BTN_RETRY_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
|
||||
pub const BTN_RETRY_FG_INACTIVE: Color = Color::Rgb(46, 204, 113);
|
||||
|
||||
pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red
|
||||
pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
|
||||
pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255);
|
||||
|
||||
// Process selection colors
|
||||
pub const PROCESS_SELECTION_BG: Color = Color::Rgb(147, 112, 219); // Medium slate blue (purple)
|
||||
pub const PROCESS_SELECTION_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast
|
||||
pub const PROCESS_TOOLTIP_BG: Color = Color::Rgb(147, 112, 219); // Same purple as selection
|
||||
pub const PROCESS_TOOLTIP_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast
|
||||
|
||||
// Process details modal colors (matches main UI aesthetic - no custom colors, terminal defaults)
|
||||
pub const PROCESS_DETAILS_ACCENT: Color = Color::Rgb(147, 112, 219); // Purple accent for highlights
|
||||
|
||||
// Emoji / icon strings (centralized so they can be themed/swapped later)
|
||||
pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR ";
|
||||
pub const ICON_CLUSTER: &str = "⚠️";
|
||||
pub const ICON_MESSAGE: &str = "💭 ";
|
||||
pub const ICON_OFFLINE_LABEL: &str = "⏱️ Offline for: ";
|
||||
pub const ICON_RETRY_LABEL: &str = "🔄 Retry attempts: ";
|
||||
pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
|
||||
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
|
||||
pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
|
||||
|
||||
// warning icon
|
||||
pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" /\\ ",
|
||||
" / \\ ",
|
||||
" / !! \\ ",
|
||||
" / !!!! \\ ",
|
||||
" / !! \\ ",
|
||||
" / !!!! \\ ",
|
||||
" / !! \\ ",
|
||||
" /______________\\ ",
|
||||
];
|
||||
|
||||
//about logo
|
||||
pub const ASCII_ART: &str = r#"
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀
|
||||
⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀
|
||||
⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀
|
||||
⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
|
||||
███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗
|
||||
██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
|
||||
███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝
|
||||
╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝
|
||||
███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║
|
||||
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝
|
||||
"#;
|
||||
@@ -3,31 +3,36 @@
|
||||
pub fn human(b: u64) -> String {
|
||||
const K: f64 = 1024.0;
|
||||
let b = b as f64;
|
||||
if b < K { return format!("{b:.0}B"); }
|
||||
if b < K {
|
||||
return format!("{b:.0}B");
|
||||
}
|
||||
let kb = b / K;
|
||||
if kb < K { return format!("{kb:.1}KB"); }
|
||||
if kb < K {
|
||||
return format!("{kb:.1}KB");
|
||||
}
|
||||
let mb = kb / K;
|
||||
if mb < K { return format!("{mb:.1}MB"); }
|
||||
if mb < K {
|
||||
return format!("{mb:.1}MB");
|
||||
}
|
||||
let gb = mb / K;
|
||||
if gb < K { return format!("{gb:.1}GB"); }
|
||||
if gb < K {
|
||||
return format!("{gb:.1}GB");
|
||||
}
|
||||
let tb = gb / K;
|
||||
format!("{tb:.2}TB")
|
||||
}
|
||||
|
||||
pub fn truncate_middle(s: &str, max: usize) -> String {
|
||||
if s.len() <= max { return s.to_string(); }
|
||||
if max <= 3 { return "...".into(); }
|
||||
let keep = max - 3;
|
||||
let left = keep / 2;
|
||||
let right = keep - left;
|
||||
format!("{}...{}", &s[..left], &s[s.len()-right..])
|
||||
}
|
||||
|
||||
pub fn disk_icon(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains(':') { "🗄️" }
|
||||
else if n.contains("nvme") { "⚡" }
|
||||
else if n.starts_with("sd") { "💽" }
|
||||
else if n.contains("overlay") { "📦" }
|
||||
else { "🖴" }
|
||||
}
|
||||
if n.contains(':') {
|
||||
"🗄️"
|
||||
} else if n.contains("nvme") {
|
||||
"⚡"
|
||||
} else if n.starts_with("sd") {
|
||||
"💽"
|
||||
} else if n.contains("overlay") {
|
||||
"📦"
|
||||
} else {
|
||||
"🖴"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! Minimal WebSocket client helpers for requesting metrics from the agent.
|
||||
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use crate::types::Metrics;
|
||||
|
||||
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::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export SinkExt/StreamExt for call sites
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 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:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_flag_documented_and_accepted() {
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let out = Command::new(exe)
|
||||
.args(["--compact", "--help"])
|
||||
.output()
|
||||
.expect("run socktop --compact --help");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"socktop --compact --help did not succeed"
|
||||
);
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.contains("--compact"),
|
||||
"help text missing --compact\n{text}"
|
||||
);
|
||||
|
||||
// The flag must not be mistaken for the positional URL argument.
|
||||
let out2 = Command::new(exe)
|
||||
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
||||
.output()
|
||||
.expect("run socktop --compact --dry-run");
|
||||
assert!(
|
||||
out2.status.success(),
|
||||
"socktop --compact with a URL was rejected: {}",
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Tests for modal formatting and duration helper.
|
||||
use std::time::Duration;
|
||||
|
||||
// Bring the format_duration function into scope by duplicating logic (private in module). If desired,
|
||||
// this could be moved to a shared util module; for now we re-assert expected behavior.
|
||||
fn format_duration_ref(duration: Duration) -> String {
|
||||
let total_secs = duration.as_secs();
|
||||
let hours = total_secs / 3600;
|
||||
let minutes = (total_secs % 3600) / 60;
|
||||
let seconds = total_secs % 60;
|
||||
if hours > 0 {
|
||||
format!("{hours}h {minutes}m {seconds}s")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}m {seconds}s")
|
||||
} else {
|
||||
format!("{seconds}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_duration_boundaries() {
|
||||
assert_eq!(format_duration_ref(Duration::from_secs(0)), "0s");
|
||||
assert_eq!(format_duration_ref(Duration::from_secs(59)), "59s");
|
||||
assert_eq!(format_duration_ref(Duration::from_secs(60)), "1m 0s");
|
||||
assert_eq!(format_duration_ref(Duration::from_secs(61)), "1m 1s");
|
||||
assert_eq!(format_duration_ref(Duration::from_secs(3600)), "1h 0m 0s");
|
||||
assert_eq!(format_duration_ref(Duration::from_secs(3661)), "1h 1m 1s");
|
||||
}
|
||||
|
||||
// Basic test to ensure auto-retry countdown semantics are consistent for initial state.
|
||||
#[test]
|
||||
fn test_auto_retry_initial_none() {
|
||||
// We can't construct App directly without pulling in whole UI; just assert logic mimic.
|
||||
// For a more thorough test, refactor countdown logic into a pure function.
|
||||
// This placeholder asserts desired initial semantics: when no disconnect/original time, countdown should be None.
|
||||
// (When integrated, consider exposing a pure helper returning Option<u64>.)
|
||||
let modal_active = false; // requirement: must be active for countdown
|
||||
let disconnected_state = true; // assume disconnected state
|
||||
let countdown = if disconnected_state && modal_active {
|
||||
// would compute target
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
assert!(countdown.is_none());
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 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 {
|
||||
sampled_at_ms: None,
|
||||
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,17 +1,71 @@
|
||||
[package]
|
||||
name = "socktop_agent"
|
||||
version = "0.1.0"
|
||||
version = "1.60.1"
|
||||
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"
|
||||
homepage = "https://github.com/jasonwitty/socktop"
|
||||
repository = "https://github.com/jasonwitty/socktop"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# Tokio: minimal features instead of "full" to reduce binary size.
|
||||
# rt-multi-thread (runtime), net (WebSocket), sync (Mutex/oneshot),
|
||||
# macros (#[tokio::test]), process (async journalctl).
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "process"] }
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
sysinfo = "0.36.1"
|
||||
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
futures = "0.3"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
futures-util = "0.3.31"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing = { version = "0.1", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
gfxinfo = { version = "0.1.2", optional = true }
|
||||
# Direct NVML fallback for distros that ship only libnvidia-ml.so.1 (Debian
|
||||
# and derivatives) — gfxinfo's default init dlopens the unversioned name.
|
||||
# Same version gfxinfo already pulls in, so this adds no new build cost.
|
||||
nvml-wrapper = { version = "0.10", optional = true }
|
||||
once_cell = "1.19"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
rustls-pemfile = "2.1"
|
||||
rcgen = "0.13"
|
||||
anyhow = "1"
|
||||
hostname = "0.3"
|
||||
prost = { workspace = true }
|
||||
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
|
||||
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
gpu = ["gfxinfo", "nvml-wrapper"]
|
||||
logging = ["tracing", "tracing-subscriber"]
|
||||
|
||||
[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"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
|
||||
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
|
||||
license-file = ["../LICENSE", "4"]
|
||||
extended-description = """\
|
||||
socktop_agent is the daemon component that runs on remote hosts to collect \
|
||||
and serve system metrics over WebSocket. It gathers CPU, memory, disk, network, \
|
||||
GPU, and process information that can be monitored remotely by the socktop TUI client."""
|
||||
depends = "$auto"
|
||||
section = "admin"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/socktop_agent", "usr/bin/", "755"],
|
||||
["../README.md", "usr/share/doc/socktop_agent/", "644"],
|
||||
]
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = { unit-name = "socktop-agent", unit-scripts = ".", enable = false }
|
||||
|
||||
@@ -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,16 @@
|
||||
fn main() {
|
||||
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());
|
||||
// Vendored protoc for reproducible builds where available. It ships no
|
||||
// riscv64 binary, so on such hosts fall through to $PROTOC / PATH
|
||||
// (prost-build's default lookup) — apt: protobuf-compiler.
|
||||
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
|
||||
cfg.protoc_executable(protoc);
|
||||
}
|
||||
// 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,57 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Create socktop user and group if they don't exist
|
||||
if ! getent group socktop >/dev/null; then
|
||||
addgroup --system socktop
|
||||
fi
|
||||
|
||||
if ! getent passwd socktop >/dev/null; then
|
||||
adduser --system --ingroup socktop --home /var/lib/socktop \
|
||||
--no-create-home --disabled-password --disabled-login \
|
||||
--gecos "Socktop Agent" socktop
|
||||
fi
|
||||
|
||||
# Create state directory
|
||||
mkdir -p /var/lib/socktop
|
||||
chown socktop:socktop /var/lib/socktop
|
||||
chmod 755 /var/lib/socktop
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
mkdir -p /etc/socktop
|
||||
chmod 755 /etc/socktop
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
# Print helpful message to the user
|
||||
cat <<EOF
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ socktop-agent has been installed successfully! │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ The systemd service has been installed but is NOT enabled by │
|
||||
│ default. To enable and start the service: │
|
||||
│ │
|
||||
│ sudo systemctl enable --now socktop-agent │
|
||||
│ │
|
||||
│ To start without enabling on boot: │
|
||||
│ │
|
||||
│ sudo systemctl start socktop-agent │
|
||||
│ │
|
||||
│ To check service status: │
|
||||
│ │
|
||||
│ sudo systemctl status socktop-agent │
|
||||
│ │
|
||||
│ Default settings: │
|
||||
│ - Port: 3000 (use -p or --port to change) │
|
||||
│ - SSL/TLS: disabled (use --enableSSL to enable) │
|
||||
│ │
|
||||
│ For more information, see: │
|
||||
│ /usr/share/doc/socktop_agent/README.md │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Remove user and group on purge
|
||||
if getent passwd socktop >/dev/null; then
|
||||
deluser --quiet socktop || true
|
||||
fi
|
||||
|
||||
if getent group socktop >/dev/null; then
|
||||
delgroup --quiet socktop || true
|
||||
fi
|
||||
|
||||
# Remove state directory on purge
|
||||
rm -rf /var/lib/socktop
|
||||
|
||||
# Remove config directory if empty
|
||||
rmdir --ignore-fail-on-non-empty /etc/socktop 2>/dev/null || true
|
||||
;;
|
||||
|
||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
# Do nothing on remove/upgrade
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
@@ -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,27 @@
|
||||
[Unit]
|
||||
Description=Socktop Agent - Remote System Monitor
|
||||
Documentation=https://github.com/jasonwitty/socktop
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/socktop_agent --port 3000
|
||||
Environment=RUST_LOG=info
|
||||
# Optional authentication token:
|
||||
# Environment=SOCKTOP_TOKEN=changeme
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=socktop
|
||||
Group=socktop
|
||||
NoNewPrivileges=true
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/socktop
|
||||
StateDirectory=socktop
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,125 @@
|
||||
// gpu.rs
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct GpuMetrics {
|
||||
pub name: String,
|
||||
pub utilization_gpu_pct: u32, // 0..100
|
||||
pub mem_used_bytes: u64,
|
||||
pub mem_total_bytes: u64,
|
||||
}
|
||||
|
||||
/// Collect metrics for the active GPU. `None` when there is no usable GPU.
|
||||
///
|
||||
/// Runs on a dedicated worker thread (see `worker`): gfxinfo's handle holds
|
||||
/// an `Rc<Nvml>` (not `Send`), and *creating* it runs a full NVML library
|
||||
/// init — ~20ms of blocking work that used to execute on the async runtime
|
||||
/// for every collection. The worker owns one handle for the process lifetime,
|
||||
/// so steady-state collection is just NVML queries. Measured on an RTX 5080
|
||||
/// box, re-initing per collect was ~80% of the agent's entire active CPU.
|
||||
#[cfg(feature = "gpu")]
|
||||
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
|
||||
worker::collect().await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gpu"))]
|
||||
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "gpu")]
|
||||
mod worker {
|
||||
use super::GpuMetrics;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::mpsc;
|
||||
|
||||
type Reply = tokio::sync::oneshot::Sender<Option<Vec<GpuMetrics>>>;
|
||||
static TX: OnceCell<mpsc::Sender<Reply>> = OnceCell::new();
|
||||
|
||||
pub async fn collect() -> Option<Vec<GpuMetrics>> {
|
||||
let tx = TX.get_or_init(spawn);
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(reply_tx).ok()?;
|
||||
reply_rx.await.ok().flatten()
|
||||
}
|
||||
|
||||
fn spawn() -> mpsc::Sender<Reply> {
|
||||
let (tx, rx) = mpsc::channel::<Reply>();
|
||||
std::thread::Builder::new()
|
||||
.name("socktop-gpu".into())
|
||||
.spawn(move || run(rx))
|
||||
.expect("spawn gpu worker thread");
|
||||
tx
|
||||
}
|
||||
|
||||
enum Handle {
|
||||
/// gfxinfo's own detection (AMD sysfs, NVIDIA via unversioned NVML).
|
||||
Gfx(Box<dyn gfxinfo::Gpu>),
|
||||
/// Direct NVML with an explicit versioned soname. Debian & friends
|
||||
/// ship only libnvidia-ml.so.1 (the unversioned symlink lives in the
|
||||
/// dev package), so gfxinfo's default dlopen fails there even though
|
||||
/// the driver is fully functional.
|
||||
Nvml(Box<nvml_wrapper::Nvml>),
|
||||
}
|
||||
|
||||
fn probe() -> Option<Handle> {
|
||||
if let Ok(g) = gfxinfo::active_gpu() {
|
||||
return Some(Handle::Gfx(g));
|
||||
}
|
||||
nvml_wrapper::Nvml::builder()
|
||||
.lib_path(std::ffi::OsStr::new("libnvidia-ml.so.1"))
|
||||
.init()
|
||||
.ok()
|
||||
.map(|nvml| Handle::Nvml(Box::new(nvml)))
|
||||
}
|
||||
|
||||
fn collect_from(handle: &Handle) -> Option<Vec<GpuMetrics>> {
|
||||
match handle {
|
||||
Handle::Gfx(gpu) => {
|
||||
let info = gpu.info();
|
||||
Some(vec![GpuMetrics {
|
||||
name: gpu.model().to_string(),
|
||||
utilization_gpu_pct: info.load_pct().clamp(0, 100),
|
||||
mem_used_bytes: info.used_vram(),
|
||||
mem_total_bytes: info.total_vram(),
|
||||
}])
|
||||
}
|
||||
Handle::Nvml(nvml) => {
|
||||
let device = nvml.device_by_index(0).ok()?;
|
||||
let mem = device.memory_info().ok()?;
|
||||
Some(vec![GpuMetrics {
|
||||
name: device.name().unwrap_or_else(|_| "NVIDIA GPU".into()),
|
||||
utilization_gpu_pct: device
|
||||
.utilization_rates()
|
||||
.map(|u| u.gpu.clamp(0, 100))
|
||||
.unwrap_or(0),
|
||||
mem_used_bytes: mem.used,
|
||||
mem_total_bytes: mem.total,
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(rx: mpsc::Receiver<Reply>) {
|
||||
let mut handle: Option<Handle> = None;
|
||||
// Probing failed: remember and answer None without re-initing the GPU
|
||||
// stack per request. The agent's negative cache stops asking anyway.
|
||||
let mut probe_failed = false;
|
||||
while let Ok(reply) = rx.recv() {
|
||||
if handle.is_none() && !probe_failed {
|
||||
handle = probe();
|
||||
probe_failed = handle.is_none();
|
||||
}
|
||||
let out = handle.as_ref().and_then(collect_from);
|
||||
// A live GPU cannot report 0 total VRAM; zeros mean the session
|
||||
// died (e.g. driver reload). Drop the handle so the next request
|
||||
// re-probes.
|
||||
if let Some(v) = &out
|
||||
&& !v.is_empty()
|
||||
&& v.iter().all(|g| g.mem_total_bytes == 0)
|
||||
{
|
||||
handle = None;
|
||||
}
|
||||
let _ = reply.send(out.filter(|v| !v.is_empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Library interface for socktop_agent functionality
|
||||
//! This allows testing of agent functions.
|
||||
|
||||
pub mod gpu;
|
||||
pub mod metrics;
|
||||
pub mod proto;
|
||||
pub mod state;
|
||||
pub mod tls;
|
||||
pub mod types;
|
||||
pub mod ws;
|
||||
|
||||
// Re-export commonly used types and functions for testing
|
||||
pub use metrics::{collect_journal_entries, collect_process_metrics};
|
||||
pub use state::{AppState, CacheEntry};
|
||||
pub use types::{
|
||||
DetailedProcessInfo, JournalEntry, JournalResponse, LogLevel, ProcessMetricsResponse,
|
||||
};
|
||||
@@ -1,136 +1,134 @@
|
||||
//! 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 ws;
|
||||
mod types;
|
||||
mod ws;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration, sync::atomic::AtomicUsize};
|
||||
use sysinfo::{
|
||||
Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind, RefreshKind,
|
||||
System,
|
||||
};
|
||||
use tokio::sync::{Mutex, RwLock, Notify};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use axum::{Router, http::StatusCode, routing::get};
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use state::{AppState, SharedTotals};
|
||||
use sampler::spawn_sampler;
|
||||
use ws::ws_handler;
|
||||
mod tls;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use state::AppState;
|
||||
|
||||
// Init logging; configure with RUST_LOG (e.g., RUST_LOG=info).
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
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
|
||||
}
|
||||
|
||||
// sysinfo build specifics (scopes what refresh_all() will touch internally)
|
||||
let refresh_kind = RefreshKind::nothing()
|
||||
.with_cpu(CpuRefreshKind::everything())
|
||||
.with_memory(MemoryRefreshKind::everything())
|
||||
.with_processes(ProcessRefreshKind::everything());
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Install rustls crypto provider before any TLS operations
|
||||
// This is required when using axum-server's tls-rustls feature
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.ok(); // Ignore error if already installed
|
||||
|
||||
// Initialize sysinfo handles once and keep them alive
|
||||
let mut sys = System::new_with_specifics(refresh_kind);
|
||||
sys.refresh_all();
|
||||
#[cfg(feature = "logging")]
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let mut nets = Networks::new();
|
||||
nets.refresh(true);
|
||||
// Configure Tokio runtime with optimized thread pool for reduced overhead.
|
||||
//
|
||||
// The agent is primarily I/O-bound (WebSocket, /proc file reads, sysinfo)
|
||||
// with no CPU-intensive or blocking operations, so a smaller thread pool
|
||||
// is beneficial:
|
||||
//
|
||||
// Benefits:
|
||||
// - Lower memory footprint (~1-2MB per thread saved)
|
||||
// - Reduced context switching overhead
|
||||
// - Fewer idle threads consuming resources
|
||||
// - Better for resource-constrained systems
|
||||
//
|
||||
// Trade-offs:
|
||||
// - Slightly reduced throughput under very high concurrent connections
|
||||
// - Could introduce latency if blocking operations are added (don't do this!)
|
||||
//
|
||||
// Default: 2 threads (sufficient for typical workloads with 1-10 clients)
|
||||
// Override: Set SOCKTOP_WORKER_THREADS=4 to use more threads if needed
|
||||
//
|
||||
// Note: Default Tokio uses num_cpus threads which is excessive for this workload.
|
||||
|
||||
let mut components = Components::new();
|
||||
components.refresh(true);
|
||||
let worker_threads = std::env::var("SOCKTOP_WORKER_THREADS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, 16); // Ensure 1-16 threads
|
||||
|
||||
let mut disks = Disks::new();
|
||||
disks.refresh(true);
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(worker_threads)
|
||||
.thread_name("socktop-agent")
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
// Shared state across requests
|
||||
let state = AppState {
|
||||
sys: Arc::new(Mutex::new(sys)),
|
||||
nets: Arc::new(Mutex::new(nets)),
|
||||
net_totals: Arc::new(Mutex::new(HashMap::<String, (u64, u64)>::new())) as SharedTotals,
|
||||
components: Arc::new(Mutex::new(components)),
|
||||
disks: Arc::new(Mutex::new(disks)),
|
||||
last_json: Arc::new(RwLock::new(String::new())),
|
||||
// new: adaptive sampling controls
|
||||
client_count: Arc::new(AtomicUsize::new(0)),
|
||||
wake_sampler: Arc::new(Notify::new()),
|
||||
auth_token: std::env::var("SOCKTOP_TOKEN").ok().filter(|s| !s.is_empty()),
|
||||
};
|
||||
runtime.block_on(async_main())
|
||||
}
|
||||
|
||||
// Start background sampler (adjust cadence as needed)
|
||||
let _sampler = spawn_sampler(state.clone(), Duration::from_millis(500));
|
||||
async fn async_main() -> anyhow::Result<()> {
|
||||
// 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(());
|
||||
}
|
||||
|
||||
// Web app
|
||||
let port = resolve_port();
|
||||
let app = Router::new().route("/ws", get(ws_handler)).with_state(state);
|
||||
let state = AppState::new();
|
||||
|
||||
// No background samplers: metrics collected on-demand per websocket request.
|
||||
|
||||
// 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 =
|
||||
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://{}/ws", addr);
|
||||
|
||||
//trace logging
|
||||
tracing::info!("Remote agent running at http://{} (ws at /ws)", addr);
|
||||
tracing::info!("WebSocket endpoint: ws://{}/ws", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
|
||||
}
|
||||
|
||||
// Resolve the listening port from CLI args/env with a 3000 default.
|
||||
// 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='{}'; using default {}", s, 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 '{}'; using default {}", v, DEFAULT);
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Missing value for {} ; using default {}", arg, DEFAULT);
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("Usage: socktop_agent [--port <PORT>] [PORT]\n SOCKTOP_PORT=<PORT> socktop_agent");
|
||||
std::process::exit(0);
|
||||
}
|
||||
s => {
|
||||
if let Ok(p) = s.parse::<u16>() {
|
||||
if p != 0 {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT
|
||||
println!("socktop_agent: Listening on ws://{addr}/ws");
|
||||
axum_server::bind(addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Unit tests for CLI parsing moved to `tests/port_parse.rs`.
|
||||
|
||||
@@ -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,36 +0,0 @@
|
||||
//! Background sampler: periodically collects metrics and updates a JSON cache,
|
||||
//! so WS replies are just a read of the cached string.
|
||||
|
||||
use crate::metrics::collect_metrics;
|
||||
use crate::state::AppState;
|
||||
//use serde_json::to_string;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Duration, interval, MissedTickBehavior};
|
||||
|
||||
pub fn spawn_sampler(state: AppState, period: Duration) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let idle_period = Duration::from_secs(10);
|
||||
loop {
|
||||
let active = state.client_count.load(std::sync::atomic::Ordering::Relaxed) > 0;
|
||||
let mut ticker = interval(if active { period } else { idle_period });
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
ticker.tick().await;
|
||||
|
||||
if !active {
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => {},
|
||||
_ = state.wake_sampler.notified() => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(json) = async {
|
||||
let m = collect_metrics(&state).await;
|
||||
serde_json::to_string(&m)
|
||||
}
|
||||
.await
|
||||
{
|
||||
*state.last_json.write().await = json;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,30 +1,193 @@
|
||||
//! Shared agent state: sysinfo handles and hot JSON cache.
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::collections::HashMap;
|
||||
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, RwLock, Notify};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub type SharedSystem = Arc<Mutex<System>>;
|
||||
pub type SharedNetworks = Arc<Mutex<Networks>>;
|
||||
pub type SharedTotals = Arc<Mutex<HashMap<String, (u64, u64)>>>;
|
||||
pub type SharedComponents = Arc<Mutex<Components>>;
|
||||
pub type 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>,
|
||||
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
|
||||
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
|
||||
/// the same `String`s on every processes poll (~once per 1.5s).
|
||||
pub names: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
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 {
|
||||
// Persistent sysinfo handles
|
||||
pub sys: SharedSystem,
|
||||
pub nets: SharedNetworks,
|
||||
pub net_totals: SharedTotals, // iface -> (rx_total, tx_total)
|
||||
pub components: SharedComponents,
|
||||
pub disks: SharedDisks,
|
||||
pub networks: SharedNetworks,
|
||||
pub hostname: String,
|
||||
|
||||
// Last serialized JSON snapshot for fast WS responses
|
||||
pub last_json: Arc<RwLock<String>>,
|
||||
// For correct per-process CPU% using /proc deltas (Linux only path uses this tracker)
|
||||
#[cfg(target_os = "linux")]
|
||||
pub proc_cpu: Arc<Mutex<ProcCpuTracker>>,
|
||||
|
||||
// Adaptive sampling controls
|
||||
// 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 wake_sampler: Arc<Notify>,
|
||||
|
||||
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>>>,
|
||||
|
||||
// Process detail caches (per-PID)
|
||||
pub cache_process_metrics:
|
||||
Arc<Mutex<HashMap<u32, CacheEntry<crate::types::ProcessMetricsResponse>>>>,
|
||||
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
|
||||
}
|
||||
|
||||
/// TTL-gated value behind a std Mutex, for `static` caches on hot paths.
|
||||
/// Replaces the hand-rolled TempCache/GpuCache/refresh-timestamp statics
|
||||
/// that each reimplemented the same at/value pair.
|
||||
pub struct TtlCell<T> {
|
||||
inner: std::sync::Mutex<CacheEntry<T>>,
|
||||
}
|
||||
|
||||
impl<T: Clone> Default for TtlCell<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> TtlCell<T> {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
inner: std::sync::Mutex::new(CacheEntry::new()),
|
||||
}
|
||||
}
|
||||
/// The stored value, only while fresh. Poisoned lock reads as a miss.
|
||||
pub fn get_fresh(&self, ttl: Duration) -> Option<T> {
|
||||
let g = self.inner.lock().ok()?;
|
||||
if g.is_fresh(ttl) {
|
||||
g.value.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn set(&self, v: T) {
|
||||
if let Ok(mut g) = self.inner.lock() {
|
||||
g.set(v);
|
||||
}
|
||||
}
|
||||
/// True exactly once per TTL window: restamps and tells the caller to do
|
||||
/// the refresh. Atomic check-and-stamp so concurrent callers don't both
|
||||
/// refresh.
|
||||
pub fn claim_stale(&self, ttl: Duration) -> bool {
|
||||
let Ok(mut g) = self.inner.lock() else {
|
||||
return false;
|
||||
};
|
||||
if g.at.is_none_or(|t| t.elapsed() >= ttl) {
|
||||
g.at = Some(Instant::now());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CacheEntry<T> {
|
||||
pub at: Option<Instant>,
|
||||
pub value: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> Default for CacheEntry<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
pub const 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 Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let sys = System::new();
|
||||
let components = Components::new_with_refreshed_list();
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
|
||||
Self {
|
||||
sys: Arc::new(Mutex::new(sys)),
|
||||
components: Arc::new(Mutex::new(components)),
|
||||
disks: Arc::new(Mutex::new(disks)),
|
||||
networks: Arc::new(Mutex::new(networks)),
|
||||
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())),
|
||||
cache_process_metrics: Arc::new(Mutex::new(HashMap::new())),
|
||||
cache_journal_entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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() {
|
||||
// Keys generated by agents older than 1.60 were written with the
|
||||
// default umask (typically 0644): tighten them on startup.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(&key_path)
|
||||
&& meta.permissions().mode() & 0o077 != 0
|
||||
{
|
||||
let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
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())?;
|
||||
// The private key must not be world-readable (File::create honors the
|
||||
// umask, which typically yields 0644).
|
||||
let mut key_opts = fs::OpenOptions::new();
|
||||
key_opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
key_opts.mode(0o600);
|
||||
}
|
||||
let mut k = key_opts.open(&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))
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
//! Data types sent to the client over WebSocket.
|
||||
//! Keep this module minimal and stable — it defines the wire format.
|
||||
|
||||
use crate::gpu::GpuMetrics;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
pub temperature: Option<f32>,
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NetworkInfo {
|
||||
pub name: String,
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
@@ -11,33 +28,107 @@ pub struct ProcessInfo {
|
||||
pub mem_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct NetworkInfo {
|
||||
pub name: String,
|
||||
// cumulative totals since the agent started (client should diff to get rates)
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Metrics {
|
||||
/// Epoch ms when this snapshot was actually collected. The agent serves
|
||||
/// TTL-cached snapshots, so the client needs the AGENT's sample time to
|
||||
/// compute rates — measuring against client receive time turned cache
|
||||
/// hits into a 0-then-2x sawtooth in the network graphs.
|
||||
pub sampled_at_ms: u64,
|
||||
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 process_count: usize,
|
||||
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<GpuMetrics>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessesPayload {
|
||||
pub process_count: usize,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub tid: u32, // Thread ID
|
||||
pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm)
|
||||
pub cpu_time_user: u64, // User CPU time in microseconds
|
||||
pub cpu_time_system: u64, // System CPU time in microseconds
|
||||
pub status: String, // Thread status (Running, Sleeping, etc.)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DetailedProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
pub virtual_mem_bytes: u64,
|
||||
pub shared_mem_bytes: Option<u64>,
|
||||
pub thread_count: u32,
|
||||
pub fd_count: Option<u32>,
|
||||
pub status: String,
|
||||
pub parent_pid: Option<u32>,
|
||||
pub user_id: u32,
|
||||
pub group_id: u32,
|
||||
pub start_time: u64, // Unix timestamp
|
||||
pub cpu_time_user: u64, // Microseconds
|
||||
pub cpu_time_system: u64, // Microseconds
|
||||
pub read_bytes: Option<u64>,
|
||||
pub write_bytes: Option<u64>,
|
||||
pub working_directory: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub child_processes: Vec<DetailedProcessInfo>,
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessMetricsResponse {
|
||||
pub process: DetailedProcessInfo,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalEntry {
|
||||
pub timestamp: String, // RFC 3339 UTC, for display
|
||||
pub timestamp_us: u64, // epoch microseconds, for sorting/formatting
|
||||
pub priority: LogLevel,
|
||||
pub message: String,
|
||||
pub unit: Option<String>, // systemd unit name
|
||||
pub pid: Option<u32>,
|
||||
pub comm: Option<String>, // process command name
|
||||
pub uid: Option<u32>,
|
||||
pub gid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum LogLevel {
|
||||
Emergency = 0,
|
||||
Alert = 1,
|
||||
Critical = 2,
|
||||
Error = 3,
|
||||
Warning = 4,
|
||||
Notice = 5,
|
||||
Info = 6,
|
||||
Debug = 7,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalResponse {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
pub total_count: u32,
|
||||
pub truncated: bool,
|
||||
/// journalctl's own explanation when the result is empty because of
|
||||
/// journal ACCESS (not absence of logs) — e.g. a user-run agent asking
|
||||
/// about a system service. None when entries exist or nothing to say.
|
||||
pub notice: Option<String>,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
@@ -1,66 +1,312 @@
|
||||
//! WebSocket upgrade and per-connection handler. Serves cached JSON quickly.
|
||||
//! WebSocket upgrade and per-connection handler (request-driven).
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
Query, State,
|
||||
},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
extract::ws::{Message, WebSocket},
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
response::Response,
|
||||
};
|
||||
use futures_util::stream::StreamExt;
|
||||
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_metrics;
|
||||
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all};
|
||||
use crate::proto::pb;
|
||||
use crate::state::AppState;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
// Payloads at or below this many bytes are sent as-is; larger ones are gzipped.
|
||||
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 {
|
||||
if let Some(expected) = state.auth_token.as_ref() {
|
||||
match q.get("token") {
|
||||
Some(t) if t == expected => {}
|
||||
_ => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
}
|
||||
// optional auth
|
||||
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))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
// Bump client count on connect and wake the sampler.
|
||||
state.client_count.fetch_add(1, Ordering::Relaxed);
|
||||
state.wake_sampler.notify_waiters();
|
||||
/// Per-PID cache limits: entries older than MAX_AGE are swept on every
|
||||
/// insert and the map is capped at MAX_ENTRIES (oldest evicted first), so a
|
||||
/// client walking PIDs cannot grow agent memory without bound.
|
||||
const PER_PID_CACHE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
const PER_PID_CACHE_MAX_ENTRIES: usize = 64;
|
||||
|
||||
// Ensure we decrement on disconnect (drop).
|
||||
struct ClientGuard(AppState);
|
||||
impl Drop for ClientGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.client_count.fetch_sub(1, Ordering::Relaxed);
|
||||
self.0.wake_sampler.notify_waiters();
|
||||
/// Serve a per-PID request from a TTL cache, collecting on miss. One home
|
||||
/// for the logic that get_process_metrics and get_journal_entries used to
|
||||
/// duplicate ~50 lines apiece.
|
||||
async fn respond_per_pid_cached<T, Fut>(
|
||||
socket: &mut WebSocket,
|
||||
cache: &Mutex<HashMap<u32, crate::state::CacheEntry<T>>>,
|
||||
pid: u32,
|
||||
ttl: std::time::Duration,
|
||||
request_name: &str,
|
||||
collect: impl FnOnce() -> Fut,
|
||||
) where
|
||||
T: serde::Serialize + Clone,
|
||||
Fut: std::future::Future<Output = Result<T, String>>,
|
||||
{
|
||||
{
|
||||
let cache = cache.lock().await;
|
||||
if let Some(entry) = cache.get(&pid)
|
||||
&& entry.is_fresh(ttl)
|
||||
&& let Some(v) = entry.get()
|
||||
{
|
||||
let _ = send_json(socket, v).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _guard = ClientGuard(state.clone());
|
||||
match collect().await {
|
||||
Ok(resp) => {
|
||||
{
|
||||
let mut cache = cache.lock().await;
|
||||
cache.retain(|_, e| e.at.is_some_and(|t| t.elapsed() < PER_PID_CACHE_MAX_AGE));
|
||||
while cache.len() >= PER_PID_CACHE_MAX_ENTRIES {
|
||||
let oldest = cache.iter().min_by_key(|(_, e)| e.at).map(|(k, _)| *k);
|
||||
match oldest {
|
||||
Some(k) => cache.remove(&k),
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(crate::state::CacheEntry::new)
|
||||
.set(resp.clone());
|
||||
}
|
||||
let _ = send_json(socket, &resp).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error_response = serde_json::json!({
|
||||
"error": err,
|
||||
"request": request_name,
|
||||
"pid": pid
|
||||
});
|
||||
let _ = send_json(socket, &error_response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
state
|
||||
.client_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
while let Some(Ok(msg)) = socket.next().await {
|
||||
match msg {
|
||||
Message::Text(text) if text == "get_metrics" => {
|
||||
// Serve the cached JSON quickly; if empty (cold start), collect once.
|
||||
let cached = state.last_json.read().await.clone();
|
||||
if !cached.is_empty() {
|
||||
let _ = socket.send(Message::Text(cached)).await;
|
||||
Message::Text(ref text) if text == "get_metrics" => {
|
||||
let m = collect_fast_metrics(&state).await;
|
||||
let _ = send_json(&mut socket, &m).await;
|
||||
}
|
||||
Message::Text(ref text) if text == "get_disks" => {
|
||||
let d = collect_disks(&state).await;
|
||||
let _ = send_json(&mut socket, &d).await;
|
||||
}
|
||||
Message::Text(ref text) if text == "get_processes" => {
|
||||
let payload = collect_processes_all(&state).await;
|
||||
|
||||
// Get cached buffers. The Vec capacity is preserved across
|
||||
// calls (with_capacity(512) seeds it, then we swap-back after
|
||||
// encode so the allocation outlives any single request).
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Move the populated Vec into the proto, encode, then move it
|
||||
// BACK into the cache so the next call reuses the same heap
|
||||
// allocation. The previous code did `mem::take(...)` here but
|
||||
// then dropped `pb` (and the Vec along with it), leaving the
|
||||
// cache holding an empty zero-capacity Vec — defeating the
|
||||
// whole point of `with_capacity(512)`.
|
||||
let mut pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows: std::mem::take(&mut cache.processes_vec),
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
let encode_result = prost::Message::encode(&pb, &mut buf);
|
||||
// Restore the (now-encoded-from) Vec to the cache before pb is
|
||||
// dropped. We `take` it out of pb to leave that field empty,
|
||||
// and the next request will `.clear()` before refilling.
|
||||
cache.processes_vec = std::mem::take(&mut pb.rows);
|
||||
|
||||
if encode_result.is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
let metrics = collect_metrics(&state).await;
|
||||
if let Ok(js) = serde_json::to_string(&metrics) {
|
||||
let _ = socket.send(Message::Text(js)).await;
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cache); // Explicit drop to release mutex early
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_process_metrics:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_process_metrics:")
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
{
|
||||
respond_per_pid_cached(
|
||||
&mut socket,
|
||||
&state.cache_process_metrics,
|
||||
pid,
|
||||
std::time::Duration::from_millis(250),
|
||||
"get_process_metrics",
|
||||
|| crate::metrics::collect_process_metrics(pid, &state),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_journal_entries:")
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
{
|
||||
respond_per_pid_cached(
|
||||
&mut socket,
|
||||
&state.cache_journal_entries,
|
||||
pid,
|
||||
std::time::Duration::from_secs(1),
|
||||
"get_journal_entries",
|
||||
|| crate::metrics::collect_journal_entries(pid),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
.client_count
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Small, cheap gzip for larger payloads; send text for small.
|
||||
async fn send_json<T: serde::Serialize>(ws: &mut WebSocket, value: &T) -> Result<(), axum::Error> {
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
if json.len() <= COMPRESSION_THRESHOLD {
|
||||
return ws.send(Message::Text(json)).await;
|
||||
}
|
||||
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
|
||||
enc.write_all(json.as_bytes()).ok();
|
||||
let bin = enc.finish().unwrap_or_else(|_| json.into_bytes());
|
||||
ws.send(Message::Binary(bin)).await
|
||||
}
|
||||
|
||||
#[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,133 @@
|
||||
//! Tests for the process cache functionality
|
||||
|
||||
use socktop_agent::state::{AppState, CacheEntry};
|
||||
use socktop_agent::types::{DetailedProcessInfo, JournalResponse, ProcessMetricsResponse};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_cache_ttl() {
|
||||
let state = AppState::new();
|
||||
let pid = 12345;
|
||||
|
||||
// Create mock data
|
||||
let process_info = DetailedProcessInfo {
|
||||
pid,
|
||||
name: "test_process".to_string(),
|
||||
command: "test command".to_string(),
|
||||
cpu_usage: 50.0,
|
||||
mem_bytes: 1024 * 1024,
|
||||
virtual_mem_bytes: 2048 * 1024,
|
||||
shared_mem_bytes: Some(512 * 1024),
|
||||
thread_count: 4,
|
||||
fd_count: Some(10),
|
||||
status: "Running".to_string(),
|
||||
parent_pid: Some(1),
|
||||
user_id: 1000,
|
||||
group_id: 1000,
|
||||
start_time: 1234567890,
|
||||
cpu_time_user: 100000,
|
||||
cpu_time_system: 50000,
|
||||
read_bytes: Some(1024),
|
||||
write_bytes: Some(2048),
|
||||
working_directory: Some("/tmp".to_string()),
|
||||
executable_path: Some("/usr/bin/test".to_string()),
|
||||
child_processes: vec![],
|
||||
threads: vec![],
|
||||
};
|
||||
|
||||
let metrics_response = ProcessMetricsResponse {
|
||||
process: process_info,
|
||||
cached_at: 1234567890,
|
||||
};
|
||||
|
||||
let journal_response = JournalResponse {
|
||||
notice: None,
|
||||
entries: vec![],
|
||||
total_count: 0,
|
||||
truncated: false,
|
||||
cached_at: 1234567890,
|
||||
};
|
||||
|
||||
// Test process metrics caching
|
||||
{
|
||||
let mut cache = state.cache_process_metrics.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(CacheEntry::new)
|
||||
.set(metrics_response.clone());
|
||||
}
|
||||
|
||||
// Should get cached value immediately
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
let ttl = Duration::from_millis(250);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(entry.is_fresh(ttl));
|
||||
assert!(entry.get().is_some());
|
||||
assert_eq!(entry.get().unwrap().process.pid, pid);
|
||||
} else {
|
||||
panic!("Expected cached entry");
|
||||
}
|
||||
}
|
||||
println!("✓ Process metrics cached and retrieved successfully");
|
||||
|
||||
// Test journal entries caching
|
||||
{
|
||||
let mut cache = state.cache_journal_entries.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(CacheEntry::new)
|
||||
.set(journal_response.clone());
|
||||
}
|
||||
|
||||
// Should get cached value immediately
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
let ttl = Duration::from_secs(1);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(entry.is_fresh(ttl));
|
||||
assert!(entry.get().is_some());
|
||||
assert_eq!(entry.get().unwrap().total_count, 0);
|
||||
} else {
|
||||
panic!("Expected cached entry");
|
||||
}
|
||||
}
|
||||
println!("✓ Journal entries cached and retrieved successfully");
|
||||
|
||||
// Wait for process metrics to expire (250ms + buffer)
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
|
||||
// Process metrics should be expired now
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
let ttl = Duration::from_millis(250);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(!entry.is_fresh(ttl));
|
||||
}
|
||||
}
|
||||
println!("✓ Process metrics correctly expired after TTL");
|
||||
|
||||
// Journal entries should still be valid (1s TTL)
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
let ttl = Duration::from_secs(1);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(entry.is_fresh(ttl));
|
||||
}
|
||||
}
|
||||
println!("✓ Journal entries still valid within TTL");
|
||||
|
||||
// Wait for journal entries to expire (additional 800ms to reach 1s total)
|
||||
sleep(Duration::from_millis(800)).await;
|
||||
|
||||
// Journal entries should be expired now
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
let ttl = Duration::from_secs(1);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(!entry.is_fresh(ttl));
|
||||
}
|
||||
}
|
||||
println!("✓ Journal entries correctly expired after TTL");
|
||||
}
|
||||
@@ -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,105 @@
|
||||
//! Tests for process detail collection functionality
|
||||
|
||||
use socktop_agent::metrics::{collect_journal_entries, collect_process_metrics};
|
||||
use socktop_agent::state::AppState;
|
||||
use std::process;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_process_metrics_self() {
|
||||
// Test collecting metrics for our own process
|
||||
let pid = process::id();
|
||||
let state = AppState::new();
|
||||
|
||||
match collect_process_metrics(pid, &state).await {
|
||||
Ok(response) => {
|
||||
assert_eq!(response.process.pid, pid);
|
||||
assert!(!response.process.name.is_empty());
|
||||
// Command might be empty on some systems, so don't assert on it
|
||||
assert!(response.cached_at > 0);
|
||||
println!(
|
||||
"✓ Process metrics collected for PID {}: {} ({})",
|
||||
pid, response.process.name, response.process.command
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// This might fail if sysinfo can't find the process, which is possible
|
||||
println!("⚠ Warning: Failed to collect process metrics for self: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_journal_entries_self() {
|
||||
// Test collecting journal entries for our own process
|
||||
let pid = process::id();
|
||||
|
||||
match collect_journal_entries(pid).await {
|
||||
Ok(response) => {
|
||||
assert!(response.cached_at > 0);
|
||||
println!(
|
||||
"✓ Journal entries collected for PID {}: {} entries",
|
||||
pid, response.total_count
|
||||
);
|
||||
if !response.entries.is_empty() {
|
||||
let entry = &response.entries[0];
|
||||
println!(" Latest entry: {}", entry.message);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// This might fail if journalctl is not available or restricted
|
||||
println!("⚠ Warning: Failed to collect journal entries for self: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_process_metrics_invalid_pid() {
|
||||
// Test with an invalid PID
|
||||
let invalid_pid = 999999;
|
||||
let state = AppState::new();
|
||||
|
||||
match collect_process_metrics(invalid_pid, &state).await {
|
||||
Ok(_) => {
|
||||
println!("⚠ Warning: Unexpectedly found process for invalid PID {invalid_pid}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✓ Correctly failed for invalid PID {invalid_pid}: {e}");
|
||||
assert!(e.contains("not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_journal_entries_invalid_pid() {
|
||||
// Test with an invalid PID - journalctl might still return empty results
|
||||
let invalid_pid = 999999;
|
||||
|
||||
match collect_journal_entries(invalid_pid).await {
|
||||
Ok(response) => {
|
||||
println!(
|
||||
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
|
||||
invalid_pid, response.total_count
|
||||
);
|
||||
// Should be empty or very few entries
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✓ Journal query failed for invalid PID {invalid_pid}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Command & Details pane went blank when the minimal-refresh
|
||||
/// optimization dropped cmd from the detail endpoint's refresh kind.
|
||||
#[tokio::test]
|
||||
async fn test_process_metrics_include_command() {
|
||||
let state = AppState::new();
|
||||
let pid = std::process::id();
|
||||
let resp = collect_process_metrics(pid, &state)
|
||||
.await
|
||||
.expect("collect self");
|
||||
assert!(
|
||||
!resp.process.command.is_empty(),
|
||||
"command should not be empty for self (cmdline is always readable)"
|
||||
);
|
||||
println!("command = {}", resp.process.command);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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::new(assert_cmd::cargo::cargo_bin!("socktop_agent"));
|
||||
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
|
||||
cmd.env("XDG_CONFIG_HOME", &xdg)
|
||||
.arg("--enableSSL")
|
||||
.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 = "1.60.1"
|
||||
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,14 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Vendored protoc for reproducible builds where available. It ships no
|
||||
// riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to
|
||||
// prost-build (apt: protobuf-compiler).
|
||||
// SAFETY: We're only setting PROTOC in a build script environment.
|
||||
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
|
||||
unsafe {
|
||||
std::env::set_var("PROTOC", protoc);
|
||||
}
|
||||
}
|
||||
|
||||
prost_build::compile_protos(&["processes.proto"], &["."])?;
|
||||
Ok(())
|
||||
}
|
||||