Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f82a5903b8 |
@@ -1,446 +0,0 @@
|
|||||||
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
-12
@@ -1,17 +1,7 @@
|
|||||||
# Any crate's build directory, including standalone sub-crates
|
/target
|
||||||
# (zellij_socktop_plugin, socktop_wasm_test) that live outside the workspace.
|
|
||||||
target/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
/.cargo/
|
/socktop-wasm-test/target
|
||||||
|
|
||||||
# Documentation files from development sessions (context-specific, not for public repo)
|
# Documentation files from development sessions (context-specific, not for public repo)
|
||||||
/OPTIMIZATION_PROCESS_DETAILS.md
|
/OPTIMIZATION_PROCESS_DETAILS.md
|
||||||
/THREAD_SUPPORT.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
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### TUI
|
|
||||||
|
|
||||||
- **`--no-kill` flag and `SOCKTOP_NO_KILL` env var** disable the local
|
|
||||||
process-kill feature regardless of agent locality, for shared terminals and
|
|
||||||
public demos (e.g. the socktop.io webterm). Either one forces the feature
|
|
||||||
off and suppresses the `t` kill hints; the env var covers every socktop
|
|
||||||
invocation under a deployment without touching command lines. `App`'s
|
|
||||||
builder renamed `with_local` → `with_kill_enabled` to match what it now
|
|
||||||
means (locality fact AND policy).
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
Generated
+302
-896
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -24,9 +24,8 @@ serde_json = "1.0"
|
|||||||
sysinfo = "0.37"
|
sysinfo = "0.37"
|
||||||
|
|
||||||
# CLI UI
|
# CLI UI
|
||||||
ratatui = "0.30"
|
ratatui = "0.28"
|
||||||
crossterm = "0.29"
|
crossterm = "0.27"
|
||||||
unicode-width = "0.2"
|
|
||||||
|
|
||||||
# web server (remote-agent)
|
# web server (remote-agent)
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Witty One Off
|
Copyright (c) 2025 Witty One Off
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Process Details Race Condition Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The `collect_process_metrics()` function was calling:
|
||||||
|
```rust
|
||||||
|
system.refresh_processes_specifics(ProcessesToUpdate::All, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
This caused several issues:
|
||||||
|
1. **Race Condition**: Refreshing ALL processes invalidated CPU baselines for main metrics collection
|
||||||
|
2. **Thread Pollution**: Main process list included threads (not desired in main UI)
|
||||||
|
3. **CPU Waste**: Refreshing ~500-1000+ processes when we only need 1
|
||||||
|
4. **Memory Waste**: Storing thread data unnecessarily
|
||||||
|
|
||||||
|
## Solution: Lightweight Child Process Enumeration
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
|
||||||
|
#### 1. Targeted Process Refresh
|
||||||
|
```rust
|
||||||
|
// OLD: Refreshed ALL processes (expensive, causes race condition)
|
||||||
|
system.refresh_processes_specifics(ProcessesToUpdate::All, ...)
|
||||||
|
|
||||||
|
// NEW: Only refresh the specific process we care about
|
||||||
|
system.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]),
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Direct /proc Access for Children (Linux)
|
||||||
|
Instead of iterating through all sysinfo processes, we now:
|
||||||
|
- Scan `/proc/` directory directly
|
||||||
|
- Read `/proc/{pid}/stat` to check parent PID
|
||||||
|
- Extract process details from `/proc/{pid}/` files
|
||||||
|
- Fall back to sysinfo for non-Linux platforms
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
|
||||||
|
| Metric | Before | After | Improvement |
|
||||||
|
|--------|--------|-------|-------------|
|
||||||
|
| CPU per request | ~15-20ms | ~1-3ms | **~85% reduction** |
|
||||||
|
| Processes refreshed | All (~500-1000+) | 1 | **99.9% reduction** |
|
||||||
|
| Memory overhead | All processes + threads | Single process | **~95% reduction** |
|
||||||
|
| Race condition risk | High | None | **100% eliminated** |
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
#### Linux Implementation
|
||||||
|
**`enumerate_child_processes_lightweight()`**
|
||||||
|
- Scans `/proc/` directory for child processes
|
||||||
|
- Uses `read_parent_pid_from_proc()` to filter by parent
|
||||||
|
- Calls `collect_process_info_from_proc()` to extract details
|
||||||
|
- Reads from:
|
||||||
|
- `/proc/{pid}/stat` - Process state, parent PID, start time
|
||||||
|
- `/proc/{pid}/status` - UID, GID, threads, memory, state
|
||||||
|
- `/proc/{pid}/cmdline` - Command line
|
||||||
|
- `/proc/{pid}/io` - I/O statistics (if available)
|
||||||
|
- `/proc/{pid}/cwd` - Working directory (symlink)
|
||||||
|
- `/proc/{pid}/exe` - Executable path (symlink)
|
||||||
|
|
||||||
|
#### Non-Linux Fallback
|
||||||
|
- Uses sysinfo's process iteration (less efficient but functional)
|
||||||
|
- Maintains cross-platform compatibility
|
||||||
|
- Same API, just different implementation
|
||||||
|
|
||||||
|
### Testing Instructions
|
||||||
|
|
||||||
|
1. **Start the agent:**
|
||||||
|
```bash
|
||||||
|
cargo run --bin socktop_agent --release -- --port 8123
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Connect with the client:**
|
||||||
|
```bash
|
||||||
|
cargo run --bin socktop --release -- ws://localhost:8123/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test process details:**
|
||||||
|
- Navigate to a process with the arrow keys
|
||||||
|
- Press Enter to open process details modal
|
||||||
|
- Verify child processes are shown correctly
|
||||||
|
- Check that the main UI still shows only top-level processes (no threads)
|
||||||
|
|
||||||
|
4. **Verify no race condition:**
|
||||||
|
- Open process details modal
|
||||||
|
- Watch main UI CPU percentages
|
||||||
|
- They should remain stable and accurate
|
||||||
|
- No sudden spikes or drops in CPU percentages
|
||||||
|
|
||||||
|
### Code Locations
|
||||||
|
|
||||||
|
- **Main fix:** `socktop_agent/src/metrics.rs`
|
||||||
|
- `collect_process_metrics()` - Modified to use targeted refresh
|
||||||
|
- `enumerate_child_processes_lightweight()` - New function for Linux
|
||||||
|
- `read_parent_pid_from_proc()` - Helper to read parent PID
|
||||||
|
- `collect_process_info_from_proc()` - Helper to read process details
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **Lightweight**: Minimal CPU and memory usage
|
||||||
|
2. **No Race Conditions**: Doesn't interfere with main metrics collection
|
||||||
|
3. **Clean Separation**: Main UI never sees threads
|
||||||
|
4. **Cross-Platform**: Works on Linux (optimized) and other platforms (fallback)
|
||||||
|
5. **Maintainable**: Clear, well-documented code
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
Potential optimizations if needed:
|
||||||
|
- Cache `/proc` file descriptors for frequently accessed processes
|
||||||
|
- Batch read multiple `/proc` files in parallel
|
||||||
|
- Add support for thread enumeration (currently not needed)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
✅ Compiles without errors
|
||||||
|
✅ No race conditions
|
||||||
|
✅ Child processes correctly enumerated
|
||||||
|
✅ Main UI remains clean (no threads)
|
||||||
|
✅ Significantly reduced CPU usage
|
||||||
|
✅ Cross-platform compatible
|
||||||
@@ -1,36 +1,510 @@
|
|||||||
# socktop
|
# socktop
|
||||||
|
|
||||||
_socktop_ is a remote system monitor with a rich TUI, talking to an ultra lightweight agent over WebSockets.
|
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
|
||||||
|
|
||||||
<img src="./docs/socktop_demo_1_60.apng" width="100%">
|
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||||
|
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||||
|
|
||||||
## Resources
|
<img src="./docs/socktop_demo.apng" width="100%">
|
||||||
|
|
||||||
| Resource | Location |
|
|
||||||
| -------- | -------- |
|
|
||||||
| Website and online demo (yes it's real) | [socktop.io](https://www.socktop.io) |
|
|
||||||
| Quick Start guide | [https://socktop.io/assets/docs/installation/quick-start.html](https://socktop.io/assets/docs/installation/quick-start.html) |
|
|
||||||
| Prereqs | [https://socktop.io/assets/docs/installation/prerequisites.html](https://socktop.io/assets/docs/installation/prerequisites.html) |
|
|
||||||
| APT Install | [https://socktop.io/assets/docs/installation/apt.html](https://socktop.io/assets/docs/installation/apt.html) |
|
|
||||||
| Cargo Install | [https://socktop.io/assets/docs/installation/cargo.html](https://socktop.io/assets/docs/installation/cargo.html)
|
|
||||||
| Usage | [https://socktop.io/assets/docs/usage/general.html](https://socktop.io/assets/docs/usage/general.html)
|
|
||||||
| Auth Setup | [https://socktop.io/assets/docs/security/token.html](https://socktop.io/assets/docs/security/token.html) |
|
|
||||||
| TLS Setup | [https://socktop.io/assets/docs/security/tls.html](https://socktop.io/assets/docs/security/tls.html) |
|
|
||||||
| Monitoring Multiple Hosts | [tmux](https://socktop.io/assets/docs/advanced/tmux.html) / [zellij](https://socktop.io/assets/docs/advanced/zellij.html) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Platform Support
|
## Features
|
||||||
|
|
||||||
Linux (all flavors), ARM/Raspberry Pi (32b/64b), MacOS, Windows, RISC-V (experimental)
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Prerequisites: Install Rust (rustup)
|
||||||
|
|
||||||
Contributions are welcome and you have the freedom to use whatever development tools you would like, as long as there is a human in the loop and all the clippy and unit tests pass you are good to submit a PR. Defects / Bugs just go ahead and fix and file a PR. New features, please create a issue in advance and let me know you are offering to build it. I don't want to be in a position where you worked for a couple of weeks on something and I don't want to merge it.
|
Rust is fast, safe, and cross‑platform. Installing it will make your machine better. Consider yourself privileged.
|
||||||
|
|
||||||
### Development
|
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)
|
||||||
|
|
||||||
|
Install GPU support with apt command below
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install libdrm-dev libdrm-amdgpu1
|
||||||
|
```
|
||||||
|
|
||||||
|
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two components:
|
||||||
|
|
||||||
|
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
|
||||||
|
|
||||||
|
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
- Build both binaries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/jasonwitty/socktop.git
|
||||||
|
cd socktop
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
- Start the agent on the target machine (default port 3000):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/socktop_agent --port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install (from crates.io)
|
||||||
|
|
||||||
|
You don’t need to clone this repo to use socktop. Install the published binaries with cargo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TUI (client)
|
||||||
|
cargo install socktop
|
||||||
|
# Agent (server)
|
||||||
|
cargo install socktop_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If you installed with cargo, binaries are in ~/.cargo/bin
|
||||||
|
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Enable SSL
|
||||||
|
|
||||||
|
# Stop service
|
||||||
|
sudo systemctl stop socktop-agent
|
||||||
|
|
||||||
|
# Edit service to append SSL option and port
|
||||||
|
sudo micro /etc/systemd/system/socktop-agent.service
|
||||||
|
|
||||||
|
--
|
||||||
|
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
|
||||||
|
--
|
||||||
|
|
||||||
|
# Reload
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
sudo systemctl start socktop-agent
|
||||||
|
|
||||||
|
# check logs for certificate location
|
||||||
|
sudo journalctl -u socktop-agent -f
|
||||||
|
|
||||||
|
--
|
||||||
|
Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
|
||||||
|
--
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Agent (server):
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connection Profiles (Named)
|
||||||
|
|
||||||
|
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||||
|
|
||||||
|
Config file location:
|
||||||
|
|
||||||
|
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
|
||||||
|
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
|
||||||
|
|
||||||
|
### Creating a profile
|
||||||
|
|
||||||
|
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod ws://prod-host:3000/ws
|
||||||
|
# With TLS pinning:
|
||||||
|
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
|
||||||
|
|
||||||
|
You can also set custom intervals (milliseconds):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
If a profile already exists you will be prompted before overwriting:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ socktop --profile prod ws://new-host:3000/ws
|
||||||
|
Overwrite existing profile 'prod'? [y/N]: y
|
||||||
|
```
|
||||||
|
|
||||||
|
To overwrite without an interactive prompt pass `--save`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod --save ws://new-host:3000/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using a saved profile
|
||||||
|
|
||||||
|
Just pass the profile name (no URL needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --profile prod
|
||||||
|
socktop -P prod-tls # short flag
|
||||||
|
```
|
||||||
|
|
||||||
|
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
|
||||||
|
|
||||||
|
### Interactive selection (no args)
|
||||||
|
|
||||||
|
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ socktop
|
||||||
|
Select profile:
|
||||||
|
1. prod
|
||||||
|
2. prod-tls
|
||||||
|
Enter number (or blank to abort): 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
|
||||||
|
|
||||||
|
### JSON format
|
||||||
|
|
||||||
|
An example `profiles.json` (pretty‑printed):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"prod": { "url": "ws://prod-host:3000/ws" },
|
||||||
|
"prod-tls": {
|
||||||
|
"url": "wss://prod-host:8443/ws",
|
||||||
|
"tls_ca": "/home/user/certs/prod-cert.pem",
|
||||||
|
"metrics_interval_ms": 500,
|
||||||
|
"processes_interval_ms": 2000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
|
||||||
|
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
|
||||||
|
- Profiles are client-side convenience only; they do not affect the agent.
|
||||||
|
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
Update the agent (systemd):
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cpu_total": 12.4,
|
||||||
|
"cpu_per_core": [11.2, 15.7],
|
||||||
|
"mem_total": 33554432,
|
||||||
|
"mem_used": 18321408,
|
||||||
|
"swap_total": 0,
|
||||||
|
"swap_used": 0,
|
||||||
|
"process_count": 127,
|
||||||
|
"hostname": "myserver",
|
||||||
|
"cpu_temp_c": 42.5,
|
||||||
|
"disks": [{"name":"nvme0n1p2","total":512000000000,"available":320000000000}],
|
||||||
|
"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 (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking.
|
||||||
|
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using tmux to monitor multiple hosts
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt
|
cargo fmt
|
||||||
@@ -45,11 +519,35 @@ cargo run -p socktop_agent -- --enableSSL --port 8443
|
|||||||
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
|
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
|
||||||
Enable it (one-time):
|
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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
- [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
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see LICENSE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Thread Support Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Added per-thread CPU metrics collection and visualization to the process details modal. Threads and child processes are now clearly distinguished in both the scatter plot and the table view.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Data Structures
|
||||||
|
|
||||||
|
#### `socktop_connector/src/types.rs` & `socktop_agent/src/types.rs`
|
||||||
|
- **New:** `ThreadInfo` struct
|
||||||
|
- `tid: u32` - Thread ID
|
||||||
|
- `name: String` - Thread name from `/proc/{pid}/task/{tid}/comm`
|
||||||
|
- `cpu_time_user: u64` - User CPU time in microseconds
|
||||||
|
- `cpu_time_system: u64` - System CPU time in microseconds
|
||||||
|
- `status: String` - Thread status (Running, Sleeping, etc.)
|
||||||
|
|
||||||
|
- **Updated:** `DetailedProcessInfo` struct
|
||||||
|
- Added `threads: Vec<ThreadInfo>` field
|
||||||
|
|
||||||
|
### 2. Agent - Thread Collection
|
||||||
|
|
||||||
|
#### `socktop_agent/src/metrics.rs`
|
||||||
|
|
||||||
|
**New Function: `collect_thread_info(pid: u32)` (Linux only)**
|
||||||
|
- Reads `/proc/{pid}/task/` directory to enumerate all threads
|
||||||
|
- For each thread:
|
||||||
|
- Reads thread name from `/proc/{pid}/task/{tid}/comm`
|
||||||
|
- Parses `/proc/{pid}/task/{tid}/stat` for CPU times and status
|
||||||
|
- Converts clock ticks (100 Hz) to microseconds: `ticks * 10,000`
|
||||||
|
- Extracts utime (field 13) and stime (field 14) from stat file
|
||||||
|
|
||||||
|
**Updated: `collect_process_metrics()`**
|
||||||
|
- Calls `collect_thread_info(pid)` to collect thread data
|
||||||
|
- Includes threads in the `DetailedProcessInfo` response
|
||||||
|
|
||||||
|
**Updated: `collect_process_info_from_proc()`**
|
||||||
|
- Added `threads: Vec::new()` to child process info (not collected recursively)
|
||||||
|
|
||||||
|
**Updated: `enumerate_child_processes_lightweight()` (non-Linux)**
|
||||||
|
- Added `threads: Vec::new()` for cross-platform compatibility
|
||||||
|
|
||||||
|
### 3. Client - UI Visualization
|
||||||
|
|
||||||
|
#### `socktop/src/ui/modal.rs`
|
||||||
|
|
||||||
|
**Updated: `render_cpu_scatter_plot()`**
|
||||||
|
- Title changed to "Thread & Process CPU Time Distribution"
|
||||||
|
- Includes threads in max value scaling calculation
|
||||||
|
- Plots threads with hollow circle marker `○`
|
||||||
|
- Plots child processes with filled circle marker `•`
|
||||||
|
- Uses different markers for overlapping items:
|
||||||
|
- `○` - Single thread
|
||||||
|
- `◎` - Multiple threads at same point
|
||||||
|
- `•` - Single child process
|
||||||
|
- `◉` - Multiple items (threads/processes) at same point
|
||||||
|
|
||||||
|
**Updated: Legend**
|
||||||
|
- Now shows: `● Main Process ○ Thread • Child Process ◉ Multiple`
|
||||||
|
|
||||||
|
**Updated: `render_thread_table()`**
|
||||||
|
- Title now shows counts: `"Threads (N) & Children (M)"`
|
||||||
|
- Table format:
|
||||||
|
```
|
||||||
|
Type TID/PID Name/Status
|
||||||
|
─────────────────────────────
|
||||||
|
[T] 12345 thread-name
|
||||||
|
[P] 12346 child-process
|
||||||
|
```
|
||||||
|
- `[T]` prefix in cyan for threads
|
||||||
|
- `[P]` prefix in green for child processes
|
||||||
|
- Displays up to 10 items total
|
||||||
|
- Threads listed first, then child processes
|
||||||
|
|
||||||
|
## Platform Support
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
- **Full support** for per-thread metrics
|
||||||
|
- Reads directly from `/proc/{pid}/task/*/` for efficiency
|
||||||
|
- No additional dependencies required
|
||||||
|
|
||||||
|
### Non-Linux
|
||||||
|
- Returns empty thread list
|
||||||
|
- Falls back gracefully
|
||||||
|
- Child process enumeration still works via sysinfo
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Thread enumeration**: ~0.5-2ms for typical processes
|
||||||
|
- **No additional locks**: Thread data collected outside sysinfo mutex
|
||||||
|
- **Minimal overhead**: Only collected when process details modal is open
|
||||||
|
- **No race conditions**: Doesn't interfere with main metrics collection
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
Perfect for visualizing:
|
||||||
|
- Multi-threaded applications (web servers, databases, compilers)
|
||||||
|
- Thread pool behavior
|
||||||
|
- Worker thread distribution
|
||||||
|
- Identifying busy vs idle threads
|
||||||
|
- Comparing thread CPU usage patterns
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
For a process with 8 threads and 2 child processes:
|
||||||
|
|
||||||
|
**Scatter Plot:**
|
||||||
|
- Main process shown as `●`
|
||||||
|
- 8 threads shown as `○` distributed based on their CPU times
|
||||||
|
- 2 child processes shown as `•`
|
||||||
|
- X-axis: User CPU time
|
||||||
|
- Y-axis: System CPU time
|
||||||
|
|
||||||
|
**Table:**
|
||||||
|
```
|
||||||
|
Threads (8) & Children (2)
|
||||||
|
Type TID/PID Name/Status
|
||||||
|
─────────────────────────────
|
||||||
|
[T] 12345 web-worker-1
|
||||||
|
[T] 12346 web-worker-2
|
||||||
|
[T] 12347 io-handler
|
||||||
|
...
|
||||||
|
[P] 12355 nginx: cache
|
||||||
|
[P] 12356 nginx: worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Test with multi-threaded applications:
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start agent
|
||||||
|
cargo run --release --bin socktop_agent -- --port 3000
|
||||||
|
|
||||||
|
# Terminal 2: Start client
|
||||||
|
cargo run --release --bin socktop -- ws://localhost:3000/ws
|
||||||
|
|
||||||
|
# Navigate to a multi-threaded process (e.g., Firefox, Chrome, Node.js)
|
||||||
|
# Press Enter to open process details
|
||||||
|
# Scatter plot will show thread distribution
|
||||||
|
# Table will show threads marked with [T] and children with [P]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
- Per-thread memory usage (requires parsing `/proc/{pid}/task/{tid}/statm`)
|
||||||
|
- Thread-level I/O statistics
|
||||||
|
- Thread CPU percentage (requires delta calculation with caching)
|
||||||
|
- Sorting threads by CPU time in the table
|
||||||
|
- Thread state filtering (show only running/sleeping threads)
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
-----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-----
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
-----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-----
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-----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-----
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
|||||||
Archive: stable
|
|
||||||
Component: main
|
|
||||||
Origin: socktop
|
|
||||||
Label: socktop
|
|
||||||
Architecture: amd64
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,320 @@
|
|||||||
|
# Auto-Generated Man Pages
|
||||||
|
|
||||||
|
This document explains how man pages are automatically generated from the CLI definitions using `clap` and `clap_mangen`.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Starting from version 1.50.0+, socktop uses **clap** for CLI parsing and **clap_mangen** to automatically generate man pages at build time. This approach has several advantages:
|
||||||
|
|
||||||
|
✅ Man pages are always in sync with the actual CLI
|
||||||
|
✅ Single source of truth (CLI definitions)
|
||||||
|
✅ No manual maintenance of separate man page files
|
||||||
|
✅ Generated during `cargo build` automatically
|
||||||
|
✅ Can be installed alongside binaries
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. CLI Definitions
|
||||||
|
|
||||||
|
Both `socktop` and `socktop_agent` use clap's derive macros to define their CLI:
|
||||||
|
|
||||||
|
- **`socktop/src/cli.rs`** - Client CLI definition
|
||||||
|
- **`socktop_agent/src/cli.rs`** - Agent CLI definition
|
||||||
|
|
||||||
|
These files use clap's attributes to specify:
|
||||||
|
- Arguments and options
|
||||||
|
- Help text and descriptions
|
||||||
|
- Value names and types
|
||||||
|
- Environment variable support
|
||||||
|
- Hidden options (for testing)
|
||||||
|
|
||||||
|
### 2. Build-Time Generation
|
||||||
|
|
||||||
|
Each crate has a `build.rs` script that:
|
||||||
|
1. Includes the CLI definition file
|
||||||
|
2. Uses `clap_mangen` to generate the man page
|
||||||
|
3. Saves it to `$OUT_DIR/man/*.1`
|
||||||
|
|
||||||
|
The generation happens automatically during:
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
cargo build --release
|
||||||
|
cargo install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generated Man Pages Location
|
||||||
|
|
||||||
|
After building, man pages are located at:
|
||||||
|
```
|
||||||
|
target/debug/build/socktop-*/out/man/socktop.1
|
||||||
|
target/debug/build/socktop_agent-*/out/man/socktop_agent.1
|
||||||
|
|
||||||
|
# Or for release builds:
|
||||||
|
target/release/build/socktop-*/out/man/socktop.1
|
||||||
|
target/release/build/socktop_agent-*/out/man/socktop_agent.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation Options
|
||||||
|
|
||||||
|
### Option 1: Use the Installation Script (Recommended)
|
||||||
|
|
||||||
|
The `scripts/install-with-man.sh` script builds the binaries, extracts the generated man pages, and installs everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User installation (no sudo)
|
||||||
|
./scripts/install-with-man.sh
|
||||||
|
|
||||||
|
# System-wide installation (requires sudo)
|
||||||
|
sudo ./scripts/install-with-man.sh --system
|
||||||
|
|
||||||
|
# Only install man pages (after building)
|
||||||
|
./scripts/install-with-man.sh --man-only
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
- Builds the project in release mode
|
||||||
|
- Extracts generated man pages from `OUT_DIR`
|
||||||
|
- Installs binaries to `~/.cargo/bin` or `/usr/local/bin`
|
||||||
|
- Installs man pages to `~/.local/share/man/man1` or `/usr/local/share/man/man1`
|
||||||
|
|
||||||
|
### Option 2: Manual Installation After Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the project
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Find generated man pages
|
||||||
|
SOCKTOP_MAN=$(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
|
||||||
|
AGENT_MAN=$(find target/release/build/socktop_agent-*/out/man/socktop_agent.1 | head -1)
|
||||||
|
|
||||||
|
# Install to user directory
|
||||||
|
mkdir -p ~/.local/share/man/man1
|
||||||
|
cp "$SOCKTOP_MAN" ~/.local/share/man/man1/
|
||||||
|
cp "$AGENT_MAN" ~/.local/share/man/man1/
|
||||||
|
|
||||||
|
# Or install system-wide
|
||||||
|
sudo mkdir -p /usr/local/share/man/man1
|
||||||
|
sudo cp "$SOCKTOP_MAN" /usr/local/share/man/man1/
|
||||||
|
sudo cp "$AGENT_MAN" /usr/local/share/man/man1/
|
||||||
|
sudo mandb # Update man database
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: View Without Installing
|
||||||
|
|
||||||
|
You can view the generated man pages directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After building
|
||||||
|
man -l $(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
|
||||||
|
man -l $(find target/release/build/socktop_agent-*/out/man/socktop_agent.1 | head -1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Viewing Installed Man Pages
|
||||||
|
|
||||||
|
After installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
man socktop
|
||||||
|
man socktop_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
If `man socktop` doesn't work after user installation, add to your shell rc:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For bash
|
||||||
|
echo 'export MANPATH="$HOME/.local/share/man:$MANPATH"' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
# For zsh
|
||||||
|
echo 'export MANPATH="$HOME/.local/share/man:$MANPATH"' >> ~/.zshrc
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating CLI and Man Pages
|
||||||
|
|
||||||
|
When you need to update the CLI or man pages:
|
||||||
|
|
||||||
|
1. **Edit the CLI definition** in `src/cli.rs`:
|
||||||
|
```rust
|
||||||
|
/// Your new option description
|
||||||
|
#[arg(short = 'x', long = "example")]
|
||||||
|
pub example: bool,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rebuild** to regenerate man pages:
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reinstall** man pages:
|
||||||
|
```bash
|
||||||
|
./scripts/install-with-man.sh --man-only
|
||||||
|
```
|
||||||
|
|
||||||
|
The man pages will automatically reflect your changes!
|
||||||
|
|
||||||
|
## CLI Definition Format
|
||||||
|
|
||||||
|
### Basic Structure
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "myapp",
|
||||||
|
version,
|
||||||
|
author,
|
||||||
|
about = "Short description",
|
||||||
|
long_about = "Longer description that appears in man page and --help"
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Short description of this option
|
||||||
|
///
|
||||||
|
/// Longer description that appears in the man page.
|
||||||
|
/// Can span multiple lines.
|
||||||
|
#[arg(short = 't', long = "thing", value_name = "VALUE")]
|
||||||
|
pub thing: Option<String>,
|
||||||
|
|
||||||
|
/// Boolean flag
|
||||||
|
#[arg(long)]
|
||||||
|
pub flag: bool,
|
||||||
|
|
||||||
|
/// Hidden option (won't appear in man page or --help)
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
pub secret: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variable Support
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Port to listen on
|
||||||
|
///
|
||||||
|
/// Can also be set via MYAPP_PORT environment variable.
|
||||||
|
#[arg(short = 'p', long = "port", env = "MYAPP_PORT")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Value Parsing
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Custom parser
|
||||||
|
#[arg(long, value_parser = parse_custom)]
|
||||||
|
pub custom: Option<String>,
|
||||||
|
|
||||||
|
fn parse_custom(s: &str) -> Result<String, String> {
|
||||||
|
// Custom validation logic
|
||||||
|
Ok(s.to_string())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advantages Over Manual Man Pages
|
||||||
|
|
||||||
|
| Feature | Auto-Generated | Manual |
|
||||||
|
|---------|---------------|--------|
|
||||||
|
| Always in sync with CLI | ✅ Yes | ❌ Manual updates required |
|
||||||
|
| Single source of truth | ✅ Yes | ❌ Duplicated info |
|
||||||
|
| Maintenance effort | ✅ Low | ❌ High |
|
||||||
|
| Consistency | ✅ Guaranteed | ❌ Can drift |
|
||||||
|
| Generated at build time | ✅ Yes | ❌ Separate process |
|
||||||
|
| Works with `--help` | ✅ Same source | ❌ Separate |
|
||||||
|
| Rich formatting | ⚠️ Good | ✅ Full control |
|
||||||
|
|
||||||
|
## Comparison with Manual Man Pages
|
||||||
|
|
||||||
|
The project also includes manually written man pages in `docs/man/` for comparison and as templates. These are more detailed and include additional sections like:
|
||||||
|
|
||||||
|
- EXAMPLES with complex scenarios
|
||||||
|
- SECURITY CONSIDERATIONS
|
||||||
|
- PLATFORM NOTES
|
||||||
|
- Systemd integration guides
|
||||||
|
- Troubleshooting tips
|
||||||
|
|
||||||
|
The auto-generated man pages from clap are excellent for:
|
||||||
|
- Options and arguments
|
||||||
|
- Basic descriptions
|
||||||
|
- Version and author info
|
||||||
|
- Environment variables
|
||||||
|
|
||||||
|
But may be limited for:
|
||||||
|
- Complex examples
|
||||||
|
- Extensive narrative documentation
|
||||||
|
- Custom formatting
|
||||||
|
- Additional reference sections
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Write good doc comments** in `cli.rs` - they become man page content
|
||||||
|
2. **Use `long_about`** for detailed descriptions
|
||||||
|
3. **Specify `value_name`** for clarity (e.g., `<PORT>`, `<URL>`)
|
||||||
|
4. **Document environment variables** in the option description
|
||||||
|
5. **Use `hide = true`** for internal/test options
|
||||||
|
6. **Keep descriptions concise** but informative
|
||||||
|
7. **Rebuild after CLI changes** to update man pages
|
||||||
|
|
||||||
|
## Testing Man Page Generation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean build to ensure regeneration
|
||||||
|
cargo clean
|
||||||
|
|
||||||
|
# Build and check for man page warning
|
||||||
|
cargo build --release 2>&1 | grep "Man page generated"
|
||||||
|
|
||||||
|
# View the generated man page
|
||||||
|
man -l $(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
lexgrog $(find target/release/build/socktop-*/out/man/socktop.1 | head -1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Man page not generated
|
||||||
|
|
||||||
|
**Solution:** Check that `build.rs` ran successfully:
|
||||||
|
```bash
|
||||||
|
cargo clean
|
||||||
|
cargo build -vv 2>&1 | grep build.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can't find generated man page
|
||||||
|
|
||||||
|
**Solution:** Look in the correct build output:
|
||||||
|
```bash
|
||||||
|
find target -name "socktop.1" -type f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Man page content is outdated
|
||||||
|
|
||||||
|
**Solution:** Clean and rebuild:
|
||||||
|
```bash
|
||||||
|
cargo clean
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### MANPATH not working
|
||||||
|
|
||||||
|
**Solution:** Verify the path is correct:
|
||||||
|
```bash
|
||||||
|
echo $MANPATH
|
||||||
|
man -w # Show current man paths
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
|
||||||
|
- [ ] Add more detailed examples section using clap's `after_help`
|
||||||
|
- [ ] Generate shell completions alongside man pages
|
||||||
|
- [ ] Create a custom man page template with additional sections
|
||||||
|
- [ ] Package man pages in release artifacts
|
||||||
|
- [ ] Auto-install man pages during `cargo install`
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [clap documentation](https://docs.rs/clap/)
|
||||||
|
- [clap_mangen documentation](https://docs.rs/clap_mangen/)
|
||||||
|
- [Manual man pages](man/README.md) - The original manually written versions
|
||||||
|
- [Quick Reference](QUICK_REFERENCE.md) - Command cheat sheet
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# Migration to Clap for Auto-Generated Man Pages
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Socktop has been migrated from manual argument parsing to **clap** (Command Line Argument Parser) with automatic man page generation via **clap_mangen**. This provides several benefits:
|
||||||
|
|
||||||
|
✅ **Auto-generated man pages** - Always in sync with CLI
|
||||||
|
✅ **Better help output** - Rich, formatted `--help` text
|
||||||
|
✅ **Type safety** - Compile-time checking of arguments
|
||||||
|
✅ **Environment variable support** - Built-in env var integration
|
||||||
|
✅ **Shell completions** - Easy to add bash/zsh/fish completions
|
||||||
|
✅ **Single source of truth** - CLI definitions generate everything
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
### Before (Manual Parsing)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Old approach: Manual argument parsing
|
||||||
|
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;
|
||||||
|
// ... lots of manual parsing code ...
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"-h" | "--help" => {
|
||||||
|
return Err(format!("Usage: {prog} ..."));
|
||||||
|
}
|
||||||
|
"--tls-ca" | "-t" => {
|
||||||
|
tls_ca = it.next();
|
||||||
|
}
|
||||||
|
// ... more matches ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ParsedArgs { url, tls_ca, ... })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problems:**
|
||||||
|
- Manual parsing is error-prone
|
||||||
|
- Help text gets out of sync
|
||||||
|
- No automatic man page generation
|
||||||
|
- Duplicated logic for `--flag` and `--flag=value`
|
||||||
|
- No environment variable support
|
||||||
|
- Hard to test
|
||||||
|
|
||||||
|
### After (Clap Derive)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// New approach: Clap derive macros
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "socktop",
|
||||||
|
version,
|
||||||
|
author,
|
||||||
|
about = "Remote system monitor with a rich TUI over WebSocket",
|
||||||
|
long_about = "socktop is a remote system monitor..."
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// WebSocket URL to connect to
|
||||||
|
#[arg(value_name = "URL")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
|
||||||
|
/// Path to TLS certificate PEM file for WSS connections
|
||||||
|
#[arg(short = 't', long = "tls-ca", value_name = "CERT_PEM")]
|
||||||
|
pub tls_ca: Option<String>,
|
||||||
|
|
||||||
|
// ... more fields ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
let cli = Cli::parse();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Declarative and concise
|
||||||
|
- Auto-generated help and man pages
|
||||||
|
- Type-safe argument parsing
|
||||||
|
- Automatic support for `--flag` and `--flag=value`
|
||||||
|
- Built-in env var support with `env` attribute
|
||||||
|
- Easy to test
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Core Changes
|
||||||
|
|
||||||
|
1. **`socktop/Cargo.toml`** - Added clap dependencies
|
||||||
|
2. **`socktop/src/cli.rs`** - NEW: CLI definition using clap derive
|
||||||
|
3. **`socktop/src/main.rs`** - Updated to use `Cli::parse_args()`
|
||||||
|
4. **`socktop/build.rs`** - NEW: Auto-generates man pages at build time
|
||||||
|
|
||||||
|
5. **`socktop_agent/Cargo.toml`** - Added clap dependencies
|
||||||
|
6. **`socktop_agent/src/cli.rs`** - NEW: CLI definition using clap derive
|
||||||
|
7. **`socktop_agent/src/main.rs`** - Updated to use `Cli::parse_args()`
|
||||||
|
8. **`socktop_agent/build.rs`** - Updated to auto-generate man pages
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
9. **`docs/AUTO_MAN_PAGES.md`** - Comprehensive guide to auto-generated man pages
|
||||||
|
10. **`docs/CLAP_MIGRATION.md`** - This file
|
||||||
|
11. **`README.md`** - Updated Man Pages section
|
||||||
|
12. **`scripts/install-with-man.sh`** - NEW: Installation script that includes man pages
|
||||||
|
|
||||||
|
## Man Page Generation
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Build Time** - When you run `cargo build`, the `build.rs` script:
|
||||||
|
- Includes the CLI definition from `src/cli.rs`
|
||||||
|
- Creates a clap `Command` instance
|
||||||
|
- Uses `clap_mangen` to generate a man page
|
||||||
|
- Saves it to `$OUT_DIR/man/*.1`
|
||||||
|
|
||||||
|
2. **Location** - Generated man pages are at:
|
||||||
|
```
|
||||||
|
target/release/build/socktop-*/out/man/socktop.1
|
||||||
|
target/release/build/socktop_agent-*/out/man/socktop_agent.1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Installation** - Use the installation script:
|
||||||
|
```bash
|
||||||
|
./scripts/install-with-man.sh # User install
|
||||||
|
sudo ./scripts/install-with-man.sh --system # System install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Viewing** - After installation:
|
||||||
|
```bash
|
||||||
|
man socktop
|
||||||
|
man socktop_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Man Page Content
|
||||||
|
|
||||||
|
The man pages include:
|
||||||
|
- **NAME** - From `about` attribute
|
||||||
|
- **SYNOPSIS** - Auto-generated from arguments
|
||||||
|
- **DESCRIPTION** - From `long_about` attribute
|
||||||
|
- **OPTIONS** - From field doc comments and `#[arg(...)]` attributes
|
||||||
|
- **VERSION** - From Cargo.toml
|
||||||
|
- **AUTHORS** - From Cargo.toml
|
||||||
|
|
||||||
|
## CLI Definition Format
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "myapp",
|
||||||
|
version, // Uses Cargo.toml version
|
||||||
|
author, // Uses Cargo.toml authors
|
||||||
|
about = "Short description",
|
||||||
|
long_about = "Longer description for man page and --help"
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Short description
|
||||||
|
///
|
||||||
|
/// Longer description that appears in the man page.
|
||||||
|
/// Multiple paragraphs supported.
|
||||||
|
#[arg(short = 't', long = "thing", value_name = "VALUE")]
|
||||||
|
pub thing: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Attributes
|
||||||
|
|
||||||
|
| Attribute | Purpose | Example |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| `short = 'x'` | Short flag | `-x` |
|
||||||
|
| `long = "example"` | Long flag | `--example` |
|
||||||
|
| `value_name = "FOO"` | Display name | `--thing <FOO>` |
|
||||||
|
| `env = "VAR"` | Environment variable | `env = "MY_VAR"` |
|
||||||
|
| `default_value = "x"` | Default value | Default: "x" |
|
||||||
|
| `hide = true` | Hide from help/man | For internal options |
|
||||||
|
| `value_parser = func` | Custom parser | Validation |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Port to listen on
|
||||||
|
///
|
||||||
|
/// Can be set via SOCKTOP_PORT environment variable.
|
||||||
|
#[arg(short = 'p', long = "port", env = "SOCKTOP_PORT")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically:
|
||||||
|
- Checks the environment variable
|
||||||
|
- Shows `[env: SOCKTOP_PORT=]` in help
|
||||||
|
- Documents it in the man page
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Both CLI modules include comprehensive unit tests:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_basic_parsing() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop", "ws://localhost:8080/ws"]).unwrap();
|
||||||
|
assert_eq!(cli.url, Some("ws://localhost:8080/ws".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tls_options() {
|
||||||
|
let cli = Cli::try_parse_from(&[
|
||||||
|
"socktop",
|
||||||
|
"-t", "/path/to/cert.pem",
|
||||||
|
"--verify-hostname",
|
||||||
|
"wss://example.com:8443/ws",
|
||||||
|
]).unwrap();
|
||||||
|
assert_eq!(cli.tls_ca, Some("/path/to/cert.pem".to_string()));
|
||||||
|
assert!(cli.verify_hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with:
|
||||||
|
```bash
|
||||||
|
cargo test --package socktop cli::
|
||||||
|
cargo test --package socktop_agent cli::
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
Test the help output:
|
||||||
|
```bash
|
||||||
|
cargo run --package socktop -- --help
|
||||||
|
cargo run --package socktop_agent -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Test argument parsing:
|
||||||
|
```bash
|
||||||
|
cargo run --package socktop -- ws://localhost:8080/ws
|
||||||
|
cargo run --package socktop -- -t cert.pem wss://localhost:8443/ws
|
||||||
|
cargo run --package socktop_agent -- --port 8080
|
||||||
|
cargo run --package socktop_agent -- --enableSSL
|
||||||
|
```
|
||||||
|
|
||||||
|
Test environment variables:
|
||||||
|
```bash
|
||||||
|
SOCKTOP_PORT=9000 cargo run --package socktop_agent
|
||||||
|
SOCKTOP_ENABLE_SSL=1 cargo run --package socktop_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
|
||||||
|
### Command-Line Interface
|
||||||
|
|
||||||
|
✅ **Fully compatible** - All existing command-line arguments work exactly the same:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Still works
|
||||||
|
socktop -t cert.pem wss://host:8443/ws
|
||||||
|
socktop --profile myprofile
|
||||||
|
socktop --demo
|
||||||
|
|
||||||
|
socktop_agent --port 8080
|
||||||
|
socktop_agent --enableSSL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
✅ **Fully compatible** - All environment variables still work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SOCKTOP_PORT=8080 socktop_agent
|
||||||
|
SOCKTOP_ENABLE_SSL=1 socktop_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
❌ **None** - This is a drop-in replacement for the old parser.
|
||||||
|
|
||||||
|
## Comparison: Manual vs Clap
|
||||||
|
|
||||||
|
| Feature | Manual Parsing | Clap |
|
||||||
|
|---------|---------------|------|
|
||||||
|
| Code lines | ~120 lines | ~50 lines |
|
||||||
|
| Man pages | Separate files | Auto-generated |
|
||||||
|
| Help text | Hardcoded strings | Auto-generated |
|
||||||
|
| Type safety | Runtime errors | Compile-time |
|
||||||
|
| Env vars | Manual `std::env::var` | Built-in `env` attribute |
|
||||||
|
| Testing | Hard to test | Easy with `try_parse_from` |
|
||||||
|
| Maintenance | High | Low |
|
||||||
|
| Consistency | Can drift | Always in sync |
|
||||||
|
| Completions | Manual | Auto-generate |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Now that we're using clap, we can easily add:
|
||||||
|
|
||||||
|
### Shell Completions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In build.rs
|
||||||
|
use clap_complete::{generate_to, shells::*};
|
||||||
|
|
||||||
|
let cmd = Cli::command();
|
||||||
|
generate_to(Bash, &mut cmd, "socktop", &out_dir)?;
|
||||||
|
generate_to(Zsh, &mut cmd, "socktop", &out_dir)?;
|
||||||
|
generate_to(Fish, &mut cmd, "socktop", &out_dir)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Parser)]
|
||||||
|
enum Commands {
|
||||||
|
/// Connect to an agent
|
||||||
|
Connect {
|
||||||
|
#[arg(value_name = "URL")]
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
/// List saved profiles
|
||||||
|
Profiles,
|
||||||
|
/// Run demo mode
|
||||||
|
Demo,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Better Validation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[arg(value_parser = clap::value_parser!(u16).range(1..=65535))]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Help Sections
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[command(
|
||||||
|
after_help = "EXAMPLES:\n socktop ws://localhost:8080/ws\n socktop --demo"
|
||||||
|
)]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
If migrating other Rust projects to clap:
|
||||||
|
|
||||||
|
- [ ] Add clap and clap_mangen dependencies
|
||||||
|
- [ ] Create `src/cli.rs` with derive macros
|
||||||
|
- [ ] Update `main.rs` to use `Cli::parse()`
|
||||||
|
- [ ] Create/update `build.rs` for man page generation
|
||||||
|
- [ ] Write unit tests for CLI parsing
|
||||||
|
- [ ] Test all existing command-line arguments
|
||||||
|
- [ ] Test environment variables
|
||||||
|
- [ ] Update documentation
|
||||||
|
- [ ] Create installation scripts for man pages
|
||||||
|
- [ ] Consider adding shell completions
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Clap Documentation](https://docs.rs/clap/)
|
||||||
|
- [Clap Derive Tutorial](https://docs.rs/clap/latest/clap/_derive/index.html)
|
||||||
|
- [Clap Mangen](https://docs.rs/clap_mangen/)
|
||||||
|
- [Auto-Generated Man Pages Guide](AUTO_MAN_PAGES.md)
|
||||||
|
- [Manual Man Pages](man/README.md)
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The migration to clap provides:
|
||||||
|
- Better developer experience
|
||||||
|
- Auto-generated, always-in-sync documentation
|
||||||
|
- Reduced maintenance burden
|
||||||
|
- Professional-quality help output and man pages
|
||||||
|
- Foundation for future enhancements (completions, subcommands)
|
||||||
|
|
||||||
|
All with **zero breaking changes** to the existing CLI.
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# 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)
|
|
||||||
+10
-13
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
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.
|
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
|
## Cross-Compilation Host Setup
|
||||||
|
|
||||||
Choose your host operating system:
|
Choose your host operating system:
|
||||||
@@ -25,9 +23,8 @@ sudo apt update
|
|||||||
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
|
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
|
||||||
|
|
||||||
# For 32-bit Raspberry Pi (armv7)
|
# For 32-bit Raspberry Pi (armv7)
|
||||||
# Note: GPU support not available on armv7
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross libdrm-dev:armhf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup Rust Cross-Compilation Targets
|
### Setup Rust Cross-Compilation Targets
|
||||||
@@ -68,8 +65,9 @@ sudo pacman -S aarch64-linux-gnu-gcc
|
|||||||
yay -S aarch64-linux-gnu-libdrm
|
yay -S aarch64-linux-gnu-libdrm
|
||||||
|
|
||||||
# For 32-bit Raspberry Pi (armv7)
|
# For 32-bit Raspberry Pi (armv7)
|
||||||
# Note: GPU support not available on armv7
|
|
||||||
sudo pacman -S arm-linux-gnueabihf-gcc
|
sudo pacman -S arm-linux-gnueabihf-gcc
|
||||||
|
# Install libdrm for armv7 using an AUR helper
|
||||||
|
yay -S arm-linux-gnueabihf-libdrm
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup Rust Cross-Compilation Targets
|
### Setup Rust Cross-Compilation Targets
|
||||||
@@ -116,8 +114,8 @@ cd path/to/socktop
|
|||||||
# For 64-bit Raspberry Pi
|
# 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
|
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)
|
# For 32-bit Raspberry Pi
|
||||||
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent --no-default-features
|
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiled binaries will be available in your local target directory.
|
The compiled binaries will be available in your local target directory.
|
||||||
@@ -135,11 +133,11 @@ The recommended approach for Windows is to use Windows Subsystem for Linux (WSL2
|
|||||||
After setting up your environment, build the socktop_agent for your target Raspberry Pi:
|
After setting up your environment, build the socktop_agent for your target Raspberry Pi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For 64-bit Raspberry Pi (with GPU support)
|
# For 64-bit Raspberry Pi
|
||||||
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
|
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
|
||||||
|
|
||||||
# For 32-bit Raspberry Pi (without GPU support)
|
# For 32-bit Raspberry Pi
|
||||||
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent --no-default-features
|
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent
|
||||||
```
|
```
|
||||||
|
|
||||||
## Transfer the Binary to Your Raspberry Pi
|
## Transfer the Binary to Your Raspberry Pi
|
||||||
@@ -163,12 +161,11 @@ SSH into your Raspberry Pi and install the required dependencies:
|
|||||||
```bash
|
```bash
|
||||||
ssh pi@raspberry-pi-ip
|
ssh pi@raspberry-pi-ip
|
||||||
|
|
||||||
# For Raspberry Pi OS (Debian-based) - 64-bit only
|
# For Raspberry Pi OS (Debian-based)
|
||||||
# (32-bit armv7 builds don't require these)
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||||
|
|
||||||
# For Arch Linux ARM - 64-bit only
|
# For Arch Linux ARM
|
||||||
sudo pacman -Syu
|
sudo pacman -Syu
|
||||||
sudo pacman -S libdrm
|
sudo pacman -S libdrm
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 64 MiB |
Executable
+200
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install socktop binaries and man pages
|
||||||
|
# This script builds the binaries, generates man pages, and installs everything
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Color output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo -e "${BLUE}==>${NC} ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}✓${NC} ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}✗${NC} ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}!${NC} ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
SYSTEM_INSTALL=false
|
||||||
|
MAN_ONLY=false
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--system)
|
||||||
|
SYSTEM_INSTALL=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--man-only)
|
||||||
|
MAN_ONLY=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Build and install socktop binaries and man pages"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --system Install system-wide (requires sudo)"
|
||||||
|
echo " --man-only Only install man pages (skip binary build)"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 # Build and install for current user"
|
||||||
|
echo " sudo $0 --system # Build and install system-wide"
|
||||||
|
echo " $0 --man-only # Only install man pages"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "Unknown option: $1"
|
||||||
|
echo "Run '$0 --help' for usage information"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Step 1: Build binaries (unless --man-only)
|
||||||
|
if [ "$MAN_ONLY" = false ]; then
|
||||||
|
print_header "Building binaries..."
|
||||||
|
cargo build --release
|
||||||
|
print_success "Binaries built successfully"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: Extract generated man pages from OUT_DIR
|
||||||
|
print_header "Extracting generated man pages..."
|
||||||
|
|
||||||
|
# Find the build output directory
|
||||||
|
SOCKTOP_OUT_DIR=$(find target/release/build/socktop-*/out -type d -name "man" 2>/dev/null | head -1)
|
||||||
|
AGENT_OUT_DIR=$(find target/release/build/socktop_agent-*/out -type d -name "man" 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$SOCKTOP_OUT_DIR" ] || [ -z "$AGENT_OUT_DIR" ]; then
|
||||||
|
print_error "Generated man pages not found. Building to generate them..."
|
||||||
|
cargo build --release
|
||||||
|
SOCKTOP_OUT_DIR=$(find target/release/build/socktop-*/out -type d -name "man" 2>/dev/null | head -1)
|
||||||
|
AGENT_OUT_DIR=$(find target/release/build/socktop_agent-*/out -type d -name "man" 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SOCKTOP_OUT_DIR" ] || [ ! -f "$SOCKTOP_OUT_DIR/socktop.1" ]; then
|
||||||
|
print_error "Failed to find generated socktop.1 man page"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$AGENT_OUT_DIR" ] || [ ! -f "$AGENT_OUT_DIR/socktop_agent.1" ]; then
|
||||||
|
print_error "Failed to find generated socktop_agent.1 man page"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Found generated man pages"
|
||||||
|
|
||||||
|
# Step 3: Determine installation directories
|
||||||
|
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
print_error "System-wide installation requires root privileges"
|
||||||
|
echo "Please run with sudo: sudo $0 --system"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BIN_DIR="/usr/local/bin"
|
||||||
|
MAN_DIR="/usr/local/share/man/man1"
|
||||||
|
else
|
||||||
|
BIN_DIR="$HOME/.cargo/bin"
|
||||||
|
MAN_DIR="$HOME/.local/share/man/man1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Install binaries (unless --man-only)
|
||||||
|
if [ "$MAN_ONLY" = false ]; then
|
||||||
|
print_header "Installing binaries to $BIN_DIR..."
|
||||||
|
|
||||||
|
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||||
|
install -m 755 target/release/socktop "$BIN_DIR/socktop"
|
||||||
|
install -m 755 target/release/socktop_agent "$BIN_DIR/socktop_agent"
|
||||||
|
else
|
||||||
|
# For user install, cargo already puts binaries in ~/.cargo/bin
|
||||||
|
# But we can copy from release if needed
|
||||||
|
if [ ! -f "$BIN_DIR/socktop" ]; then
|
||||||
|
cp target/release/socktop "$BIN_DIR/"
|
||||||
|
chmod 755 "$BIN_DIR/socktop"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$BIN_DIR/socktop_agent" ]; then
|
||||||
|
cp target/release/socktop_agent "$BIN_DIR/"
|
||||||
|
chmod 755 "$BIN_DIR/socktop_agent"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Binaries installed to $BIN_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Install man pages
|
||||||
|
print_header "Installing man pages to $MAN_DIR..."
|
||||||
|
|
||||||
|
mkdir -p "$MAN_DIR"
|
||||||
|
|
||||||
|
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||||
|
install -m 644 "$SOCKTOP_OUT_DIR/socktop.1" "$MAN_DIR/socktop.1"
|
||||||
|
install -m 644 "$AGENT_OUT_DIR/socktop_agent.1" "$MAN_DIR/socktop_agent.1"
|
||||||
|
else
|
||||||
|
cp "$SOCKTOP_OUT_DIR/socktop.1" "$MAN_DIR/socktop.1"
|
||||||
|
cp "$AGENT_OUT_DIR/socktop_agent.1" "$MAN_DIR/socktop_agent.1"
|
||||||
|
chmod 644 "$MAN_DIR/socktop.1"
|
||||||
|
chmod 644 "$MAN_DIR/socktop_agent.1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Man pages installed to $MAN_DIR"
|
||||||
|
|
||||||
|
# Update man database if available
|
||||||
|
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||||
|
if command -v mandb &>/dev/null; then
|
||||||
|
print_header "Updating man database..."
|
||||||
|
mandb 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final summary
|
||||||
|
echo ""
|
||||||
|
print_success "Installation complete!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$MAN_ONLY" = false ]; then
|
||||||
|
echo "Binaries installed:"
|
||||||
|
echo " socktop -> $BIN_DIR/socktop"
|
||||||
|
echo " socktop_agent -> $BIN_DIR/socktop_agent"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Man pages installed:"
|
||||||
|
echo " socktop(1) -> $MAN_DIR/socktop.1"
|
||||||
|
echo " socktop_agent(1) -> $MAN_DIR/socktop_agent.1"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Try it out:"
|
||||||
|
if [ "$MAN_ONLY" = false ]; then
|
||||||
|
echo " socktop --help"
|
||||||
|
echo " socktop_agent --help"
|
||||||
|
fi
|
||||||
|
echo " man socktop"
|
||||||
|
echo " man socktop_agent"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if MANPATH needs updating for user install
|
||||||
|
if [ "$SYSTEM_INSTALL" = false ]; then
|
||||||
|
if ! man -w socktop &>/dev/null 2>&1; then
|
||||||
|
print_warning "If 'man socktop' doesn't work, add to your shell rc file:"
|
||||||
|
echo " export MANPATH=\"\$HOME/.local/share/man:\$MANPATH\""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
#!/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 $?
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/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)."
|
|
||||||
|
|
||||||
+10
-25
@@ -1,17 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "socktop"
|
name = "socktop"
|
||||||
version = "1.60.2"
|
version = "1.50.0"
|
||||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||||
description = "Remote system monitor over WebSocket, TUI like top"
|
description = "Remote system monitor over WebSocket, TUI like top"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
homepage = "https://github.com/jasonwitty/socktop"
|
|
||||||
repository = "https://github.com/jasonwitty/socktop"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# CLI parsing and man page generation
|
||||||
|
clap = { version = "4.5", features = ["derive", "cargo", "wrap_help"] }
|
||||||
|
|
||||||
# socktop connector for agent communication
|
# socktop connector for agent communication
|
||||||
socktop_connector = { version = "1.60.1", path = "../socktop_connector" }
|
socktop_connector = "1.50.0"
|
||||||
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
@@ -20,30 +21,14 @@ serde_json = { workspace = true }
|
|||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
unicode-width = { workspace = true }
|
|
||||||
anyhow = { 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 }
|
dirs-next = { workspace = true }
|
||||||
|
sysinfo = { workspace = true }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive", "cargo"] }
|
||||||
|
clap_mangen = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2.0"
|
assert_cmd = "2.0"
|
||||||
tempfile = "3"
|
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,30 @@
|
|||||||
|
use clap::CommandFactory;
|
||||||
|
use clap_mangen::Man;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
include!("src/cli.rs");
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
println!("cargo:rerun-if-changed=src/cli.rs");
|
||||||
|
|
||||||
|
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||||
|
let man_dir = out_dir.join("man");
|
||||||
|
fs::create_dir_all(&man_dir)?;
|
||||||
|
|
||||||
|
// Generate man page for socktop
|
||||||
|
let cmd = Cli::command();
|
||||||
|
let man = Man::new(cmd);
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
man.render(&mut buffer)?;
|
||||||
|
|
||||||
|
fs::write(man_dir.join("socktop.1"), buffer)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"cargo:warning=Man page generated at {:?}",
|
||||||
|
man_dir.join("socktop.1")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+618
-1643
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
// CLI argument definitions using clap derive macros.
|
||||||
|
// This file is also included by build.rs for man page generation.
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "socktop",
|
||||||
|
version,
|
||||||
|
author,
|
||||||
|
about = "Remote system monitor with a rich TUI over WebSocket",
|
||||||
|
long_about = "socktop is a remote system monitor with a rich terminal user interface (TUI), \
|
||||||
|
inspired by top/btop. It connects to a lightweight socktop_agent over WebSockets \
|
||||||
|
to display real-time system metrics including CPU usage, memory, swap, disk usage, \
|
||||||
|
network throughput, temperatures, GPU metrics, and a sortable process table.\n\n\
|
||||||
|
The agent is request-driven with near-zero CPU usage when idle."
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// WebSocket URL to connect to (e.g., ws://192.168.1.100:8080/ws or wss://host:8443/ws)
|
||||||
|
#[arg(value_name = "URL")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
|
||||||
|
/// Path to TLS certificate PEM file for WSS connections
|
||||||
|
///
|
||||||
|
/// The certificate is pinned for security. The agent auto-generates
|
||||||
|
/// a self-signed certificate on first run.
|
||||||
|
#[arg(short = 't', long = "tls-ca", value_name = "CERT_PEM")]
|
||||||
|
pub tls_ca: Option<String>,
|
||||||
|
|
||||||
|
/// Enable hostname (SAN) verification for TLS connections
|
||||||
|
///
|
||||||
|
/// By default, hostname verification is skipped for easier home network usage,
|
||||||
|
/// but the certificate is still pinned.
|
||||||
|
#[arg(long)]
|
||||||
|
pub verify_hostname: bool,
|
||||||
|
|
||||||
|
/// Use a named connection profile
|
||||||
|
///
|
||||||
|
/// Profiles are stored in ~/.config/socktop/profiles.json and can contain
|
||||||
|
/// URL, TLS settings, and polling intervals.
|
||||||
|
#[arg(short = 'P', long = "profile", value_name = "NAME")]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
|
||||||
|
/// Save the current connection as a named profile
|
||||||
|
///
|
||||||
|
/// Use with --profile to specify the profile name.
|
||||||
|
#[arg(long)]
|
||||||
|
pub save: bool,
|
||||||
|
|
||||||
|
/// Run in demo mode using mock data without connecting to an agent
|
||||||
|
///
|
||||||
|
/// Useful for testing the UI without a running agent.
|
||||||
|
#[arg(long)]
|
||||||
|
pub demo: bool,
|
||||||
|
|
||||||
|
/// Set the metrics polling interval in milliseconds
|
||||||
|
///
|
||||||
|
/// Default is typically 1000ms. Lower values increase update frequency
|
||||||
|
/// but also CPU usage.
|
||||||
|
#[arg(long, value_name = "MS")]
|
||||||
|
pub metrics_interval_ms: Option<u64>,
|
||||||
|
|
||||||
|
/// Set the process list polling interval in milliseconds
|
||||||
|
///
|
||||||
|
/// Can be different from metrics interval to reduce overhead.
|
||||||
|
#[arg(long, value_name = "MS")]
|
||||||
|
pub processes_interval_ms: Option<u64>,
|
||||||
|
|
||||||
|
/// Hidden test helper: skip connecting
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
/// Parse CLI arguments from environment
|
||||||
|
pub fn parse_args() -> Self {
|
||||||
|
Cli::parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_basic_parsing() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop", "ws://localhost:8080/ws"]).unwrap();
|
||||||
|
assert_eq!(cli.url, Some("ws://localhost:8080/ws".to_string()));
|
||||||
|
assert!(!cli.demo);
|
||||||
|
assert!(!cli.save);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tls_options() {
|
||||||
|
let cli = Cli::try_parse_from(&[
|
||||||
|
"socktop",
|
||||||
|
"-t",
|
||||||
|
"/path/to/cert.pem",
|
||||||
|
"--verify-hostname",
|
||||||
|
"wss://example.com:8443/ws",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cli.tls_ca, Some("/path/to/cert.pem".to_string()));
|
||||||
|
assert!(cli.verify_hostname);
|
||||||
|
assert_eq!(cli.url, Some("wss://example.com:8443/ws".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_options() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop", "-P", "myprofile", "--save"]).unwrap();
|
||||||
|
assert_eq!(cli.profile, Some("myprofile".to_string()));
|
||||||
|
assert!(cli.save);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intervals() {
|
||||||
|
let cli = Cli::try_parse_from(&[
|
||||||
|
"socktop",
|
||||||
|
"--metrics-interval-ms",
|
||||||
|
"500",
|
||||||
|
"--processes-interval-ms",
|
||||||
|
"2000",
|
||||||
|
"ws://localhost:8080/ws",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cli.metrics_interval_ms, Some(500));
|
||||||
|
assert_eq!(cli.processes_interval_ms, Some(2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_demo_mode() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop", "--demo"]).unwrap();
|
||||||
|
assert!(cli.demo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-16
@@ -2,25 +2,16 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
/// Push a value into a capped deque. Returns the evicted front element if any.
|
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||||
/// Callers maintaining a running sum can use this to update the sum without
|
if dq.len() == cap {
|
||||||
/// re-iterating the whole deque.
|
dq.pop_front();
|
||||||
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);
|
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 struct PerCoreHistory {
|
||||||
pub deques: Vec<VecDeque<u64>>,
|
pub deques: Vec<VecDeque<u16>>,
|
||||||
cap: usize,
|
cap: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +35,7 @@ impl PerCoreHistory {
|
|||||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||||
self.ensure_cores(samples.len());
|
self.ensure_cores(samples.len());
|
||||||
for (i, v) in samples.iter().enumerate() {
|
for (i, v) in samples.iter().enumerate() {
|
||||||
let val = v.clamp(0.0, 100.0).round() as u64;
|
let val = v.clamp(0.0, 100.0).round() as u16;
|
||||||
push_capped(&mut self.deques[i], val, self.cap);
|
push_capped(&mut self.deques[i], val, self.cap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
//! 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(""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-253
@@ -1,186 +1,24 @@
|
|||||||
//! Entry point for the socktop TUI. Parses args and runs the App.
|
//! Entry point for the socktop TUI. Parses args and runs the App.
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
|
mod cli;
|
||||||
mod history;
|
mod history;
|
||||||
mod local;
|
|
||||||
mod proc_kill;
|
|
||||||
mod profiles;
|
mod profiles;
|
||||||
mod retry;
|
mod retry;
|
||||||
mod types;
|
mod types;
|
||||||
mod ui; // pure retry timing logic
|
mod ui;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
use cli::Cli;
|
||||||
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};
|
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};
|
||||||
use std::env;
|
|
||||||
use std::io::{self, Write};
|
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,
|
|
||||||
no_kill: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when the `SOCKTOP_NO_KILL` environment variable disables the process-kill
|
|
||||||
/// feature. Any value other than empty, `0`, or `false` (case-insensitive) counts
|
|
||||||
/// as set, so a deployment can export `SOCKTOP_NO_KILL=1` once and every socktop
|
|
||||||
/// launched under it — whatever its command line — has the feature off.
|
|
||||||
pub(crate) fn no_kill_from_env() -> bool {
|
|
||||||
match env::var("SOCKTOP_NO_KILL") {
|
|
||||||
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
let mut no_kill = 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] [--no-kill] [--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;
|
|
||||||
}
|
|
||||||
"--no-kill" => {
|
|
||||||
// Disable the local process-kill feature even when the agent is
|
|
||||||
// local. For shared/kiosk deployments; SOCKTOP_NO_KILL=1 in the
|
|
||||||
// environment does the same without touching the command line.
|
|
||||||
no_kill = 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// An unrecognized option must never fall through to the
|
|
||||||
// positional URL slot: an older binary handed a newer flag
|
|
||||||
// would otherwise "connect" to the flag text — and offer to
|
|
||||||
// save it over a named profile's URL.
|
|
||||||
if arg.starts_with('-') {
|
|
||||||
return Err(format!(
|
|
||||||
"Unknown option '{arg}'. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
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] [--no-kill] [ws://HOST:PORT/ws]"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ParsedArgs {
|
|
||||||
url,
|
|
||||||
tls_ca,
|
|
||||||
profile,
|
|
||||||
save,
|
|
||||||
demo,
|
|
||||||
dry_run,
|
|
||||||
metrics_interval_ms,
|
|
||||||
processes_interval_ms,
|
|
||||||
verify_hostname,
|
|
||||||
compact,
|
|
||||||
no_kill,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let parsed = match parse_args(env::args()) {
|
let parsed = Cli::parse_args();
|
||||||
Ok(v) => v,
|
|
||||||
Err(msg) => {
|
|
||||||
eprintln!("{msg}");
|
|
||||||
// --help produces the bare usage text and exits cleanly; real
|
|
||||||
// parse errors must be visible to scripts and CI as a failure.
|
|
||||||
if !msg.starts_with("Usage:") {
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//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")) {
|
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill).await;
|
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let profiles_file = load_profiles();
|
let profiles_file = load_profiles();
|
||||||
@@ -285,12 +123,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if (1..=names.len()).contains(&idx) {
|
if (1..=names.len()).contains(&idx) {
|
||||||
let name = &names[idx - 1];
|
let name = &names[idx - 1];
|
||||||
if name == "demo" {
|
if name == "demo" {
|
||||||
return run_demo_mode(
|
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||||
parsed.tls_ca.as_deref(),
|
|
||||||
parsed.compact,
|
|
||||||
parsed.no_kill,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||||
(
|
(
|
||||||
@@ -350,8 +183,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
eprintln!("If you don't have an agent running, you can try the demo mode.");
|
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]: ") {
|
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, parsed.no_kill)
|
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||||
.await;
|
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -363,17 +195,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let is_tls = url.starts_with("wss://");
|
let is_tls = url.starts_with("wss://");
|
||||||
let has_token = url.contains("token=");
|
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) —
|
|
||||||
// AND neither --no-kill nor SOCKTOP_NO_KILL disables it as a matter of
|
|
||||||
// policy (shared terminals, public demos).
|
|
||||||
let kill_enabled = local::agent_is_local(&url) && !parsed.no_kill && !no_kill_from_env();
|
|
||||||
let mut app = App::new()
|
let mut app = App::new()
|
||||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||||
.with_status(is_tls, has_token)
|
.with_status(is_tls, has_token);
|
||||||
.with_compact(parsed.compact)
|
|
||||||
.with_kill_enabled(kill_enabled);
|
|
||||||
if parsed.dry_run {
|
if parsed.dry_run {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -437,30 +261,11 @@ fn gather_intervals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Demo mode implementation
|
// Demo mode implementation
|
||||||
async fn run_demo_mode(
|
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
_tls_ca: Option<&str>,
|
|
||||||
compact: bool,
|
|
||||||
no_kill: bool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let port = 3231;
|
let port = 3231;
|
||||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||||
let child = match spawn_demo_agent(port) {
|
let child = spawn_demo_agent(port)?;
|
||||||
Ok(child) => child,
|
let mut app = App::new();
|
||||||
// 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, --no-kill and
|
|
||||||
// SOCKTOP_NO_KILL still override).
|
|
||||||
let mut app = App::new()
|
|
||||||
.with_compact(compact)
|
|
||||||
.with_kill_enabled(local::agent_is_local(&url) && !no_kill && !no_kill_from_env());
|
|
||||||
// Demo mode connects to localhost, so disable hostname verification
|
// 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(()) } }
|
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||||
}
|
}
|
||||||
@@ -476,50 +281,9 @@ impl Drop for DemoGuard {
|
|||||||
eprintln!("Stopped demo agent on port {}", self.port);
|
eprintln!("Stopped demo agent on port {}", self.port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Debug)]
|
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||||
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 candidate = find_agent_executable();
|
||||||
let mut cmd = std::process::Command::new(&candidate);
|
let mut cmd = std::process::Command::new(candidate);
|
||||||
cmd.arg("--port").arg(port.to_string());
|
cmd.arg("--port").arg(port.to_string());
|
||||||
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
||||||
|
|
||||||
@@ -527,10 +291,7 @@ fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
|
|||||||
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
||||||
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
||||||
|
|
||||||
let child = cmd.spawn().map_err(|e| match e.kind() {
|
let child = cmd.spawn()?;
|
||||||
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
|
|
||||||
_ => DemoAgentError::Io(e),
|
|
||||||
})?;
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
Ok(DemoGuard {
|
Ok(DemoGuard {
|
||||||
port,
|
port,
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+88
-332
@@ -7,17 +7,11 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{
|
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||||
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::history::PerCoreHistory;
|
use crate::history::PerCoreHistory;
|
||||||
use crate::types::Metrics;
|
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
|
/// State for dragging the scrollbar thumb
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
@@ -139,9 +133,11 @@ pub fn per_core_handle_scrollbar_mouse(
|
|||||||
}
|
}
|
||||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||||
let top_for_offset = |off: usize| -> usize {
|
let top_for_offset = |off: usize| -> usize {
|
||||||
((track - thumb_len) * off + max_off / 2)
|
if max_off == 0 {
|
||||||
.checked_div(max_off)
|
0
|
||||||
.unwrap_or(0)
|
} else {
|
||||||
|
((track - thumb_len) * off + max_off / 2) / max_off
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let thumb_top = top_for_offset(offset);
|
let thumb_top = top_for_offset(offset);
|
||||||
|
|
||||||
@@ -194,9 +190,11 @@ pub fn per_core_handle_scrollbar_mouse(
|
|||||||
// Inverse mapping top -> offset
|
// Inverse mapping top -> offset
|
||||||
if track > thumb_len {
|
if track > thumb_len {
|
||||||
let denom = track - thumb_len;
|
let denom = track - thumb_len;
|
||||||
offset = (new_top * max_off + denom / 2)
|
offset = if max_off == 0 {
|
||||||
.checked_div(denom)
|
0
|
||||||
.unwrap_or(0);
|
} else {
|
||||||
|
(new_top * max_off + denom / 2) / denom
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
offset = 0;
|
offset = 0;
|
||||||
}
|
}
|
||||||
@@ -236,51 +234,62 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draws the CPU average sparkline graph.
|
/// 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(
|
pub fn draw_cpu_avg_graph(
|
||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
hist: &mut std::collections::VecDeque<u64>,
|
hist: &std::collections::VecDeque<u64>,
|
||||||
hist_sum: u64,
|
|
||||||
m: Option<&Metrics>,
|
m: Option<&Metrics>,
|
||||||
) {
|
) {
|
||||||
let avg_cpu = if hist.is_empty() {
|
// Calculate average CPU over the monitoring period
|
||||||
0.0
|
let avg_cpu = if !hist.is_empty() {
|
||||||
|
let sum: u64 = hist.iter().sum();
|
||||||
|
sum as f64 / hist.len() as f64
|
||||||
} else {
|
} else {
|
||||||
hist_sum as f64 / hist.len() as f64
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let (title, top_right_info) = cpu_title_for_width(
|
let title = if let Some(mm) = m {
|
||||||
m.map(|mm| mm.cpu_total),
|
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
|
||||||
avg_cpu,
|
} else {
|
||||||
m.and_then(|mm| mm.cpu_temp_c),
|
"CPU avg".into()
|
||||||
area.width,
|
};
|
||||||
);
|
|
||||||
|
// Build the top-right info (CPU temp and polling intervals)
|
||||||
|
let top_right_info = if let Some(mm) = m {
|
||||||
|
mm.cpu_temp_c
|
||||||
|
.map(|t| {
|
||||||
|
let icon = if t < 50.0 {
|
||||||
|
"😎"
|
||||||
|
} else if t < 85.0 {
|
||||||
|
"⚠️"
|
||||||
|
} else {
|
||||||
|
"🔥"
|
||||||
|
};
|
||||||
|
format!("CPU Temp: {t:.1}°C {icon}")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "CPU Temp: N/A".into())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
// 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 max_points = area.width.saturating_sub(2) as usize;
|
||||||
let start = hist.len().saturating_sub(max_points);
|
let start = hist.len().saturating_sub(max_points);
|
||||||
let slice = &hist.make_contiguous()[start..];
|
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||||
|
|
||||||
|
// Render the sparkline with title on left
|
||||||
let spark = Sparkline::default()
|
let spark = Sparkline::default()
|
||||||
.block(Block::default().borders(Borders::ALL).title(title))
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
.data(slice)
|
.data(&data)
|
||||||
.max(100)
|
.max(100)
|
||||||
.style(Style::default().fg(Color::Cyan));
|
.style(Style::default().fg(Color::Cyan));
|
||||||
f.render_widget(spark, area);
|
f.render_widget(spark, area);
|
||||||
|
|
||||||
// Temperature overlays the top border, right-aligned inside the corner. The title
|
// Render the top-right info as text overlay in the top-right corner
|
||||||
// above is sized so the two cannot collide.
|
|
||||||
if !top_right_info.is_empty() {
|
if !top_right_info.is_empty() {
|
||||||
let w = cols(&top_right_info);
|
|
||||||
let info_area = Rect {
|
let info_area = Rect {
|
||||||
x: area.x + area.width.saturating_sub(w + 1),
|
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width: w,
|
width: top_right_info.len() as u16 + 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let info_line = Line::from(Span::raw(top_right_info));
|
let info_line = Line::from(Span::raw(top_right_info));
|
||||||
@@ -288,73 +297,12 @@ pub fn draw_cpu_avg_graph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// Draws the per-core CPU bars with sparklines and trends.
|
||||||
pub fn draw_per_core_bars(
|
pub fn draw_per_core_bars(
|
||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
m: Option<&Metrics>,
|
m: Option<&Metrics>,
|
||||||
per_core_hist: &mut PerCoreHistory,
|
per_core_hist: &PerCoreHistory,
|
||||||
scroll_offset: usize,
|
scroll_offset: usize,
|
||||||
) {
|
) {
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
@@ -399,7 +347,7 @@ pub fn draw_per_core_bars(
|
|||||||
let rect = vchunks[i];
|
let rect = vchunks[i];
|
||||||
let hchunks = Layout::default()
|
let hchunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||||
.split(rect);
|
.split(rect);
|
||||||
|
|
||||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||||
@@ -410,17 +358,12 @@ pub fn draw_per_core_bars(
|
|||||||
.map(|v| v as f32)
|
.map(|v| v as f32)
|
||||||
.unwrap_or(curr);
|
.unwrap_or(curr);
|
||||||
|
|
||||||
// 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 {
|
let trend = if curr > older + 0.2 {
|
||||||
"↑"
|
"↑"
|
||||||
} else if curr + 0.2 < older {
|
} else if curr + 0.2 < older {
|
||||||
"↓"
|
"↓"
|
||||||
} else {
|
} else {
|
||||||
" "
|
"╌"
|
||||||
};
|
};
|
||||||
|
|
||||||
let fg = match curr {
|
let fg = match curr {
|
||||||
@@ -429,24 +372,24 @@ pub fn draw_per_core_bars(
|
|||||||
_ => Color::Red,
|
_ => Color::Red,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
let hist: Vec<u64> = per_core_hist
|
||||||
// to Sparkline without allocating a fresh Vec each frame.
|
.deques
|
||||||
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
.get(idx)
|
||||||
let max_points = hchunks[0].width as usize;
|
.map(|d| {
|
||||||
let start = d.len().saturating_sub(max_points);
|
let max_points = hchunks[0].width as usize;
|
||||||
let slice = &d.make_contiguous()[start..];
|
let start = d.len().saturating_sub(max_points);
|
||||||
let spark = Sparkline::default()
|
d.iter().skip(start).map(|&v| v as u64).collect()
|
||||||
.data(slice)
|
})
|
||||||
.max(100)
|
.unwrap_or_default();
|
||||||
.style(Style::default().fg(fg));
|
|
||||||
f.render_widget(spark, hchunks[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hard space between the trend mark and the number — even if the
|
let spark = Sparkline::default()
|
||||||
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
.data(&hist)
|
||||||
// them, this space prevents the trend mark from visually joining
|
.max(100)
|
||||||
// `100.0` to look like a negative value.
|
.style(Style::default().fg(fg));
|
||||||
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
|
||||||
|
f.render_widget(spark, hchunks[0]);
|
||||||
|
|
||||||
|
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
|
||||||
let line = Line::from(Span::styled(
|
let line = Line::from(Span::styled(
|
||||||
label,
|
label,
|
||||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||||
@@ -454,225 +397,38 @@ pub fn draw_per_core_bars(
|
|||||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
||||||
// content fits in the viewport, matching the previous behaviour.
|
|
||||||
let scroll_area = Rect {
|
let scroll_area = Rect {
|
||||||
x: inner.x + inner.width.saturating_sub(1),
|
x: inner.x + inner.width.saturating_sub(1),
|
||||||
y: inner.y,
|
y: inner.y,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: inner.height,
|
height: inner.height,
|
||||||
};
|
};
|
||||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
if scroll_area.height >= 3 {
|
||||||
if scroll_area.height >= 3 && max_off > 0 {
|
let track = (scroll_area.height - 2) as usize;
|
||||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
let total = total_rows.max(1);
|
||||||
.begin_symbol(Some("▲"))
|
let view = viewport_rows.clamp(1, total);
|
||||||
.end_symbol(Some("▼"))
|
let max_off = total.saturating_sub(view);
|
||||||
.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)]
|
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||||
mod title_tests {
|
let thumb_top = if max_off == 0 {
|
||||||
use super::*;
|
0
|
||||||
|
} else {
|
||||||
|
((track - thumb_len) * offset + max_off / 2) / max_off
|
||||||
|
};
|
||||||
|
|
||||||
/// The defect this replaces: the temperature was painted over the title's tail on a
|
// Build lines: top arrow, track (with thumb), bottom arrow
|
||||||
/// narrow pane. Whatever the width, the two must fit side by side on the border.
|
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
|
||||||
#[test]
|
lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW))));
|
||||||
fn title_and_temperature_never_overlap() {
|
for i in 0..track {
|
||||||
for width in 0..=200u16 {
|
if i >= thumb_top && i < thumb_top + thumb_len {
|
||||||
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
||||||
let budget = width.saturating_sub(4);
|
} else {
|
||||||
if temp.is_empty() {
|
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
||||||
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:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
||||||
|
|
||||||
/// An agent that reports no temperature must not leave a stray label behind.
|
f.render_widget(Paragraph::new(lines), scroll_area);
|
||||||
#[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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -1,8 +1,7 @@
|
|||||||
//! Disk cards with per-device gauge and title line.
|
//! Disk cards with per-device gauge and title line.
|
||||||
|
|
||||||
use crate::types::Metrics;
|
use crate::types::Metrics;
|
||||||
use crate::ui::fit::truncate_middle_cols;
|
use crate::ui::util::{disk_icon, human, truncate_middle};
|
||||||
use crate::ui::util::{disk_icon, human};
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::Style,
|
style::Style,
|
||||||
@@ -25,11 +24,16 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplication is performed once on the App side when fresh disk data
|
// Filter duplicates by keeping first occurrence of each unique name
|
||||||
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
let mut seen_names = std::collections::HashSet::new();
|
||||||
// here would rebuild a HashSet ~10x per refresh for no reason).
|
let unique_disks: Vec<_> = mm
|
||||||
|
.disks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| seen_names.insert(d.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let per_disk_h = 3u16;
|
let per_disk_h = 3u16;
|
||||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize;
|
||||||
|
|
||||||
let constraints: Vec<Constraint> = (0..max_cards)
|
let constraints: Vec<Constraint> = (0..max_cards)
|
||||||
.map(|_| Constraint::Length(per_disk_h))
|
.map(|_| Constraint::Length(per_disk_h))
|
||||||
@@ -40,7 +44,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
for (i, slot) in rows.iter().enumerate() {
|
for (i, slot) in rows.iter().enumerate() {
|
||||||
let d = &mm.disks[i];
|
let d = unique_disks[i];
|
||||||
let used = d.total.saturating_sub(d.available);
|
let used = d.total.saturating_sub(d.available);
|
||||||
let ratio = if d.total > 0 {
|
let ratio = if d.total > 0 {
|
||||||
used as f64 / d.total as f64
|
used as f64 / d.total as f64
|
||||||
@@ -70,7 +74,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
"{}{}{}{} {} / {} ({}%)",
|
"{}{}{}{} {} / {} ({}%)",
|
||||||
indent,
|
indent,
|
||||||
disk_icon(&d.name),
|
disk_icon(&d.name),
|
||||||
truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2),
|
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||||
temp_str,
|
temp_str,
|
||||||
human(used),
|
human(used),
|
||||||
human(d.total),
|
human(d.total),
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
//! 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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -121,210 +121,3 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+35
-212
@@ -1,232 +1,55 @@
|
|||||||
//! Top header with hostname, connection status and polling intervals.
|
//! Top header with hostname and CPU temperature indicator.
|
||||||
//!
|
|
||||||
//! 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 crate::types::Metrics;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Columns kept clear between the left and right halves.
|
pub fn draw_header(
|
||||||
const GAP: u16 = 2;
|
f: &mut ratatui::Frame<'_>,
|
||||||
/// Never shorten the hostname below this before dropping the intervals instead.
|
area: Rect,
|
||||||
const HOSTNAME_FLOOR: u16 = 8;
|
m: Option<&Metrics>,
|
||||||
|
is_tls: bool,
|
||||||
/// Session state the header renders.
|
has_token: bool,
|
||||||
#[derive(Clone, Copy)]
|
metrics_interval: Duration,
|
||||||
pub struct HeaderState<'a> {
|
procs_interval: Duration,
|
||||||
pub hostname: Option<&'a str>,
|
) {
|
||||||
pub is_tls: bool,
|
let base = if let Some(mm) = m {
|
||||||
pub has_token: bool,
|
format!("socktop — host: {}", mm.hostname)
|
||||||
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 {
|
} else {
|
||||||
"🔒✗ TLS"
|
"socktop — connecting...".into()
|
||||||
};
|
};
|
||||||
let badges = if state.has_token {
|
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||||
format!("{tls} | 🔑 token")
|
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||||
} else {
|
// Token indicator
|
||||||
tls.to_string()
|
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||||
};
|
let mut parts = vec![base, tls_txt.into()];
|
||||||
|
if !tok_txt.is_empty() {
|
||||||
let named = format!("socktop — host: {host}");
|
parts.push(tok_txt.into());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
parts.push("(a: about, h: help, q: quit)".into());
|
||||||
|
let title = parts.join(" | ");
|
||||||
|
|
||||||
// Past the floor of the ladder: shorten the hostname, and give up the intervals only
|
// Render the block with left-aligned title
|
||||||
// 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);
|
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||||
|
|
||||||
if intervals.is_empty() {
|
// Render polling intervals on the right side
|
||||||
return;
|
let mi = metrics_interval.as_millis();
|
||||||
}
|
let pi = procs_interval.as_millis();
|
||||||
let intervals_width = cols(intervals);
|
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||||
if area.width >= intervals_width {
|
let intervals_width = intervals.len() as u16;
|
||||||
|
|
||||||
|
if area.width > intervals_width + 2 {
|
||||||
let right_area = Rect {
|
let right_area = Rect {
|
||||||
x: area.x + area.width - intervals_width,
|
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width: intervals_width,
|
width: intervals_width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
|
let intervals_line = Line::from(Span::raw(intervals));
|
||||||
}
|
f.render_widget(Paragraph::new(intervals_line), 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}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,352 +0,0 @@
|
|||||||
//! 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
pub mod cpu;
|
pub mod cpu;
|
||||||
pub mod disks;
|
pub mod disks;
|
||||||
pub mod fit;
|
|
||||||
pub mod gpu;
|
pub mod gpu;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod layout;
|
|
||||||
pub mod mem;
|
pub mod mem;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod modal_connection;
|
pub mod modal_connection;
|
||||||
|
|||||||
+76
-549
@@ -1,10 +1,6 @@
|
|||||||
//! Modal window system for socktop TUI application
|
//! Modal window system for socktop TUI application
|
||||||
|
|
||||||
use super::fit;
|
use super::theme::MODAL_DIM_BG;
|
||||||
use super::theme::{
|
|
||||||
BTN_EXIT_BG_ACTIVE, BTN_RETRY_BG_ACTIVE, MODAL_BG, MODAL_BORDER_FG, MODAL_DIM_BG, MODAL_FG,
|
|
||||||
MODAL_TITLE_FG,
|
|
||||||
};
|
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -30,10 +26,6 @@ pub struct ModalManager {
|
|||||||
pub help_scroll_offset: usize,
|
pub help_scroll_offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key hints shown under the confirmation buttons. Also sets the minimum
|
|
||||||
/// width of that dialog — sizing from the question alone clipped this line.
|
|
||||||
const CONFIRM_HINT: &str = "Tab ← → choose · Enter run · Esc cancel";
|
|
||||||
|
|
||||||
impl ModalManager {
|
impl ModalManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -91,52 +83,6 @@ impl ModalManager {
|
|||||||
}
|
}
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
/// Close the details view for `pid` WHEREVER it sits in the stack.
|
|
||||||
/// Returns whether anything was closed.
|
|
||||||
///
|
|
||||||
/// Not just the top: killing from inside the details view stacks the
|
|
||||||
/// "Signal sent" Info modal on top of it, and a SIGKILL victim is usually
|
|
||||||
/// confirmed dead on the very next tick — while that Info is still up. A
|
|
||||||
/// top-only check missed the close, and since a gone PID is processed
|
|
||||||
/// once, the details view stayed open (frozen on the dead process's last
|
|
||||||
/// sample) with nothing left to ever close it.
|
|
||||||
///
|
|
||||||
/// Per-PID matching keeps the parent-navigation property: only the dead
|
|
||||||
/// process's view goes; parent views underneath are other processes that
|
|
||||||
/// may still be alive and close themselves the same way.
|
|
||||||
pub fn close_process_details(&mut self, pid: u32) -> bool {
|
|
||||||
let was_top =
|
|
||||||
matches!(self.stack.last(), Some(ModalType::ProcessDetails { pid: p }) if *p == pid);
|
|
||||||
let before = self.stack.len();
|
|
||||||
self.stack
|
|
||||||
.retain(|m| !matches!(m, ModalType::ProcessDetails { pid: p } if *p == pid));
|
|
||||||
if self.stack.len() == before {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Mirror pop_modal's focus bookkeeping when the top changed.
|
|
||||||
if was_top && let Some(next) = self.stack.last() {
|
|
||||||
self.active_button = match next {
|
|
||||||
ModalType::ConnectionError { .. } => ModalButton::Retry,
|
|
||||||
ModalType::ProcessDetails { .. } => ModalButton::Ok,
|
|
||||||
ModalType::About => ModalButton::Ok,
|
|
||||||
ModalType::Help => ModalButton::Ok,
|
|
||||||
ModalType::Confirmation { .. } => ModalButton::Confirm,
|
|
||||||
ModalType::Info { .. } => ModalButton::Ok,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PID of the uppermost ProcessDetails view, looking through any
|
|
||||||
/// Info/Confirmation stacked above it. What the user will land on when
|
|
||||||
/// transient modals are dismissed.
|
|
||||||
pub fn topmost_process_details(&self) -> Option<u32> {
|
|
||||||
self.stack.iter().rev().find_map(|m| match m {
|
|
||||||
ModalType::ProcessDetails { pid } => Some(*pid),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_connection_error_countdown(&mut self, new_countdown: Option<u64>) {
|
pub fn update_connection_error_countdown(&mut self, new_countdown: Option<u64>) {
|
||||||
if let Some(ModalType::ConnectionError {
|
if let Some(ModalType::ConnectionError {
|
||||||
auto_retry_countdown,
|
auto_retry_countdown,
|
||||||
@@ -164,16 +110,6 @@ impl ModalManager {
|
|||||||
self.prev_button();
|
self.prev_button();
|
||||||
ModalAction::None
|
ModalAction::None
|
||||||
}
|
}
|
||||||
// Kill the process being viewed. `t` rather than `k` because `k`
|
|
||||||
// scrolls the thread table in this modal — and using the same key
|
|
||||||
// here as on the processes pane means one thing to remember.
|
|
||||||
KeyCode::Char('t') | KeyCode::Char('T') => {
|
|
||||||
if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() {
|
|
||||||
ModalAction::KillSelected(*pid)
|
|
||||||
} else {
|
|
||||||
ModalAction::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('r') | KeyCode::Char('R') => {
|
KeyCode::Char('r') | KeyCode::Char('R') => {
|
||||||
if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) {
|
if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) {
|
||||||
ModalAction::RetryConnection
|
ModalAction::RetryConnection
|
||||||
@@ -305,16 +241,7 @@ impl ModalManager {
|
|||||||
ModalAction::Dismiss
|
ModalAction::Dismiss
|
||||||
}
|
}
|
||||||
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
|
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
|
||||||
(Some(ModalType::Confirmation { .. }), ModalButton::ConfirmForce) => {
|
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
|
||||||
ModalAction::ConfirmForce
|
|
||||||
}
|
|
||||||
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => {
|
|
||||||
// Pop here so Enter-on-Cancel behaves like Esc (which pops in
|
|
||||||
// handle_key); the app's Cancel handler can then assume the
|
|
||||||
// modal is already gone.
|
|
||||||
self.pop_modal();
|
|
||||||
ModalAction::Cancel
|
|
||||||
}
|
|
||||||
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
|
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
|
||||||
self.pop_modal();
|
self.pop_modal();
|
||||||
ModalAction::Dismiss
|
ModalAction::Dismiss
|
||||||
@@ -326,29 +253,12 @@ impl ModalManager {
|
|||||||
self.active_button = match (&self.stack.last(), &self.active_button) {
|
self.active_button = match (&self.stack.last(), &self.active_button) {
|
||||||
(Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit,
|
(Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit,
|
||||||
(Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry,
|
(Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry,
|
||||||
// Confirmation cycles through three: the safe affirmative, the
|
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalButton::Cancel,
|
||||||
// escalated one, then cancel.
|
|
||||||
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => {
|
|
||||||
ModalButton::ConfirmForce
|
|
||||||
}
|
|
||||||
(Some(ModalType::Confirmation { .. }), ModalButton::ConfirmForce) => {
|
|
||||||
ModalButton::Cancel
|
|
||||||
}
|
|
||||||
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm,
|
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm,
|
||||||
_ => self.active_button.clone(),
|
_ => self.active_button.clone(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
fn prev_button(&mut self) {
|
fn prev_button(&mut self) {
|
||||||
// Confirmation has three buttons, so stepping back is not the same as
|
|
||||||
// stepping forward; everything else is a two-way toggle.
|
|
||||||
if let Some(ModalType::Confirmation { .. }) = self.stack.last() {
|
|
||||||
self.active_button = match self.active_button {
|
|
||||||
ModalButton::Confirm => ModalButton::Cancel,
|
|
||||||
ModalButton::ConfirmForce => ModalButton::Confirm,
|
|
||||||
_ => ModalButton::ConfirmForce,
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.next_button();
|
self.next_button();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,150 +280,6 @@ impl ModalManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap `text` to at most `width` columns on word boundaries, so a dialog
|
|
||||||
/// can be sized from its content instead of guessing.
|
|
||||||
fn wrap_cols(text: &str, width: u16) -> Vec<String> {
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut current = String::new();
|
|
||||||
for word in text.split_whitespace() {
|
|
||||||
let candidate = if current.is_empty() {
|
|
||||||
word.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{current} {word}")
|
|
||||||
};
|
|
||||||
if fit::cols(&candidate) <= width || current.is_empty() {
|
|
||||||
current = candidate;
|
|
||||||
} else {
|
|
||||||
lines.push(std::mem::take(&mut current));
|
|
||||||
current = word.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !current.is_empty() {
|
|
||||||
lines.push(current);
|
|
||||||
}
|
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(String::new());
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A centered box just big enough for `message` plus `footer_rows` of
|
|
||||||
/// buttons/hints. Never exceeds the screen, and never gets so narrow that
|
|
||||||
/// the title is clipped.
|
|
||||||
///
|
|
||||||
/// `min_content_w` is the width the footer needs. Sizing from the message
|
|
||||||
/// alone clipped the key-hint line, which is longer than most questions.
|
|
||||||
fn dialog_rect(area: Rect, message: &str, footer_rows: u16, min_content_w: u16) -> Rect {
|
|
||||||
// 2 border columns + 2 columns of breathing room on each side.
|
|
||||||
const CHROME_W: u16 = 6;
|
|
||||||
const MAX_TEXT_W: u16 = 64;
|
|
||||||
const MIN_TEXT_W: u16 = 24;
|
|
||||||
|
|
||||||
let avail_text = area.width.saturating_sub(CHROME_W).max(1);
|
|
||||||
let text_w = fit::cols(message)
|
|
||||||
.min(MAX_TEXT_W)
|
|
||||||
.min(avail_text)
|
|
||||||
.max(MIN_TEXT_W.min(avail_text));
|
|
||||||
let lines = Self::wrap_cols(message, text_w);
|
|
||||||
let widest = lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| fit::cols(l))
|
|
||||||
.max()
|
|
||||||
.unwrap_or(text_w)
|
|
||||||
.max(min_content_w.min(avail_text));
|
|
||||||
|
|
||||||
let width = (widest + CHROME_W).min(area.width);
|
|
||||||
// borders + blank + message + blank + footer
|
|
||||||
let height = (lines.len() as u16 + footer_rows + 4).min(area.height);
|
|
||||||
Rect {
|
|
||||||
x: area.x + (area.width.saturating_sub(width)) / 2,
|
|
||||||
y: area.y + (area.height.saturating_sub(height)) / 2,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared chrome for the small dialogs: themed border, centered message
|
|
||||||
/// with real padding, and the footer row(s) returned for the caller to
|
|
||||||
/// fill with buttons.
|
|
||||||
///
|
|
||||||
/// The old versions laid their content out over `area` rather than the
|
|
||||||
/// block's inner rect, which put the first line of text on top of the
|
|
||||||
/// border and pushed the buttons against the frame.
|
|
||||||
fn render_dialog_frame(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
title: &str,
|
|
||||||
message: &str,
|
|
||||||
footer_rows: u16,
|
|
||||||
) -> Rect {
|
|
||||||
let block = Block::default()
|
|
||||||
.title(
|
|
||||||
Line::from(format!(" {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));
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
// Pad one column each side so text never touches the border.
|
|
||||||
let padded = Rect {
|
|
||||||
x: inner.x + 1,
|
|
||||||
y: inner.y,
|
|
||||||
width: inner.width.saturating_sub(2),
|
|
||||||
height: inner.height,
|
|
||||||
};
|
|
||||||
let rows = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(1), // breathing room under the title
|
|
||||||
Constraint::Min(1), // message
|
|
||||||
Constraint::Length(1), // gap above the footer
|
|
||||||
Constraint::Length(footer_rows), // buttons / hints
|
|
||||||
])
|
|
||||||
.split(padded);
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(message)
|
|
||||||
.style(Style::default().fg(MODAL_FG))
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.wrap(Wrap { trim: true }),
|
|
||||||
rows[1],
|
|
||||||
);
|
|
||||||
rows[3]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One button, sized to its label and centered in `area`.
|
|
||||||
fn render_button(f: &mut Frame, area: Rect, label: &str, active: bool, accent: Color) {
|
|
||||||
let style = if active {
|
|
||||||
Style::default()
|
|
||||||
.bg(accent)
|
|
||||||
.fg(MODAL_BG)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(accent)
|
|
||||||
};
|
|
||||||
let text = format!(" {label} ");
|
|
||||||
let w = fit::cols(&text).min(area.width);
|
|
||||||
let btn = Rect {
|
|
||||||
x: area.x + (area.width.saturating_sub(w)) / 2,
|
|
||||||
y: area.y,
|
|
||||||
width: w,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(text)
|
|
||||||
.style(style)
|
|
||||||
.alignment(Alignment::Center),
|
|
||||||
btn,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) {
|
fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
// Different sizes for different modal types
|
// Different sizes for different modal types
|
||||||
@@ -530,13 +296,6 @@ impl ModalManager {
|
|||||||
// Help modal uses medium size
|
// Help modal uses medium size
|
||||||
self.centered_rect(70, 80, area)
|
self.centered_rect(70, 80, area)
|
||||||
}
|
}
|
||||||
// Confirmation and Info are one-question dialogs. A fixed 70%x50%
|
|
||||||
// box left a short question floating in a mostly-empty pane, so
|
|
||||||
// these size themselves to their content instead.
|
|
||||||
ModalType::Confirmation { message, .. } => {
|
|
||||||
Self::dialog_rect(area, message, 3, fit::cols(CONFIRM_HINT))
|
|
||||||
}
|
|
||||||
ModalType::Info { message, .. } => Self::dialog_rect(area, message, 1, 16),
|
|
||||||
_ => {
|
_ => {
|
||||||
// Other modals use smaller size
|
// Other modals use smaller size
|
||||||
self.centered_rect(70, 50, area)
|
self.centered_rect(70, 50, area)
|
||||||
@@ -581,64 +340,86 @@ impl ModalManager {
|
|||||||
confirm_text: &str,
|
confirm_text: &str,
|
||||||
cancel_text: &str,
|
cancel_text: &str,
|
||||||
) {
|
) {
|
||||||
// Three buttons + a key hint line.
|
let chunks = Layout::default()
|
||||||
let footer = Self::render_dialog_frame(f, area, title, message, 3);
|
|
||||||
let rows = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([Constraint::Min(1), Constraint::Length(3)])
|
||||||
Constraint::Length(1), // buttons
|
.split(area);
|
||||||
Constraint::Length(1), // spacer
|
let block = Block::default()
|
||||||
Constraint::Length(1), // key hints
|
.title(format!(" {title} "))
|
||||||
])
|
.borders(Borders::ALL)
|
||||||
.split(footer);
|
.style(Style::default().bg(Color::Black));
|
||||||
|
f.render_widget(block, area);
|
||||||
let cols = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
])
|
|
||||||
.split(rows[0]);
|
|
||||||
|
|
||||||
Self::render_button(
|
|
||||||
f,
|
|
||||||
cols[0],
|
|
||||||
confirm_text,
|
|
||||||
self.active_button == ModalButton::Confirm,
|
|
||||||
BTN_RETRY_BG_ACTIVE,
|
|
||||||
);
|
|
||||||
Self::render_button(
|
|
||||||
f,
|
|
||||||
cols[1],
|
|
||||||
"Force kill",
|
|
||||||
self.active_button == ModalButton::ConfirmForce,
|
|
||||||
MODAL_TITLE_FG,
|
|
||||||
);
|
|
||||||
Self::render_button(
|
|
||||||
f,
|
|
||||||
cols[2],
|
|
||||||
cancel_text,
|
|
||||||
self.active_button == ModalButton::Cancel,
|
|
||||||
BTN_EXIT_BG_ACTIVE,
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(CONFIRM_HINT)
|
Paragraph::new(message)
|
||||||
.style(Style::default().fg(MODAL_FG).add_modifier(Modifier::DIM))
|
.style(Style::default().fg(Color::White))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
let buttons = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(chunks[1]);
|
||||||
|
let confirm_style = if self.active_button == ModalButton::Confirm {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Green)
|
||||||
|
.fg(Color::Black)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
};
|
||||||
|
let cancel_style = if self.active_button == ModalButton::Cancel {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Red)
|
||||||
|
.fg(Color::Black)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(confirm_text)
|
||||||
|
.style(confirm_style)
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
rows[2],
|
buttons[0],
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(cancel_text)
|
||||||
|
.style(cancel_style)
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
buttons[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) {
|
fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) {
|
||||||
let footer = Self::render_dialog_frame(f, area, title, message, 1);
|
let chunks = Layout::default()
|
||||||
Self::render_button(
|
.direction(Direction::Vertical)
|
||||||
f,
|
.constraints([Constraint::Min(1), Constraint::Length(3)])
|
||||||
footer,
|
.split(area);
|
||||||
"Enter — OK",
|
let block = Block::default()
|
||||||
self.active_button == ModalButton::Ok,
|
.title(format!(" {title} "))
|
||||||
BTN_RETRY_BG_ACTIVE,
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().bg(Color::Black));
|
||||||
|
f.render_widget(block, area);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(message)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
let ok_style = if self.active_button == ModalButton::Ok {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Blue)
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("[ Enter ] OK")
|
||||||
|
.style(ok_style)
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
chunks[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,9 +505,6 @@ impl ModalManager {
|
|||||||
" ↑/↓ ............ Select/navigate processes",
|
" ↑/↓ ............ Select/navigate processes",
|
||||||
" Enter .......... Open Process Details",
|
" Enter .......... Open Process Details",
|
||||||
" x/X ............ Clear selection",
|
" x/X ............ Clear selection",
|
||||||
" t .............. Signal selected process — local agent only",
|
|
||||||
" (also works inside Process Details; the prompt",
|
|
||||||
" offers Terminate/SIGTERM or Force kill/SIGKILL)",
|
|
||||||
" Click header ... Sort by column (CPU/Mem)",
|
" Click header ... Sort by column (CPU/Mem)",
|
||||||
" Click row ...... Select process",
|
" Click row ...... Select process",
|
||||||
"",
|
"",
|
||||||
@@ -854,254 +632,3 @@ impl ModalManager {
|
|||||||
.split(vert[1])[1]
|
.split(vert[1])[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod confirm_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn confirm_modal() -> ModalManager {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::Confirmation {
|
|
||||||
title: "Confirm signal".into(),
|
|
||||||
message: "Send a signal to bash (PID 42)?".into(),
|
|
||||||
confirm_text: "Terminate".into(),
|
|
||||||
cancel_text: "Cancel".into(),
|
|
||||||
});
|
|
||||||
m
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The safe option is focused first, so a reflexive Enter terminates rather
|
|
||||||
/// than force-kills.
|
|
||||||
#[test]
|
|
||||||
fn opens_on_the_safe_option() {
|
|
||||||
let mut m = confirm_modal();
|
|
||||||
assert_eq!(m.active_button, ModalButton::Confirm);
|
|
||||||
assert_eq!(m.handle_key(KeyCode::Enter), ModalAction::Confirm);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_cycles_all_three_buttons_forward() {
|
|
||||||
let mut m = confirm_modal();
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
assert_eq!(m.active_button, ModalButton::ConfirmForce);
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
assert_eq!(m.active_button, ModalButton::Cancel);
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
assert_eq!(m.active_button, ModalButton::Confirm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With three buttons, back is not the same as forward — the old
|
|
||||||
/// prev_button just called next_button, which only worked for two.
|
|
||||||
#[test]
|
|
||||||
fn shift_tab_cycles_backward() {
|
|
||||||
let mut m = confirm_modal();
|
|
||||||
m.handle_key(KeyCode::BackTab);
|
|
||||||
assert_eq!(m.active_button, ModalButton::Cancel);
|
|
||||||
m.handle_key(KeyCode::BackTab);
|
|
||||||
assert_eq!(m.active_button, ModalButton::ConfirmForce);
|
|
||||||
m.handle_key(KeyCode::BackTab);
|
|
||||||
assert_eq!(m.active_button, ModalButton::Confirm);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn force_kill_reports_its_own_action() {
|
|
||||||
let mut m = confirm_modal();
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
assert_eq!(m.handle_key(KeyCode::Enter), ModalAction::ConfirmForce);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn escape_cancels_and_closes() {
|
|
||||||
let mut m = confirm_modal();
|
|
||||||
assert_eq!(m.handle_key(KeyCode::Esc), ModalAction::Cancel);
|
|
||||||
assert!(!m.is_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter on Cancel must behave like Esc, including closing the modal.
|
|
||||||
#[test]
|
|
||||||
fn enter_on_cancel_closes_too() {
|
|
||||||
let mut m = confirm_modal();
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
assert_eq!(m.handle_key(KeyCode::Enter), ModalAction::Cancel);
|
|
||||||
assert!(!m.is_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `t` inside process details asks the app to raise the kill prompt for the
|
|
||||||
/// process being viewed — not for whatever is selected in the list behind it.
|
|
||||||
#[test]
|
|
||||||
fn t_in_process_details_targets_that_pid() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 4242 });
|
|
||||||
assert_eq!(
|
|
||||||
m.handle_key(KeyCode::Char('t')),
|
|
||||||
ModalAction::KillSelected(4242)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `k` still scrolls the thread table, which is why `t` is the kill key.
|
|
||||||
#[test]
|
|
||||||
fn k_in_process_details_still_scrolls() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 1 });
|
|
||||||
m.thread_scroll_max = 5;
|
|
||||||
m.handle_key(KeyCode::Char('j'));
|
|
||||||
assert_eq!(m.thread_scroll_offset, 1);
|
|
||||||
assert_eq!(m.handle_key(KeyCode::Char('k')), ModalAction::Handled);
|
|
||||||
assert_eq!(m.thread_scroll_offset, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn t_elsewhere_is_not_a_kill() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::Help);
|
|
||||||
assert_eq!(m.handle_key(KeyCode::Char('t')), ModalAction::None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A one-line question must not be handed a half-screen box.
|
|
||||||
#[test]
|
|
||||||
fn dialog_is_sized_to_its_content() {
|
|
||||||
let screen = Rect::new(0, 0, 120, 40);
|
|
||||||
let r = ModalManager::dialog_rect(
|
|
||||||
screen,
|
|
||||||
"Send a signal to bash (PID 42)?",
|
|
||||||
3,
|
|
||||||
fit::cols(CONFIRM_HINT),
|
|
||||||
);
|
|
||||||
assert!(r.width < screen.width, "dialog took the full width");
|
|
||||||
assert!(r.height <= 12, "dialog was {} rows tall", r.height);
|
|
||||||
assert!(r.height >= 7, "dialog too short to hold its own footer");
|
|
||||||
// Centered to within the rounding of integer division.
|
|
||||||
let center_delta = (r.x + r.width / 2) as i32 - (screen.width / 2) as i32;
|
|
||||||
assert!(center_delta.abs() <= 1, "off-center by {center_delta}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dialog_never_exceeds_a_small_screen() {
|
|
||||||
let screen = Rect::new(0, 0, 20, 8);
|
|
||||||
let long = "a".repeat(400);
|
|
||||||
let r = ModalManager::dialog_rect(screen, &long, 3, fit::cols(CONFIRM_HINT));
|
|
||||||
assert!(r.width <= screen.width && r.height <= screen.height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod button_style_tests {
|
|
||||||
use super::*;
|
|
||||||
use ratatui::Terminal;
|
|
||||||
use ratatui::backend::TestBackend;
|
|
||||||
|
|
||||||
/// The focused button must be the highlighted one — the whole point of the
|
|
||||||
/// three-button layout is that you can see which action Enter will run.
|
|
||||||
#[test]
|
|
||||||
fn focus_moves_the_highlight() {
|
|
||||||
let msg = "Send a signal to bash (PID 42)?";
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::Confirmation {
|
|
||||||
title: "Confirm signal".into(),
|
|
||||||
message: msg.into(),
|
|
||||||
confirm_text: "Terminate".into(),
|
|
||||||
cancel_text: "Cancel".into(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Background colors present on the button row, per focused button.
|
|
||||||
let bgs = |m: &ModalManager| -> Vec<Color> {
|
|
||||||
let screen = Rect::new(0, 0, 100, 30);
|
|
||||||
let area = ModalManager::dialog_rect(screen, msg, 3, fit::cols(CONFIRM_HINT));
|
|
||||||
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
|
||||||
t.draw(|f| {
|
|
||||||
m.render_confirmation(f, area, "Confirm signal", msg, "Terminate", "Cancel")
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let buf = t.backend().buffer();
|
|
||||||
// Buttons sit on the first footer row: title, gap, message, gap.
|
|
||||||
let row = area.y + 4;
|
|
||||||
(area.x..area.x + area.width)
|
|
||||||
.map(|x| buf[(x, row)].bg)
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
let terminate_focused = bgs(&m);
|
|
||||||
assert!(
|
|
||||||
terminate_focused.contains(&BTN_RETRY_BG_ACTIVE),
|
|
||||||
"Terminate should be highlighted when focused"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!terminate_focused.contains(&BTN_EXIT_BG_ACTIVE),
|
|
||||||
"Cancel must not be highlighted while Terminate has focus"
|
|
||||||
);
|
|
||||||
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
m.handle_key(KeyCode::Tab);
|
|
||||||
let cancel_focused = bgs(&m);
|
|
||||||
assert!(
|
|
||||||
cancel_focused.contains(&BTN_EXIT_BG_ACTIVE),
|
|
||||||
"Cancel should be highlighted after two Tabs"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!cancel_focused.contains(&BTN_RETRY_BG_ACTIVE),
|
|
||||||
"Terminate must not stay highlighted"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod close_details_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closes_the_view_for_that_pid() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 4242 });
|
|
||||||
assert!(m.close_process_details(4242));
|
|
||||||
assert!(!m.is_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaves_a_different_pid_alone() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 4242 });
|
|
||||||
assert!(!m.close_process_details(1));
|
|
||||||
assert!(m.is_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walking up to a parent stacks details views. Only the dead process's
|
|
||||||
/// view goes — whichever position it holds — and the survivor stays put.
|
|
||||||
#[test]
|
|
||||||
fn closes_only_the_dead_pids_view_in_a_parent_chain() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 100 }); // parent
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 200 }); // child, on top
|
|
||||||
|
|
||||||
// Parent dies while the child is viewed: its view is removed from
|
|
||||||
// UNDER the top, so closing the child later lands on the process list
|
|
||||||
// instead of a frozen corpse view.
|
|
||||||
assert!(m.close_process_details(100));
|
|
||||||
assert!(matches!(
|
|
||||||
m.current_modal(),
|
|
||||||
Some(ModalType::ProcessDetails { pid: 200 })
|
|
||||||
));
|
|
||||||
assert!(m.close_process_details(200));
|
|
||||||
assert!(!m.is_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The F1 regression: killing from inside the details view stacks the
|
|
||||||
/// "Signal sent" Info on top, and the death is usually confirmed while
|
|
||||||
/// that Info is still up. The details view must close anyway — a top-only
|
|
||||||
/// check left it open forever, frozen on the dead process.
|
|
||||||
#[test]
|
|
||||||
fn closes_details_beneath_a_stacked_info_modal() {
|
|
||||||
let mut m = ModalManager::new();
|
|
||||||
m.push_modal(ModalType::ProcessDetails { pid: 7 });
|
|
||||||
m.push_modal(ModalType::Info {
|
|
||||||
title: "Signal sent".into(),
|
|
||||||
message: "Sent SIGKILL".into(),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(m.close_process_details(7));
|
|
||||||
// The Info survives on top; dismissing it lands on the process list.
|
|
||||||
assert!(matches!(m.current_modal(), Some(ModalType::Info { .. })));
|
|
||||||
m.pop_modal();
|
|
||||||
assert!(!m.is_active());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,12 +41,11 @@ impl ModalManager {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(
|
.title(ICON_WARNING_TITLE)
|
||||||
Line::from(ICON_WARNING_TITLE).style(
|
.title_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(MODAL_TITLE_FG)
|
.fg(MODAL_TITLE_FG)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ impl ModalManager {
|
|||||||
main_chunks[0],
|
main_chunks[0],
|
||||||
&details.process,
|
&details.process,
|
||||||
data.history.cpu,
|
data.history.cpu,
|
||||||
data.history.cpu_sum,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
||||||
@@ -95,7 +94,7 @@ impl ModalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Help line
|
// Help line
|
||||||
let mut help_text = vec![Line::from(vec![
|
let help_text = vec![Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"X ",
|
"X ",
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -129,23 +128,6 @@ impl ModalManager {
|
|||||||
Span::styled("journal", Style::default().add_modifier(Modifier::DIM)),
|
Span::styled("journal", Style::default().add_modifier(Modifier::DIM)),
|
||||||
])];
|
])];
|
||||||
|
|
||||||
// Kill from here too — same key as the processes pane, and only shown
|
|
||||||
// when the kill feature is enabled (agent local, no policy override).
|
|
||||||
if data.kill_enabled
|
|
||||||
&& let Some(line) = help_text.first_mut()
|
|
||||||
{
|
|
||||||
line.spans.push(Span::styled(
|
|
||||||
" t ",
|
|
||||||
Style::default()
|
|
||||||
.fg(PROCESS_DETAILS_ACCENT)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
));
|
|
||||||
line.spans.push(Span::styled(
|
|
||||||
"kill",
|
|
||||||
Style::default().add_modifier(Modifier::DIM),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let help = Paragraph::new(Text::from(help_text))
|
let help = Paragraph::new(Text::from(help_text))
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.style(Style::default());
|
.style(Style::default());
|
||||||
@@ -437,7 +419,7 @@ impl ModalManager {
|
|||||||
)
|
)
|
||||||
.header(header)
|
.header(header)
|
||||||
.block(block)
|
.block(block)
|
||||||
.row_highlight_style(Style::default());
|
.highlight_style(Style::default());
|
||||||
|
|
||||||
f.render_widget(table, area);
|
f.render_widget(table, area);
|
||||||
|
|
||||||
@@ -489,28 +471,13 @@ impl ModalManager {
|
|||||||
.borders(Borders::ALL);
|
.borders(Borders::ALL);
|
||||||
|
|
||||||
let content_lines: Vec<Line> = if journal.entries.is_empty() {
|
let content_lines: Vec<Line> = if journal.entries.is_empty() {
|
||||||
let mut lines = vec![
|
vec![
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
"No journal entries found for this process",
|
"No journal entries found for this process",
|
||||||
Style::default().add_modifier(Modifier::DIM),
|
Style::default().add_modifier(Modifier::DIM),
|
||||||
)),
|
)),
|
||||||
];
|
]
|
||||||
// Access limits, not absence of logs: show journalctl's own hint
|
|
||||||
// (typical when the agent runs as an unprivileged user, e.g. demo
|
|
||||||
// mode) plus the practical fix.
|
|
||||||
if let Some(notice) = &journal.notice {
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
format!("⚠ {notice}"),
|
|
||||||
Style::default().fg(Color::Yellow),
|
|
||||||
)));
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
" Run the agent as a service (or a user in the systemd-journal group) for full journal access.",
|
|
||||||
Style::default().add_modifier(Modifier::DIM),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
} else {
|
} else {
|
||||||
journal
|
journal
|
||||||
.entries
|
.entries
|
||||||
@@ -597,13 +564,8 @@ impl ModalManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation
|
// Create a 2D grid to represent the plot
|
||||||
// instead of `plot_height` inner Vec<char>s like the old version did.
|
let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
|
||||||
let mut plot_grid: Vec<char> = vec![' '; plot_width * plot_height];
|
|
||||||
let cell = |grid: &[char], x: usize, y: usize| grid[y * plot_width + x];
|
|
||||||
let put = |grid: &mut [char], x: usize, y: usize, ch: char| {
|
|
||||||
grid[y * plot_width + x] = ch;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Plot main process
|
// Plot main process
|
||||||
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
|
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
|
||||||
@@ -611,7 +573,7 @@ impl ModalManager {
|
|||||||
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
|
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
|
||||||
);
|
);
|
||||||
if main_x < plot_width && main_y < plot_height {
|
if main_x < plot_width && main_y < plot_height {
|
||||||
put(&mut plot_grid, main_x, main_y, '●');
|
plot_grid[main_y][main_x] = '●'; // Main process marker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plot threads (use different marker)
|
// Plot threads (use different marker)
|
||||||
@@ -625,13 +587,13 @@ impl ModalManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if thread_x < plot_width && thread_y < plot_height {
|
if thread_x < plot_width && thread_y < plot_height {
|
||||||
let ch = cell(&plot_grid, thread_x, thread_y);
|
if plot_grid[thread_y][thread_x] == ' ' {
|
||||||
let next = match ch {
|
plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle)
|
||||||
' ' => '○',
|
} else if plot_grid[thread_y][thread_x] == '○' {
|
||||||
'○' => '◎',
|
plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point
|
||||||
_ => '◉',
|
} else {
|
||||||
};
|
plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point
|
||||||
put(&mut plot_grid, thread_x, thread_y, next);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,47 +608,28 @@ impl ModalManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if child_x < plot_width && child_y < plot_height {
|
if child_x < plot_width && child_y < plot_height {
|
||||||
let ch = cell(&plot_grid, child_x, child_y);
|
if plot_grid[child_y][child_x] == ' ' {
|
||||||
let next = if ch == ' ' { '•' } else { '◉' };
|
plot_grid[child_y][child_x] = '•'; // Child process marker
|
||||||
put(&mut plot_grid, child_x, child_y, next);
|
} else {
|
||||||
|
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
|
// Render the plot
|
||||||
// labels + axis title + (top) Y-axis title + legend + spacing.
|
let mut lines = Vec::new();
|
||||||
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
|
|
||||||
|
|
||||||
// Format a CPU-time value: whole ms once past 100, one decimal below.
|
// Add Y-axis labels and plot content
|
||||||
let fmt_ms = |v: f64| {
|
for (i, row) in plot_grid.iter().enumerate() {
|
||||||
if v >= 100.0 {
|
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
|
||||||
format!("{v:.0}")
|
// Always format with 4 characters width, right-aligned, to prevent axis shifting
|
||||||
|
let y_label = if y_value >= 100.0 {
|
||||||
|
format!("{y_value:>4.0}")
|
||||||
} else {
|
} else {
|
||||||
format!("{v:.1}")
|
format!("{y_value:>4.1}")
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Y-axis labels, right-aligned to the widest value this frame so the
|
let plot_content: String = row.iter().collect();
|
||||||
// axis stays a straight line. The old fixed 4-char field predates the
|
|
||||||
// CPU-time unit fix; honest millisecond values (e.g. 136114) blew
|
|
||||||
// through it and skewed the whole axis.
|
|
||||||
let y_values: Vec<String> = (0..plot_height)
|
|
||||||
.map(|y| {
|
|
||||||
fmt_ms(params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64)))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let y_label_w = y_values.iter().map(|s| s.len()).max().unwrap_or(4).max(4);
|
|
||||||
|
|
||||||
let mut row_buf = String::with_capacity(plot_width);
|
|
||||||
for (y, y_value) in y_values.iter().enumerate() {
|
|
||||||
let y_label = format!("{y_value:>y_label_w$}");
|
|
||||||
|
|
||||||
// Build the row's char slice into a reusable String buffer.
|
|
||||||
row_buf.clear();
|
|
||||||
let start = y * plot_width;
|
|
||||||
row_buf.extend(plot_grid[start..start + plot_width].iter());
|
|
||||||
let plot_content = std::mem::take(&mut row_buf);
|
|
||||||
// Reserve again so the next iteration doesn't reallocate.
|
|
||||||
row_buf.reserve(plot_width);
|
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(y_label, Style::default()),
|
Span::styled(y_label, Style::default()),
|
||||||
@@ -695,8 +638,8 @@ impl ModalManager {
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add X-axis (padding = Y label width + the space before the bar)
|
// Add X-axis
|
||||||
let x_axis_padding = " ".repeat(y_label_w + 1);
|
let x_axis_padding = " ".to_string(); // Match Y-axis label width
|
||||||
let x_axis_line = "─".repeat(plot_width + 1);
|
let x_axis_line = "─".repeat(plot_width + 1);
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(x_axis_padding, Style::default()),
|
Span::styled(x_axis_padding, Style::default()),
|
||||||
@@ -704,14 +647,13 @@ impl ModalManager {
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
// Add X-axis labels
|
// Add X-axis labels
|
||||||
let x_label_start = fmt_ms(0.0);
|
let x_label_start = "0.0".to_string();
|
||||||
let x_label_mid = fmt_ms(params.max_user / 2.0);
|
let x_label_mid = format!("{:.1}", params.max_user / 2.0);
|
||||||
let x_label_end = fmt_ms(params.max_user);
|
let x_label_end = format!("{:.1}", params.max_user);
|
||||||
|
|
||||||
let spacing = plot_width / 3;
|
let spacing = plot_width / 3;
|
||||||
let x_labels = format!(
|
let x_labels = format!(
|
||||||
"{}{}{}{}{}{}",
|
" {}{}{}{}{}",
|
||||||
" ".repeat(y_label_w + 1),
|
|
||||||
x_label_start,
|
x_label_start,
|
||||||
" ".repeat(spacing.saturating_sub(x_label_start.len())),
|
" ".repeat(spacing.saturating_sub(x_label_start.len())),
|
||||||
x_label_mid,
|
x_label_mid,
|
||||||
@@ -723,7 +665,7 @@ impl ModalManager {
|
|||||||
|
|
||||||
// Add axis titles with better visibility
|
// Add axis titles with better visibility
|
||||||
lines.push(Line::from(vec![Span::styled(
|
lines.push(Line::from(vec![Span::styled(
|
||||||
format!("{}User CPU Time (ms) →", " ".repeat(y_label_w + 1)),
|
" User CPU Time (ms) →",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -891,7 +833,6 @@ impl ModalManager {
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
process: &socktop_connector::DetailedProcessInfo,
|
process: &socktop_connector::DetailedProcessInfo,
|
||||||
cpu_history: &std::collections::VecDeque<f32>,
|
cpu_history: &std::collections::VecDeque<f32>,
|
||||||
cpu_history_sum: f32,
|
|
||||||
) {
|
) {
|
||||||
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
||||||
let top_chunks = Layout::default()
|
let top_chunks = Layout::default()
|
||||||
@@ -902,7 +843,7 @@ impl ModalManager {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum);
|
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history);
|
||||||
self.render_thread_scatter_plot(f, top_chunks[1], process);
|
self.render_thread_scatter_plot(f, top_chunks[1], process);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,7 +853,6 @@ impl ModalManager {
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
process: &socktop_connector::DetailedProcessInfo,
|
process: &socktop_connector::DetailedProcessInfo,
|
||||||
cpu_history: &std::collections::VecDeque<f32>,
|
cpu_history: &std::collections::VecDeque<f32>,
|
||||||
cpu_history_sum: f32,
|
|
||||||
) {
|
) {
|
||||||
// Normalize CPU to 0-100% by dividing by thread count
|
// Normalize CPU to 0-100% by dividing by thread count
|
||||||
// This shows per-core utilization rather than total utilization across all cores
|
// This shows per-core utilization rather than total utilization across all cores
|
||||||
@@ -924,7 +864,8 @@ impl ModalManager {
|
|||||||
let avg_cpu = if cpu_history.is_empty() {
|
let avg_cpu = if cpu_history.is_empty() {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count)
|
let total: f32 = cpu_history.iter().sum();
|
||||||
|
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
|
||||||
};
|
};
|
||||||
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
|
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ use std::time::Instant;
|
|||||||
/// History data for process metrics rendering
|
/// History data for process metrics rendering
|
||||||
pub struct ProcessHistoryData<'a> {
|
pub struct ProcessHistoryData<'a> {
|
||||||
pub cpu: &'a std::collections::VecDeque<f32>,
|
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 mem: &'a std::collections::VecDeque<u64>,
|
||||||
pub io_read: &'a std::collections::VecDeque<u64>,
|
pub io_read: &'a std::collections::VecDeque<u64>,
|
||||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||||
@@ -19,10 +17,6 @@ pub struct ProcessModalData<'a> {
|
|||||||
pub history: ProcessHistoryData<'a>,
|
pub history: ProcessHistoryData<'a>,
|
||||||
pub max_mem_bytes: u64,
|
pub max_mem_bytes: u64,
|
||||||
pub unsupported: bool,
|
pub unsupported: bool,
|
||||||
/// Whether the process-kill feature is available (agent local, no policy
|
|
||||||
/// override). Only used to decide whether the `t` kill hint is shown —
|
|
||||||
/// the kill itself is gated in `App`.
|
|
||||||
pub kill_enabled: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for rendering scatter plot
|
/// Parameters for rendering scatter plot
|
||||||
@@ -68,15 +62,9 @@ pub enum ModalAction {
|
|||||||
RetryConnection,
|
RetryConnection,
|
||||||
ExitApp,
|
ExitApp,
|
||||||
Confirm,
|
Confirm,
|
||||||
/// Confirmation modal's second affirmative: the same action, escalated.
|
|
||||||
/// Used by the kill prompt for SIGKILL, where `Confirm` means SIGTERM.
|
|
||||||
ConfirmForce,
|
|
||||||
Cancel,
|
Cancel,
|
||||||
Dismiss,
|
Dismiss,
|
||||||
SwitchToParentProcess(u32), // Switch to viewing parent process details
|
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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -84,10 +72,6 @@ pub enum ModalButton {
|
|||||||
Retry,
|
Retry,
|
||||||
Exit,
|
Exit,
|
||||||
Confirm,
|
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,
|
Cancel,
|
||||||
Ok,
|
Ok,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
|
|||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
title: &str,
|
title: &str,
|
||||||
hist: &mut VecDeque<u64>,
|
hist: &VecDeque<u64>,
|
||||||
color: Color,
|
color: Color,
|
||||||
) {
|
) {
|
||||||
let max_points = area.width.saturating_sub(2) as usize;
|
let max_points = area.width.saturating_sub(2) as usize;
|
||||||
let start = hist.len().saturating_sub(max_points);
|
let start = hist.len().saturating_sub(max_points);
|
||||||
let slice = &hist.make_contiguous()[start..];
|
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||||
|
|
||||||
let spark = Sparkline::default()
|
let spark = Sparkline::default()
|
||||||
.block(
|
.block(
|
||||||
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
|
|||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(title.to_string()),
|
.title(title.to_string()),
|
||||||
)
|
)
|
||||||
.data(slice)
|
.data(&data)
|
||||||
.style(Style::default().fg(color));
|
.style(Style::default().fg(color));
|
||||||
f.render_widget(spark, area);
|
f.render_widget(spark, area);
|
||||||
}
|
}
|
||||||
|
|||||||
+272
-776
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,19 @@ pub fn human(b: u64) -> String {
|
|||||||
format!("{tb:.2}TB")
|
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 {
|
pub fn disk_icon(name: &str) -> &'static str {
|
||||||
let n = name.to_ascii_lowercase();
|
let n = name.to_ascii_lowercase();
|
||||||
if n.contains(':') {
|
if n.contains(':') {
|
||||||
|
|||||||
@@ -73,112 +73,3 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
|
|||||||
);
|
);
|
||||||
assert!(text3.contains("Usage:"));
|
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_kill_flag_documented_and_accepted() {
|
|
||||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
|
||||||
let out = Command::new(exe)
|
|
||||||
.args(["--no-kill", "--help"])
|
|
||||||
.output()
|
|
||||||
.expect("run socktop --no-kill --help");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"socktop --no-kill --help did not succeed"
|
|
||||||
);
|
|
||||||
let text = format!(
|
|
||||||
"{}{}",
|
|
||||||
String::from_utf8_lossy(&out.stdout),
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
text.contains("--no-kill"),
|
|
||||||
"help text missing --no-kill\n{text}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The flag must not be mistaken for the positional URL argument.
|
|
||||||
let out2 = Command::new(exe)
|
|
||||||
.args(["--no-kill", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
|
||||||
.output()
|
|
||||||
.expect("run socktop --no-kill --dry-run");
|
|
||||||
assert!(
|
|
||||||
out2.status.success(),
|
|
||||||
"socktop --no-kill with a URL was rejected: {}",
|
|
||||||
String::from_utf8_lossy(&out2.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_kill_env_var_accepted() {
|
|
||||||
// SOCKTOP_NO_KILL must not break startup — the env-only path is how the
|
|
||||||
// webterm deployment disables the kill feature for every invocation.
|
|
||||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
|
||||||
let out = Command::new(exe)
|
|
||||||
.env("SOCKTOP_NO_KILL", "1")
|
|
||||||
.args(["--dry-run", "ws://127.0.0.1:3000/ws"])
|
|
||||||
.output()
|
|
||||||
.expect("run socktop with SOCKTOP_NO_KILL=1");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"socktop with SOCKTOP_NO_KILL=1 did not succeed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_unknown_option_rejected_not_treated_as_url() {
|
|
||||||
// Regression guard for the socktop.io incident (Aug 2026): socktop 1.60.1
|
|
||||||
// parsed the then-unknown --no-kill flag as the positional websocket URL,
|
|
||||||
// which made it prompt to overwrite the 'local' profile's URL with the
|
|
||||||
// literal string "--no-kill". Unknown options must fail loudly instead of
|
|
||||||
// falling through to the URL slot.
|
|
||||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
|
||||||
let out = Command::new(exe)
|
|
||||||
.args(["--not-a-real-flag", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
|
||||||
.output()
|
|
||||||
.expect("run socktop with unknown flag");
|
|
||||||
assert_eq!(
|
|
||||||
out.status.code(),
|
|
||||||
Some(2),
|
|
||||||
"unknown option should exit 2, got: {:?}\nstderr: {}",
|
|
||||||
out.status.code(),
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
|
||||||
assert!(
|
|
||||||
err.contains("Unknown option '--not-a-real-flag'"),
|
|
||||||
"stderr should name the rejected option\n{err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|||||||
#[allow(dead_code)] // touch crate
|
#[allow(dead_code)] // touch crate
|
||||||
fn touch() {
|
fn touch() {
|
||||||
let _ = socktop::types::Metrics {
|
let _ = socktop::types::Metrics {
|
||||||
sampled_at_ms: None,
|
|
||||||
cpu_total: 0.0,
|
cpu_total: 0.0,
|
||||||
cpu_per_core: vec![],
|
cpu_per_core: vec![],
|
||||||
mem_total: 0,
|
mem_total: 0,
|
||||||
|
|||||||
+14
-33
@@ -1,19 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "socktop_agent"
|
name = "socktop_agent"
|
||||||
version = "1.60.2"
|
version = "1.50.1"
|
||||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||||
description = "Socktop agent daemon. Serves host metrics over WebSocket."
|
description = "Socktop agent daemon. Serves host metrics over WebSocket."
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
homepage = "https://github.com/jasonwitty/socktop"
|
|
||||||
repository = "https://github.com/jasonwitty/socktop"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Tokio: minimal features instead of "full" to reduce binary size.
|
# CLI parsing and man page generation
|
||||||
# rt-multi-thread (runtime), net (WebSocket), sync (Mutex/oneshot),
|
clap = { version = "4.5", features = ["derive", "cargo", "wrap_help", "env"] }
|
||||||
# macros (#[tokio::test]), process (async journalctl).
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "process"] }
|
# Tokio: Use minimal features instead of "full" to reduce binary size
|
||||||
|
# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test])
|
||||||
|
# Excluded: io, fs, process, signal, time (not needed for this workload)
|
||||||
|
# Savings: ~200-300KB binary size, faster compile times
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||||
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
|
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -22,14 +24,10 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
|||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
tracing = { version = "0.1", optional = true }
|
tracing = { version = "0.1", optional = true }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||||
gfxinfo = { version = "0.1.2", optional = true }
|
gfxinfo = "0.1.2"
|
||||||
# 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"
|
once_cell = "1.19"
|
||||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
rustls = "0.23"
|
||||||
rustls-pemfile = "2.1"
|
rustls-pemfile = "2.1"
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -38,11 +36,12 @@ prost = { workspace = true }
|
|||||||
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
|
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["gpu"]
|
default = []
|
||||||
gpu = ["gfxinfo", "nvml-wrapper"]
|
|
||||||
logging = ["tracing", "tracing-subscriber"]
|
logging = ["tracing", "tracing-subscriber"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive", "cargo", "env"] }
|
||||||
|
clap_mangen = "0.2"
|
||||||
prost-build = "0.13"
|
prost-build = "0.13"
|
||||||
tonic-build = { version = "0.12", default-features = false, optional = true }
|
tonic-build = { version = "0.12", default-features = false, optional = true }
|
||||||
protoc-bin-vendored = "3"
|
protoc-bin-vendored = "3"
|
||||||
@@ -51,21 +50,3 @@ protoc-bin-vendored = "3"
|
|||||||
assert_cmd = "2.0"
|
assert_cmd = "2.0"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
tokio-tungstenite = "0.21"
|
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 }
|
|
||||||
|
|||||||
+36
-6
@@ -1,16 +1,46 @@
|
|||||||
|
use clap::CommandFactory;
|
||||||
|
use clap_mangen::Man;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
include!("src/cli.rs");
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Vendored protoc for reproducible builds
|
||||||
|
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=proto/processes.proto");
|
println!("cargo:rerun-if-changed=proto/processes.proto");
|
||||||
|
println!("cargo:rerun-if-changed=src/cli.rs");
|
||||||
|
|
||||||
// Compile protobuf definitions for processes
|
// Compile protobuf definitions for processes
|
||||||
let mut cfg = prost_build::Config::new();
|
let mut cfg = prost_build::Config::new();
|
||||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||||
// Vendored protoc for reproducible builds where available. It ships no
|
cfg.protoc_executable(protoc); // Use the vendored protoc directly
|
||||||
// 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)
|
// Use local path (ensures file is inside published crate tarball)
|
||||||
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
|
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
|
||||||
.expect("compile protos");
|
.expect("compile protos");
|
||||||
|
|
||||||
|
// Generate man page
|
||||||
|
generate_man_page().expect("man page generation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_man_page() -> std::io::Result<()> {
|
||||||
|
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||||
|
let man_dir = out_dir.join("man");
|
||||||
|
fs::create_dir_all(&man_dir)?;
|
||||||
|
|
||||||
|
// Generate man page for socktop_agent
|
||||||
|
let cmd = Cli::command();
|
||||||
|
let man = Man::new(cmd);
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
man.render(&mut buffer)?;
|
||||||
|
|
||||||
|
fs::write(man_dir.join("socktop_agent.1"), buffer)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"cargo:warning=Man page generated at {:?}",
|
||||||
|
man_dir.join("socktop_agent.1")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[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,95 @@
|
|||||||
|
//! Caching for process metrics and journal entries
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::types::{ProcessMetricsResponse, JournalResponse};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CacheEntry<T> {
|
||||||
|
data: T,
|
||||||
|
cached_at: Instant,
|
||||||
|
ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> CacheEntry<T> {
|
||||||
|
fn is_expired(&self) -> bool {
|
||||||
|
self.cached_at.elapsed() > self.ttl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProcessCache {
|
||||||
|
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
|
||||||
|
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
process_metrics: RwLock::new(HashMap::new()),
|
||||||
|
journal_entries: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached process metrics if available and not expired (250ms TTL)
|
||||||
|
pub async fn get_process_metrics(&self, pid: u32) -> Option<ProcessMetricsResponse> {
|
||||||
|
let cache = self.process_metrics.read().await;
|
||||||
|
if let Some(entry) = cache.get(&pid) {
|
||||||
|
if !entry.is_expired() {
|
||||||
|
return Some(entry.data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache process metrics with 250ms TTL
|
||||||
|
pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) {
|
||||||
|
let mut cache = self.process_metrics.write().await;
|
||||||
|
cache.insert(pid, CacheEntry {
|
||||||
|
data,
|
||||||
|
cached_at: Instant::now(),
|
||||||
|
ttl: Duration::from_millis(250),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached journal entries if available and not expired (1s TTL)
|
||||||
|
pub async fn get_journal_entries(&self, pid: u32) -> Option<JournalResponse> {
|
||||||
|
let cache = self.journal_entries.read().await;
|
||||||
|
if let Some(entry) = cache.get(&pid) {
|
||||||
|
if !entry.is_expired() {
|
||||||
|
return Some(entry.data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache journal entries with 1s TTL
|
||||||
|
pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) {
|
||||||
|
let mut cache = self.journal_entries.write().await;
|
||||||
|
cache.insert(pid, CacheEntry {
|
||||||
|
data,
|
||||||
|
cached_at: Instant::now(),
|
||||||
|
ttl: Duration::from_secs(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired entries periodically
|
||||||
|
pub async fn cleanup_expired(&self) {
|
||||||
|
{
|
||||||
|
let mut cache = self.process_metrics.write().await;
|
||||||
|
cache.retain(|_, entry| !entry.is_expired());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut cache = self.journal_entries.write().await;
|
||||||
|
cache.retain(|_, entry| !entry.is_expired());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProcessCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// CLI argument definitions for socktop_agent using clap derive macros.
|
||||||
|
// This file is also included by build.rs for man page generation.
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "socktop_agent",
|
||||||
|
version,
|
||||||
|
author,
|
||||||
|
about = "Lightweight WebSocket server for remote system monitoring",
|
||||||
|
long_about = "socktop_agent is a lightweight Rust-based WebSocket server that provides system \
|
||||||
|
metrics on demand. It serves metrics to socktop clients over WebSocket connections \
|
||||||
|
at the /ws endpoint.\n\n\
|
||||||
|
The agent is request-driven with near-zero CPU usage when idle. It collects metrics \
|
||||||
|
only when clients request them over WebSocket, eliminating the need for background \
|
||||||
|
sampling loops. This design results in minimal resource consumption, making it ideal \
|
||||||
|
for resource-constrained systems like Raspberry Pi.\n\n\
|
||||||
|
Metrics include: CPU (overall and per-core), memory, swap, disk usage, network \
|
||||||
|
throughput, CPU temperatures, top processes, and optional GPU metrics."
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Port number to listen on
|
||||||
|
///
|
||||||
|
/// Default is 3000 for non-TLS mode and 8443 for TLS mode.
|
||||||
|
/// Can also be set via SOCKTOP_PORT environment variable.
|
||||||
|
#[arg(short = 'p', long = "port", value_name = "PORT", env = "SOCKTOP_PORT")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
|
||||||
|
/// Enable TLS (secure WebSocket) mode
|
||||||
|
///
|
||||||
|
/// The agent will listen on wss:// instead of ws://.
|
||||||
|
/// On first run with TLS enabled, the agent automatically generates
|
||||||
|
/// a self-signed certificate and private key.
|
||||||
|
/// Can also be enabled via SOCKTOP_ENABLE_SSL=1 environment variable.
|
||||||
|
#[arg(long = "enableSSL", env = "SOCKTOP_ENABLE_SSL", value_parser = parse_bool_env)]
|
||||||
|
pub enable_ssl: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse boolean from environment variable (accepts "1" or "true")
|
||||||
|
fn parse_bool_env(s: &str) -> Result<bool, String> {
|
||||||
|
match s {
|
||||||
|
"1" | "true" | "TRUE" | "True" => Ok(true),
|
||||||
|
"0" | "false" | "FALSE" | "False" => Ok(false),
|
||||||
|
_ => Err(format!("Invalid boolean value: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
/// Parse CLI arguments from environment
|
||||||
|
pub fn parse_args() -> Self {
|
||||||
|
Cli::parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the port to listen on, with appropriate defaults
|
||||||
|
pub fn get_port(&self) -> u16 {
|
||||||
|
if let Some(port) = self.port {
|
||||||
|
port
|
||||||
|
} else if self.enable_ssl {
|
||||||
|
8443
|
||||||
|
} else {
|
||||||
|
3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop_agent"]).unwrap();
|
||||||
|
assert_eq!(cli.port, None);
|
||||||
|
assert!(!cli.enable_ssl);
|
||||||
|
assert_eq!(cli.get_port(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_port() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop_agent", "--port", "8080"]).unwrap();
|
||||||
|
assert_eq!(cli.port, Some(8080));
|
||||||
|
assert_eq!(cli.get_port(), 8080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_port() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop_agent", "-p", "9000"]).unwrap();
|
||||||
|
assert_eq!(cli.port, Some(9000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enable_ssl() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop_agent", "--enableSSL"]).unwrap();
|
||||||
|
assert!(cli.enable_ssl);
|
||||||
|
assert_eq!(cli.get_port(), 8443); // Default TLS port
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ssl_with_custom_port() {
|
||||||
|
let cli = Cli::try_parse_from(&["socktop_agent", "--enableSSL", "-p", "9443"]).unwrap();
|
||||||
|
assert!(cli.enable_ssl);
|
||||||
|
assert_eq!(cli.get_port(), 9443);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-114
@@ -1,4 +1,5 @@
|
|||||||
// gpu.rs
|
// gpu.rs
|
||||||
|
use gfxinfo::active_gpu;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct GpuMetrics {
|
pub struct GpuMetrics {
|
||||||
@@ -8,118 +9,16 @@ pub struct GpuMetrics {
|
|||||||
pub mem_total_bytes: u64,
|
pub mem_total_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect metrics for the active GPU. `None` when there is no usable GPU.
|
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
|
||||||
///
|
let gpu = active_gpu()?; // Use ? to unwrap Result
|
||||||
/// Runs on a dedicated worker thread (see `worker`): gfxinfo's handle holds
|
let info = gpu.info();
|
||||||
/// 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
|
let metrics = GpuMetrics {
|
||||||
/// for every collection. The worker owns one handle for the process lifetime,
|
name: gpu.model().to_string(),
|
||||||
/// so steady-state collection is just NVML queries. Measured on an RTX 5080
|
utilization_gpu_pct: info.load_pct() as u32,
|
||||||
/// box, re-initing per collect was ~80% of the agent's entire active CPU.
|
mem_used_bytes: info.used_vram(),
|
||||||
#[cfg(feature = "gpu")]
|
mem_total_bytes: info.total_vram(),
|
||||||
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
|
};
|
||||||
worker::collect().await
|
|
||||||
}
|
Ok(vec![metrics])
|
||||||
|
|
||||||
#[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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
|
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
|
||||||
|
|
||||||
|
mod cli;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod proto;
|
mod proto;
|
||||||
@@ -14,28 +15,10 @@ use std::str::FromStr;
|
|||||||
|
|
||||||
mod tls;
|
mod tls;
|
||||||
|
|
||||||
|
use cli::Cli;
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
fn arg_flag(name: &str) -> bool {
|
|
||||||
std::env::args().any(|a| a == name)
|
|
||||||
}
|
|
||||||
fn arg_value(name: &str) -> Option<String> {
|
|
||||||
let mut it = std::env::args();
|
|
||||||
while let Some(a) = it.next() {
|
|
||||||
if a == name {
|
|
||||||
return it.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
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
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
@@ -76,11 +59,8 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn async_main() -> anyhow::Result<()> {
|
async fn async_main() -> anyhow::Result<()> {
|
||||||
// Version flag (print and exit). Keep before heavy initialization.
|
// Parse CLI arguments
|
||||||
if arg_flag("--version") || arg_flag("-V") {
|
let cli = Cli::parse_args();
|
||||||
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = AppState::new();
|
let state = AppState::new();
|
||||||
|
|
||||||
@@ -96,15 +76,8 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|
||||||
let enable_ssl =
|
if cli.enable_ssl {
|
||||||
arg_flag("--enableSSL") || std::env::var("SOCKTOP_ENABLE_SSL").ok().as_deref() == Some("1");
|
let port = cli.get_port();
|
||||||
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 (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 cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
|
||||||
@@ -118,11 +91,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-TLS HTTP/WS path
|
// Non-TLS HTTP/WS path
|
||||||
let port = arg_value("--port")
|
let port = cli.get_port();
|
||||||
.or_else(|| arg_value("-p"))
|
|
||||||
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
|
|
||||||
.and_then(|s| s.parse::<u16>().ok())
|
|
||||||
.unwrap_or(3000);
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
println!("socktop_agent: Listening on ws://{addr}/ws");
|
println!("socktop_agent: Listening on ws://{addr}/ws");
|
||||||
axum_server::bind(addr)
|
axum_server::bind(addr)
|
||||||
|
|||||||
+427
-542
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,6 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
|
|||||||
pub struct ProcCpuTracker {
|
pub struct ProcCpuTracker {
|
||||||
pub last_total: u64,
|
pub last_total: u64,
|
||||||
pub last_per_pid: HashMap<u32, 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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
@@ -74,55 +70,6 @@ pub struct AppState {
|
|||||||
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CacheEntry<T> {
|
pub struct CacheEntry<T> {
|
||||||
pub at: Option<Instant>,
|
pub at: Option<Instant>,
|
||||||
@@ -136,7 +83,7 @@ impl<T> Default for CacheEntry<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T> CacheEntry<T> {
|
impl<T> CacheEntry<T> {
|
||||||
pub const fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
at: None,
|
at: None,
|
||||||
value: None,
|
value: None,
|
||||||
|
|||||||
@@ -24,17 +24,6 @@ pub fn cert_paths() -> (PathBuf, PathBuf) {
|
|||||||
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
||||||
let (cert_path, key_path) = cert_paths();
|
let (cert_path, key_path) = cert_paths();
|
||||||
if cert_path.exists() && key_path.exists() {
|
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));
|
return Ok((cert_path, key_path));
|
||||||
}
|
}
|
||||||
fs::create_dir_all(cert_path.parent().unwrap())?;
|
fs::create_dir_all(cert_path.parent().unwrap())?;
|
||||||
@@ -90,16 +79,7 @@ pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
|||||||
|
|
||||||
let mut f = fs::File::create(&cert_path)?;
|
let mut f = fs::File::create(&cert_path)?;
|
||||||
f.write_all(cert_pem.as_bytes())?;
|
f.write_all(cert_pem.as_bytes())?;
|
||||||
// The private key must not be world-readable (File::create honors the
|
let mut k = fs::File::create(&key_path)?;
|
||||||
// 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())?;
|
k.write_all(key_pem.as_bytes())?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ pub struct ProcessInfo {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct Metrics {
|
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_total: f32,
|
||||||
pub cpu_per_core: Vec<f32>,
|
pub cpu_per_core: Vec<f32>,
|
||||||
pub mem_total: u64,
|
pub mem_total: u64,
|
||||||
@@ -98,8 +93,7 @@ pub struct ProcessMetricsResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct JournalEntry {
|
pub struct JournalEntry {
|
||||||
pub timestamp: String, // RFC 3339 UTC, for display
|
pub timestamp: String, // ISO 8601 formatted timestamp
|
||||||
pub timestamp_us: u64, // epoch microseconds, for sorting/formatting
|
|
||||||
pub priority: LogLevel,
|
pub priority: LogLevel,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub unit: Option<String>, // systemd unit name
|
pub unit: Option<String>, // systemd unit name
|
||||||
@@ -126,9 +120,5 @@ pub struct JournalResponse {
|
|||||||
pub entries: Vec<JournalEntry>,
|
pub entries: Vec<JournalEntry>,
|
||||||
pub total_count: u32,
|
pub total_count: u32,
|
||||||
pub truncated: bool,
|
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
|
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-107
@@ -16,7 +16,9 @@ use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all}
|
|||||||
use crate::proto::pb;
|
use crate::proto::pb;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
// Payloads at or below this many bytes are sent as-is; larger ones are gzipped.
|
// Compression threshold based on typical payload size
|
||||||
|
// Temporarily increased for testing - revert to 768 for production
|
||||||
|
//const COMPRESSION_THRESHOLD: usize = 50_000;
|
||||||
const COMPRESSION_THRESHOLD: usize = 768;
|
const COMPRESSION_THRESHOLD: usize = 768;
|
||||||
|
|
||||||
// Reusable buffer for compression to avoid allocations
|
// Reusable buffer for compression to avoid allocations
|
||||||
@@ -50,66 +52,6 @@ pub async fn ws_handler(
|
|||||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||||
state
|
state
|
||||||
.client_count
|
.client_count
|
||||||
@@ -127,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
|||||||
Message::Text(ref text) if text == "get_processes" => {
|
Message::Text(ref text) if text == "get_processes" => {
|
||||||
let payload = collect_processes_all(&state).await;
|
let payload = collect_processes_all(&state).await;
|
||||||
|
|
||||||
// Get cached buffers. The Vec capacity is preserved across
|
// Map to protobuf message
|
||||||
// calls (with_capacity(512) seeds it, then we swap-back after
|
// Get cached buffers
|
||||||
// encode so the allocation outlives any single request).
|
|
||||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||||
let mut cache = cache.lock().await;
|
let mut cache = cache.lock().await;
|
||||||
|
|
||||||
|
// Reuse process vector to build the list
|
||||||
cache.processes_vec.clear();
|
cache.processes_vec.clear();
|
||||||
cache
|
cache
|
||||||
.processes_vec
|
.processes_vec
|
||||||
@@ -143,38 +85,29 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
|||||||
mem_bytes: p.mem_bytes,
|
mem_bytes: p.mem_bytes,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Move the populated Vec into the proto, encode, then move it
|
let pb = pb::Processes {
|
||||||
// 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,
|
process_count: payload.process_count as u64,
|
||||||
rows: std::mem::take(&mut cache.processes_vec),
|
rows: std::mem::take(&mut cache.processes_vec),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(8 * 1024);
|
let mut buf = Vec::with_capacity(8 * 1024);
|
||||||
let encode_result = prost::Message::encode(&pb, &mut buf);
|
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||||
// 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;
|
let _ = socket.send(Message::Close(None)).await;
|
||||||
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
|
||||||
let _ = socket.send(Message::Binary(buf)).await;
|
|
||||||
} else {
|
} else {
|
||||||
// Create a new encoder for each message to ensure proper gzip headers
|
// compress if large
|
||||||
let mut encoder =
|
if buf.len() <= COMPRESSION_THRESHOLD {
|
||||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
let _ = socket.send(Message::Binary(buf)).await;
|
||||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
} else {
|
||||||
Ok(compressed) => {
|
// Create a new encoder for each message to ensure proper gzip headers
|
||||||
let _ = socket.send(Message::Binary(compressed)).await;
|
let mut encoder =
|
||||||
}
|
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||||
Err(_) => {
|
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||||
let _ = socket.send(Message::Binary(buf)).await;
|
Ok(compressed) => {
|
||||||
|
let _ = socket.send(Message::Binary(compressed)).await;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = socket.send(Message::Binary(buf)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,30 +117,84 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
|||||||
if let Some(pid_str) = text.strip_prefix("get_process_metrics:")
|
if let Some(pid_str) = text.strip_prefix("get_process_metrics:")
|
||||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||||
{
|
{
|
||||||
respond_per_pid_cached(
|
let ttl = std::time::Duration::from_millis(250); // 250ms TTL
|
||||||
&mut socket,
|
|
||||||
&state.cache_process_metrics,
|
// Check cache first
|
||||||
pid,
|
{
|
||||||
std::time::Duration::from_millis(250),
|
let cache = state.cache_process_metrics.lock().await;
|
||||||
"get_process_metrics",
|
if let Some(entry) = cache.get(&pid)
|
||||||
|| crate::metrics::collect_process_metrics(pid, &state),
|
&& entry.is_fresh(ttl)
|
||||||
)
|
&& let Some(cached_response) = entry.get()
|
||||||
.await;
|
{
|
||||||
|
let _ = send_json(&mut socket, cached_response).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect fresh data
|
||||||
|
match crate::metrics::collect_process_metrics(pid, &state).await {
|
||||||
|
Ok(response) => {
|
||||||
|
// Cache the response
|
||||||
|
{
|
||||||
|
let mut cache = state.cache_process_metrics.lock().await;
|
||||||
|
cache
|
||||||
|
.entry(pid)
|
||||||
|
.or_insert_with(crate::state::CacheEntry::new)
|
||||||
|
.set(response.clone());
|
||||||
|
}
|
||||||
|
let _ = send_json(&mut socket, &response).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let error_response = serde_json::json!({
|
||||||
|
"error": err,
|
||||||
|
"request": "get_process_metrics",
|
||||||
|
"pid": pid
|
||||||
|
});
|
||||||
|
let _ = send_json(&mut socket, &error_response).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
|
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
|
||||||
if let Some(pid_str) = text.strip_prefix("get_journal_entries:")
|
if let Some(pid_str) = text.strip_prefix("get_journal_entries:")
|
||||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||||
{
|
{
|
||||||
respond_per_pid_cached(
|
let ttl = std::time::Duration::from_secs(1); // 1s TTL
|
||||||
&mut socket,
|
|
||||||
&state.cache_journal_entries,
|
// Check cache first
|
||||||
pid,
|
{
|
||||||
std::time::Duration::from_secs(1),
|
let cache = state.cache_journal_entries.lock().await;
|
||||||
"get_journal_entries",
|
if let Some(entry) = cache.get(&pid)
|
||||||
|| crate::metrics::collect_journal_entries(pid),
|
&& entry.is_fresh(ttl)
|
||||||
)
|
&& let Some(cached_response) = entry.get()
|
||||||
.await;
|
{
|
||||||
|
let _ = send_json(&mut socket, cached_response).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect fresh data
|
||||||
|
match crate::metrics::collect_journal_entries(pid) {
|
||||||
|
Ok(response) => {
|
||||||
|
// Cache the response
|
||||||
|
{
|
||||||
|
let mut cache = state.cache_journal_entries.lock().await;
|
||||||
|
cache
|
||||||
|
.entry(pid)
|
||||||
|
.or_insert_with(crate::state::CacheEntry::new)
|
||||||
|
.set(response.clone());
|
||||||
|
}
|
||||||
|
let _ = send_json(&mut socket, &response).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let error_response = serde_json::json!({
|
||||||
|
"error": err,
|
||||||
|
"request": "get_journal_entries",
|
||||||
|
"pid": pid
|
||||||
|
});
|
||||||
|
let _ = send_json(&mut socket, &error_response).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(_) => break,
|
Message::Close(_) => break,
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ async fn test_process_cache_ttl() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let journal_response = JournalResponse {
|
let journal_response = JournalResponse {
|
||||||
notice: None,
|
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
total_count: 0,
|
total_count: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async fn test_collect_journal_entries_self() {
|
|||||||
// Test collecting journal entries for our own process
|
// Test collecting journal entries for our own process
|
||||||
let pid = process::id();
|
let pid = process::id();
|
||||||
|
|
||||||
match collect_journal_entries(pid).await {
|
match collect_journal_entries(pid) {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
assert!(response.cached_at > 0);
|
assert!(response.cached_at > 0);
|
||||||
println!(
|
println!(
|
||||||
@@ -74,7 +74,7 @@ async fn test_collect_journal_entries_invalid_pid() {
|
|||||||
// Test with an invalid PID - journalctl might still return empty results
|
// Test with an invalid PID - journalctl might still return empty results
|
||||||
let invalid_pid = 999999;
|
let invalid_pid = 999999;
|
||||||
|
|
||||||
match collect_journal_entries(invalid_pid).await {
|
match collect_journal_entries(invalid_pid) {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
println!(
|
println!(
|
||||||
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
|
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
|
||||||
@@ -87,19 +87,3 @@ async fn test_collect_journal_entries_invalid_pid() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use assert_cmd::prelude::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -16,7 +17,7 @@ fn generates_self_signed_cert_and_key_in_xdg_path() {
|
|||||||
let xdg = tmpdir.path().to_path_buf();
|
let xdg = tmpdir.path().to_path_buf();
|
||||||
|
|
||||||
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed
|
// 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"));
|
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
|
||||||
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
|
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
|
||||||
cmd.env("XDG_CONFIG_HOME", &xdg)
|
cmd.env("XDG_CONFIG_HOME", &xdg)
|
||||||
.arg("--enableSSL")
|
.arg("--enableSSL")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "socktop_connector"
|
name = "socktop_connector"
|
||||||
version = "1.60.2"
|
version = "1.50.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "WebSocket connector library for socktop agent communication"
|
description = "WebSocket connector library for socktop agent communication"
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Vendored protoc for reproducible builds where available. It ships no
|
// Set the protoc binary path to use the vendored version for CI compatibility
|
||||||
// riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to
|
// SAFETY: We're only setting PROTOC in a build script environment, which is safe
|
||||||
// prost-build (apt: protobuf-compiler).
|
unsafe {
|
||||||
// SAFETY: We're only setting PROTOC in a build script environment.
|
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
|
||||||
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("PROTOC", protoc);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prost_build::compile_protos(&["processes.proto"], &["."])?;
|
prost_build::compile_protos(&["processes.proto"], &["."])?;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ use crate::error::{ConnectorError, Result};
|
|||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[cfg(feature = "tls")]
|
#[cfg(feature = "tls")]
|
||||||
@@ -15,7 +15,7 @@ use {
|
|||||||
rustls::{
|
rustls::{
|
||||||
DigitallySignedStruct, RootCertStore, SignatureScheme,
|
DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||||
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||||
crypto::{WebPkiSupportedAlgorithms, ring},
|
crypto::ring,
|
||||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||||
},
|
},
|
||||||
rustls_pemfile::Item,
|
rustls_pemfile::Item,
|
||||||
@@ -64,8 +64,7 @@ async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> R
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `true` disables Nagle: small request/response frames, latency matters.
|
let (ws, _) = connect_async(req).await?;
|
||||||
let (ws, _) = tokio_tungstenite::connect_async_with_config(req, None, true).await?;
|
|
||||||
Ok(ws)
|
Ok(ws)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +85,7 @@ async fn connect_with_ca_and_config(
|
|||||||
der_certs.push(der);
|
der_certs.push(der);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if der_certs.is_empty() {
|
root.add_parsable_certificates(der_certs);
|
||||||
return Err(ConnectorError::protocol_error(format!(
|
|
||||||
"no certificates found in --tls-ca file: {ca_path}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
root.add_parsable_certificates(der_certs.iter().cloned());
|
|
||||||
|
|
||||||
let mut cfg = ClientConfig::builder()
|
let mut cfg = ClientConfig::builder()
|
||||||
.with_root_certificates(root)
|
.with_root_certificates(root)
|
||||||
@@ -120,89 +114,57 @@ async fn connect_with_ca_and_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !config.verify_hostname {
|
if !config.verify_hostname {
|
||||||
// Default mode: certificate PINNING without hostname verification.
|
#[derive(Debug)]
|
||||||
// The server must present a certificate byte-identical to one in the
|
struct NoVerify;
|
||||||
// --tls-ca file. This intentionally ignores expiry and chain building
|
impl ServerCertVerifier for NoVerify {
|
||||||
// (the operator pinned this exact cert), but unlike a blanket accept
|
fn verify_server_cert(
|
||||||
// it makes MITM certs fail the handshake.
|
&self,
|
||||||
cfg.dangerous()
|
_end_entity: &CertificateDer<'_>,
|
||||||
.set_certificate_verifier(Arc::new(PinnedCertVerifier::new(der_certs)));
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &ServerName,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> std::result::Result<ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &CertificateDer<'_>,
|
||||||
|
_dss: &DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
vec![
|
||||||
|
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
SignatureScheme::ED25519,
|
||||||
|
SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
|
||||||
|
// Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
|
||||||
}
|
}
|
||||||
let cfg = Arc::new(cfg);
|
let cfg = Arc::new(cfg);
|
||||||
// Third argument is tungstenite's `disable_nagle`: always true — socktop
|
|
||||||
// exchanges small request/response frames where Nagle only adds latency.
|
|
||||||
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
|
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
|
||||||
req,
|
req,
|
||||||
None,
|
None,
|
||||||
true,
|
config.verify_hostname,
|
||||||
Some(Connector::Rustls(cfg)),
|
Some(Connector::Rustls(cfg)),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(ws)
|
Ok(ws)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accepts exactly the certificates the user pinned via `--tls-ca`, nothing else.
|
|
||||||
///
|
|
||||||
/// Used when hostname verification is off (the default for self-signed
|
|
||||||
/// home-lab certs). Signature validation still runs with the ring provider's
|
|
||||||
/// full algorithm set; only the certificate identity check is replaced —
|
|
||||||
/// by an exact DER comparison against the pinned certificate(s).
|
|
||||||
#[cfg(feature = "tls")]
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PinnedCertVerifier {
|
|
||||||
pinned: Vec<CertificateDer<'static>>,
|
|
||||||
algorithms: WebPkiSupportedAlgorithms,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tls")]
|
|
||||||
impl PinnedCertVerifier {
|
|
||||||
fn new(pinned: Vec<CertificateDer<'static>>) -> Self {
|
|
||||||
Self {
|
|
||||||
pinned,
|
|
||||||
algorithms: ring::default_provider().signature_verification_algorithms,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "tls")]
|
|
||||||
impl ServerCertVerifier for PinnedCertVerifier {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
end_entity: &CertificateDer<'_>,
|
|
||||||
_intermediates: &[CertificateDer<'_>],
|
|
||||||
_server_name: &ServerName,
|
|
||||||
_ocsp_response: &[u8],
|
|
||||||
_now: UnixTime,
|
|
||||||
) -> std::result::Result<ServerCertVerified, rustls::Error> {
|
|
||||||
if self.pinned.iter().any(|p| p == end_entity) {
|
|
||||||
Ok(ServerCertVerified::assertion())
|
|
||||||
} else {
|
|
||||||
Err(rustls::Error::InvalidCertificate(
|
|
||||||
rustls::CertificateError::ApplicationVerificationFailure,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &CertificateDer<'_>,
|
|
||||||
dss: &DigitallySignedStruct,
|
|
||||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms)
|
|
||||||
}
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &CertificateDer<'_>,
|
|
||||||
dss: &DigitallySignedStruct,
|
|
||||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms)
|
|
||||||
}
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
|
||||||
self.algorithms.supported_schemes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "tls"))]
|
#[cfg(not(feature = "tls"))]
|
||||||
async fn connect_with_ca_and_config(
|
async fn connect_with_ca_and_config(
|
||||||
_url: &str,
|
_url: &str,
|
||||||
@@ -219,73 +181,3 @@ async fn connect_with_ca_and_config(
|
|||||||
fn ensure_crypto_provider() {
|
fn ensure_crypto_provider() {
|
||||||
let _ = ring::default_provider().install_default();
|
let _ = ring::default_provider().install_default();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "tls"))]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn verifier(pinned: &[&[u8]]) -> PinnedCertVerifier {
|
|
||||||
let _ = ring::default_provider().install_default();
|
|
||||||
PinnedCertVerifier::new(
|
|
||||||
pinned
|
|
||||||
.iter()
|
|
||||||
.map(|b| CertificateDer::from(b.to_vec()))
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify(v: &PinnedCertVerifier, presented: &[u8]) -> bool {
|
|
||||||
v.verify_server_cert(
|
|
||||||
&CertificateDer::from(presented.to_vec()),
|
|
||||||
&[],
|
|
||||||
&ServerName::try_from("agent.test").unwrap(),
|
|
||||||
&[],
|
|
||||||
UnixTime::now(),
|
|
||||||
)
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The regression this verifier exists to prevent: the old NoVerify
|
|
||||||
/// accepted ANY certificate when hostname verification was off, so the
|
|
||||||
/// documented pinning was a no-op. The pinned cert must be accepted and
|
|
||||||
/// every other cert rejected.
|
|
||||||
#[test]
|
|
||||||
fn only_the_pinned_certificate_is_accepted() {
|
|
||||||
let v = verifier(&[b"pinned-cert-der"]);
|
|
||||||
assert!(verify(&v, b"pinned-cert-der"));
|
|
||||||
assert!(!verify(&v, b"some-mitm-cert"), "unpinned cert accepted");
|
|
||||||
assert!(!verify(&v, b""), "empty cert accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A --tls-ca file may hold several certs (e.g. during rotation); any of
|
|
||||||
/// them must satisfy the pin.
|
|
||||||
#[test]
|
|
||||||
fn any_cert_in_a_multi_cert_pem_satisfies_the_pin() {
|
|
||||||
let v = verifier(&[b"old-cert", b"new-cert"]);
|
|
||||||
assert!(verify(&v, b"old-cert"));
|
|
||||||
assert!(verify(&v, b"new-cert"));
|
|
||||||
assert!(!verify(&v, b"third-party-cert"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fail closed: an empty pin set must reject everything rather than
|
|
||||||
/// falling back to accept-all.
|
|
||||||
#[test]
|
|
||||||
fn an_empty_pin_set_rejects_all_certificates() {
|
|
||||||
let v = verifier(&[]);
|
|
||||||
assert!(!verify(&v, b"anything"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signature schemes come from the real provider, not a hardcoded list —
|
|
||||||
/// an agent using e.g. RSA-PKCS1 must still be able to handshake.
|
|
||||||
#[test]
|
|
||||||
fn signature_schemes_come_from_the_provider() {
|
|
||||||
let v = verifier(&[b"x"]);
|
|
||||||
let schemes = v.supported_verify_schemes();
|
|
||||||
assert!(
|
|
||||||
schemes.len() > 3,
|
|
||||||
"suspiciously short scheme list: {schemes:?}"
|
|
||||||
);
|
|
||||||
assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA256));
|
|
||||||
assert!(schemes.contains(&SignatureScheme::ECDSA_NISTP256_SHA256));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ pub struct GpuInfo {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct Metrics {
|
pub struct Metrics {
|
||||||
/// Epoch ms when the agent actually collected this snapshot (agents may
|
|
||||||
/// serve TTL-cached data). Absent on agents older than 1.60.
|
|
||||||
#[serde(default)]
|
|
||||||
pub sampled_at_ms: Option<u64>,
|
|
||||||
pub cpu_total: f32,
|
pub cpu_total: f32,
|
||||||
pub cpu_per_core: Vec<f32>,
|
pub cpu_per_core: Vec<f32>,
|
||||||
pub mem_total: u64,
|
pub mem_total: u64,
|
||||||
@@ -151,10 +147,6 @@ pub struct JournalResponse {
|
|||||||
pub entries: Vec<JournalEntry>,
|
pub entries: Vec<JournalEntry>,
|
||||||
pub total_count: u32,
|
pub total_count: u32,
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
/// Agent-side explanation for an empty result (journal access limits).
|
|
||||||
/// Absent on agents older than 1.60.
|
|
||||||
#[serde(default)]
|
|
||||||
pub notice: Option<String>,
|
|
||||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ pub async fn send_request_and_wait(
|
|||||||
// For now, return a placeholder metrics response indicating binary data received
|
// For now, return a placeholder metrics response indicating binary data received
|
||||||
// TODO: Implement proper protobuf decoding for binary data
|
// TODO: Implement proper protobuf decoding for binary data
|
||||||
let placeholder_metrics = Metrics {
|
let placeholder_metrics = Metrics {
|
||||||
sampled_at_ms: None,
|
|
||||||
cpu_total: 0.0,
|
cpu_total: 0.0,
|
||||||
cpu_per_core: vec![0.0],
|
cpu_per_core: vec![0.0],
|
||||||
mem_total: 0,
|
mem_total: 0,
|
||||||
|
|||||||
Generated
+5
-3
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
@@ -475,7 +475,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socktop_connector"
|
name = "socktop_connector"
|
||||||
version = "1.51.0"
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a63dadaa5105df11b0684759a829012257d48e72a469cc554c0cf4394605f5a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flate2",
|
"flate2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ edition = "2021"
|
|||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Use WASM features for WebSocket connectivity (in-repo connector via path)
|
# Use WASM features for WebSocket connectivity (published version)
|
||||||
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
|
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|||||||
Generated
-4384
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,6 @@ name = "zellij_socktop_plugin"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# Standalone package, not part of the parent workspace (same as socktop_wasm_test)
|
|
||||||
[workspace]
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
@@ -13,7 +10,7 @@ crate-type = ["cdylib"]
|
|||||||
zellij-tile = "0.40.0"
|
zellij-tile = "0.40.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
|
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
|
|||||||
Reference in New Issue
Block a user