Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0322308896 | |||
| ebda3c51af | |||
| 623a6e5f85 | |||
| 20966d0c94 | |||
| f95a64a18b | |||
| 1c0c44ec3c | |||
| ee4468ca23 | |||
| 697a77bdab | |||
| 8f452a35e6 | |||
| 1d285c3c4e | |||
| 0fb45f6c50 | |||
| be24fa3859 | |||
| 39619b2845 | |||
| 40c925e7f9 | |||
| 8f69e469e6 | |||
| 3024816525 | |||
| 1d7bc42d59 | |||
| 518ae8c2bf | |||
| 6eb1809309 | |||
| 1c01902a71 | |||
| 9d302ad475 | |||
| 7875f132f7 | |||
| 0d789fb97c | |||
| 5ddaed298b | |||
| 1528568c30 | |||
| 6f238cdf25 | |||
| ffe451edaa | |||
| c9bde52cb1 | |||
| 0603746d7c | |||
| 25632f3427 | |||
| e51cdb0c50 | |||
| 1cb05d404b | |||
| 4196066e57 | |||
| 47e96c7d92 | |||
| bae2ecb79a | |||
| bd0d15a1ae | |||
| 689498c5f4 | |||
| 34e260a612 |
@@ -0,0 +1,422 @@
|
||||
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 }}
|
||||
runs-on: ubuntu-latest
|
||||
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: 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 }}
|
||||
+12
-2
@@ -1,7 +1,17 @@
|
||||
/target
|
||||
# Any crate's build directory, including standalone sub-crates
|
||||
# (zellij_socktop_plugin, socktop_wasm_test) that live outside the workspace.
|
||||
target/
|
||||
.vscode/
|
||||
/socktop-wasm-test/target
|
||||
/.cargo/
|
||||
|
||||
# Documentation files from development sessions (context-specific, not for public repo)
|
||||
/OPTIMIZATION_PROCESS_DETAILS.md
|
||||
/THREAD_SUPPORT.md
|
||||
|
||||
# APT Repository - Safety: Never commit private keys!
|
||||
*.asc
|
||||
*-private.key
|
||||
*-secret.key
|
||||
gpg-private-backup.key
|
||||
secring.gpg
|
||||
# Note: Release.gpg, InRelease, and KEY.gpg (public) ARE safe to commit
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## 1.60.0 — unreleased
|
||||
|
||||
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.0 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.
|
||||
|
||||
### 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
+1254
-1153
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -24,8 +24,9 @@ serde_json = "1.0"
|
||||
sysinfo = "0.37"
|
||||
|
||||
# CLI UI
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.27"
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
unicode-width = "0.2"
|
||||
|
||||
# web server (remote-agent)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Debian Packaging Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented Debian packaging for socktop using `cargo-deb`, with GitHub Actions automation for building packages for both AMD64 and ARM64 architectures.
|
||||
|
||||
## Branches Created
|
||||
|
||||
1. **`feature/debian-packaging`** - Main branch with debian packaging implementation
|
||||
2. **`feature/man-pages`** - Separate branch for man pages work (to be researched further)
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. Cargo.toml Updates
|
||||
|
||||
Both `socktop/Cargo.toml` and `socktop_agent/Cargo.toml` were updated with:
|
||||
- `[package.metadata.deb]` sections
|
||||
- Package metadata (maintainer, description, dependencies)
|
||||
- Asset definitions (binaries, documentation)
|
||||
- Systemd service configuration (agent only)
|
||||
|
||||
### 2. Systemd Service
|
||||
|
||||
**File**: `socktop_agent/socktop-agent.service`
|
||||
- Runs as `socktop` user/group
|
||||
- Listens on port 3000 by default
|
||||
- Security hardening enabled
|
||||
- Disabled by default (user must explicitly enable)
|
||||
|
||||
### 3. Maintainer Scripts
|
||||
|
||||
**Directory**: `socktop_agent/debian/`
|
||||
|
||||
- **`postinst`**: Creates `socktop` user/group, sets up `/var/lib/socktop` directory
|
||||
- **`postrm`**: Cleanup on package removal/purge
|
||||
|
||||
### 4. GitHub Actions Workflow
|
||||
|
||||
**File**: `.github/workflows/build-deb.yml`
|
||||
|
||||
Features:
|
||||
- Builds for both x86_64 and ARM64
|
||||
- Triggered on:
|
||||
- Push to `master` or `feature/debian-packaging`
|
||||
- Pull requests to `master`
|
||||
- Version tags (v*)
|
||||
- Manual workflow dispatch
|
||||
- Creates artifacts:
|
||||
- `debian-packages-amd64`
|
||||
- `debian-packages-arm64`
|
||||
- `all-debian-packages` (combined)
|
||||
- `checksums` (SHA256SUMS)
|
||||
- Automatic GitHub releases for version tags
|
||||
|
||||
### 5. Documentation
|
||||
|
||||
**File**: `docs/DEBIAN_PACKAGING.md`
|
||||
|
||||
Comprehensive guide covering:
|
||||
- Building packages locally
|
||||
- Cross-compilation for ARM64
|
||||
- Installation and configuration
|
||||
- Using GitHub Actions artifacts
|
||||
- Creating local APT repositories
|
||||
- Troubleshooting
|
||||
|
||||
## Package Details
|
||||
|
||||
### socktop (TUI Client)
|
||||
- **Binary**: `/usr/bin/socktop`
|
||||
- **Size**: ~3.5 MB (x86_64)
|
||||
- **Dependencies**: Auto-detected
|
||||
|
||||
### socktop_agent (Daemon)
|
||||
- **Binary**: `/usr/bin/socktop_agent`
|
||||
- **Service**: `socktop-agent.service`
|
||||
- **User/Group**: `socktop` (created automatically)
|
||||
- **State directory**: `/var/lib/socktop`
|
||||
- **Size**: ~6.7 MB (x86_64)
|
||||
- **Dependencies**: Auto-detected
|
||||
|
||||
## Testing
|
||||
|
||||
Both packages successfully built locally:
|
||||
```
|
||||
✓ socktop_1.50.0-1_amd64.deb
|
||||
✓ socktop-agent_1.50.1-1_amd64.deb
|
||||
```
|
||||
|
||||
Verified:
|
||||
- Package contents (dpkg -c)
|
||||
- Package metadata (dpkg -I)
|
||||
- Systemd service file inclusion
|
||||
- Maintainer scripts inclusion
|
||||
- Documentation inclusion
|
||||
|
||||
## Usage
|
||||
|
||||
### For Users
|
||||
|
||||
Download pre-built packages from GitHub Actions artifacts:
|
||||
1. Go to Actions tab
|
||||
2. Select latest "Build Debian Packages" run
|
||||
3. Download architecture-specific artifact
|
||||
4. Install: `sudo dpkg -i socktop*.deb`
|
||||
|
||||
### For Developers
|
||||
|
||||
Build locally:
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
```
|
||||
|
||||
Cross-compile for ARM64:
|
||||
```bash
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
cargo deb --package socktop --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
To get packages in official APT repositories:
|
||||
|
||||
1. **Short term**: Host packages on GitHub Releases (automated)
|
||||
2. **Medium term**: Create PPA for Ubuntu users
|
||||
3. **Long term**: Submit to Debian/Ubuntu official repositories
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
```
|
||||
Modified:
|
||||
socktop/Cargo.toml
|
||||
socktop_agent/Cargo.toml
|
||||
|
||||
Created:
|
||||
.github/workflows/build-deb.yml
|
||||
docs/DEBIAN_PACKAGING.md
|
||||
socktop_agent/socktop-agent.service
|
||||
socktop_agent/debian/postinst
|
||||
socktop_agent/debian/postrm
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
532ed16 Add Debian packaging support with cargo-deb
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
|
||||
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
|
||||
- Full documentation in `docs/DEBIAN_PACKAGING.md`
|
||||
@@ -5,6 +5,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
|
||||
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||
[socktop.io](https://www.socktop.io)
|
||||
|
||||
<img src="./docs/socktop_demo.apng" width="100%">
|
||||
|
||||
---
|
||||
@@ -29,6 +31,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
|
||||
- Only top-level processes listed (threads hidden) — matches btop/top
|
||||
- Optional GPU metrics (can be disabled)
|
||||
- Optional auth token for the agent
|
||||
- Compact layout for small windows: automatically drops the panes that no longer fit so
|
||||
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
|
||||
|
||||
---
|
||||
|
||||
@@ -51,15 +55,23 @@ 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)
|
||||
### Raspberry Pi / Ubuntu / PopOS (required for GPU support)
|
||||
|
||||
Install GPU support with apt command below
|
||||
**Note:** GPU monitoring is only supported on x86_64 and aarch64 (64-bit ARM) platforms. ARMv7 (32-bit) and RISC-V builds do not include GPU support.
|
||||
|
||||
For 64-bit systems with GPU support:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
For ARMv7 (32-bit Raspberry Pi), build with `--no-default-features` to disable GPU support:
|
||||
|
||||
```bash
|
||||
cargo build --release -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
|
||||
|
||||
---
|
||||
@@ -203,6 +215,8 @@ socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# shorthand:
|
||||
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
||||
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
||||
# force the small-window layout at any terminal size (normally automatic):
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Intervals (client-driven):
|
||||
@@ -214,6 +228,29 @@ The agent stays idle unless queried. When queried, it collects just what’s nee
|
||||
|
||||
---
|
||||
|
||||
## Compact mode
|
||||
|
||||
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
|
||||
are the first things to collapse — exactly the panes you are most likely watching. Once
|
||||
the window is too short for the Disks pane to show even one disk, socktop switches to a
|
||||
compact layout:
|
||||
|
||||
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
|
||||
- **Memory and Swap move side by side** into the row Disks vacated.
|
||||
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
|
||||
with no GPU the pane disappears entirely.
|
||||
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
|
||||
below the size where they used to vanish.
|
||||
|
||||
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
|
||||
layout at any window size:
|
||||
|
||||
```bash
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection Profiles (Named)
|
||||
|
||||
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||
@@ -379,6 +416,7 @@ Tip: If only the binary changed, restart is enough. If the unit file changed, ru
|
||||
|
||||
```json
|
||||
{
|
||||
"sampled_at_ms": 1786752000123,
|
||||
"cpu_total": 12.4,
|
||||
"cpu_per_core": [11.2, 15.7],
|
||||
"mem_total": 33554432,
|
||||
@@ -438,7 +476,7 @@ 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.
|
||||
- Hostname (SAN) verification is DISABLED by default; instead the client PINS the certificate: the agent must present a cert byte-identical to one in your `--tls-ca` file (expiry is ignored in this mode — you pinned that exact cert). Use `--verify-hostname` to switch to strict chain + SAN validation instead.
|
||||
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGNBGkih7QBDADgX6sYMx2Lp6qcZxeCCizcy4TFsxcRJfp5mfbMplVES0hQToIP
|
||||
EMC11JqPwQdLliXKjUr8Z2kgM2oqvH+dkdgzUGrw6kTK8YHc+qs37iJAOVS9D72X
|
||||
tTld282NrtFwzb74nS2GKPkpWI7aSKBpHtWFPX/1ONsc56qGqFd3wwikEvCz8MeJ
|
||||
HwCD1JZ9F+2DyyXWsTJNgDwPloJSUbtyVuk2gd6PeTg7AQdx92Pk/mggmYbHtP8N
|
||||
wy072ku1g8K/hplmwIOGpSx1JWvAQkDU/Bb/jSqrYg2wSHO7IQnYE8I3x/zglYBl
|
||||
FYNh47TVQr0zPVSYR1MQkHU5YLBTDc5UgDvtcsYUiTtq4D/m8HWmKja0/UKGxvDJ
|
||||
P5sUPcp4dk77RdoCtUe5HImYGS8lo5N3+t0lz8sd9rYmRiIO4f7FJaJqJeHbUJyn
|
||||
iw/GCQh5D5/D571dICrEq/QhL+k5KhJljPGoVMGPFXJIc7q+CxvGp2oOo5fOlbOn
|
||||
3kSrM93AJPwT8FMAEQEAAbRFSmFzb24gV2l0dHkgKHNvY2t0b3AgYXB0IHNpZ25p
|
||||
bmcga2V5KSA8amFzb25wd2l0dHkrc29ja3RvcEBwcm90b24ubWU+iQHOBBMBCgA4
|
||||
FiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkih7QCGwMFCwkIBwIGFQoJCAsCBBYC
|
||||
AwECHgECF4AACgkQESwaeYRl+/KV+gwAzfZVZEhO7MQV2EmNeKVK1GycFSm2oUAl
|
||||
ZbwNIEHu6+tOzqXJb8o65BtGlbLSGavsMpgRCK2SL83DdLOkutG1ahQiJr+5GaXC
|
||||
zbQgX+VWqGPZtQ+I6/rVoYZPMTCrqpAmFgvVpqv0xod7w8/wny8/XmhQ37KY2/0l
|
||||
B38oNTvdA7C8jzSrI6kr3XqurvQRW7z+MnC+nCp9Ob9bYtY0kpd4U3NrVdb8m32U
|
||||
d5LVFwD1OGvzLOSqyJ33IKjSJc4KLvW+aEsHXe+fHO9UEzH8Nbo5MmVvX3QIHiyq
|
||||
jD4zN16AGsGYqCK4irtQCiD3wBOdsG/RVkgIcdlmAH3EGEp7Ux8+7v1PXYI+UrSs
|
||||
XE7f1xFTJ2r5TMex6W3he073Em4qhQsrnMF5syTZsM6N+5UqXVOM1RuDVVXr7929
|
||||
hC3G8pK/A2W5Lwpxl2yzock2CxhvUn7M/xm4VbcPlWTCUd/QzU8VtsgaGHcuhi5e
|
||||
xHY1AU07STLB9RinjBVf2bmk4oDQcmB6uQGNBGkih7QBDACrjE+xSWP92n931/5t
|
||||
+tXcujwFlIpSZdbSQFr0B0YyjPRUP4FSzEGu8vuM5ChUfWKhmN1dDr5C4qFo9NgQ
|
||||
6oCN2HubajSGyXNwnOMlMb5ck79Ubmy9yDV9/ZLqpJJiozGap2/EnNoDhaANlmUg
|
||||
rfqUHpIB8XC2IZ0Itt05tp/u78dJiB+R6ReZn/bVUafNV4jIqYZfLRzI3FTJ4xvK
|
||||
FGs/ER+JajAdJQ8LPfazmDQSGw0huguxhopZwKQ/qWZMn1OHq/ZaPvCqbQt3irLw
|
||||
dLPDC4pEaYGRyADYeyuarG0DVyUQ9XRc/NufKDvOAn33LpBPBpcvNQAsVhWTCYl7
|
||||
ogQ+suVYVN8Tu7v4bUSHKwzXKvLN/ojJX/Fh7eTW4TPsgLHNHAEDUkSQozIe9vO6
|
||||
o+vydDqRxuXJgdkR7lqP6PQDYrhRYZGJf57eKf6VtTKYFaMbiMWPU+vcHeB0/iDe
|
||||
Pv81qro2LD2PG5WCzDpNETBceCTjykb9r0VHx4/JsiojKmsAEQEAAYkBtgQYAQoA
|
||||
IBYhBB51VqgFObg5S8KCDREsGnmEZfvyBQJpIoe0AhsMAAoJEBEsGnmEZfvyNp8M
|
||||
AIH+6+hGB3qADdnhNgb+3fN0511eK9Uk82lxgGARLcD8GN1UP0HlvEqkxCHy3PUe
|
||||
tHcsuYVz7i8pmpEGdFx9zv7MelenUsJniUQ++OZKx6iUG/MYqz//NxY+5lyRmcu2
|
||||
aYvUxhkgf9zgxXTkTyV2VV32mX//cHcwc+c/089QAPzCMaSrHdNK+ED9+k8uquJ1
|
||||
lSL9Bm15z/EV42v9Q/4KTM5OBLHpNw0Rvn9C0iuZVwHXBrrA/HSGXpA54AqNUMpZ
|
||||
kRPgLQcy5yVE2y1aXLXt2XdTn6YPzrAjNoazYYuCWHYIZU7dGkIswpsDirDLKHdD
|
||||
onb3VShmSpemYjsuFiqhfi6qwCkeHsz/CpQAp70SZ+z9oB8H80PJVKPbPIP3zEf3
|
||||
i7bcsqHA7stF+8sJclXgxBUBeDJ3O2jN/scBOcvNA6xoRp7+oJbnjDRuxBmh+fVg
|
||||
TIuw2++vTF2Ml0EMv7ePTpr7b1DofuJRNYGkuAIMVXHjLTqMiTJUce3OUy003zMg
|
||||
Dg==
|
||||
=AaPQ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -0,0 +1,38 @@
|
||||
# socktop APT Repository
|
||||
|
||||
This repository contains Debian packages for socktop and socktop-agent.
|
||||
|
||||
## Adding this repository
|
||||
|
||||
Add the repository to your system:
|
||||
|
||||
```bash
|
||||
# Add the GPG key
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
# Add the repository
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Update and install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
```
|
||||
|
||||
## Manual Installation
|
||||
|
||||
You can also download and install packages manually from the `pool/main/` directory.
|
||||
|
||||
```bash
|
||||
wget https://jasonwitty.github.io/socktop/pool/main/socktop_VERSION_ARCH.deb
|
||||
sudo dpkg -i socktop_VERSION_ARCH.deb
|
||||
```
|
||||
|
||||
## Supported Architectures
|
||||
|
||||
- amd64 (x86_64)
|
||||
- arm64 (aarch64)
|
||||
- armhf (32-bit ARM)
|
||||
|
||||
## Building from Source
|
||||
|
||||
See the main repository at https://github.com/jasonwitty/socktop
|
||||
@@ -0,0 +1,32 @@
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: Sun, 23 Nov 2025 04:05:21 +0000
|
||||
MD5Sum:
|
||||
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
|
||||
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
|
||||
SHA256:
|
||||
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
|
||||
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
|
||||
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAEBCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAYACgkQESwaeYRl
|
||||
+/KBsAv/eYhnK/XrNtPhLyw/zX2cGfUtBsBZrypFhV/n+TvudAIwQaqxDEvLlBUn
|
||||
HBAhMKDQXGs7V45+nOgDX4rKWUqJh4SPbJgNbVte2PX7U+hsMpZBsYp3vkjApgTO
|
||||
pq2CCkViyBXgTY+6vUigtvfJ9afTTWI6Qm4dLXZ7hxErBxgHQyowOoO/sF92cNOu
|
||||
AosBMpE+qSy7sVqJU5g/JXJh0kddKFotXHSGA1kFMzJafJC/n5nLrusDzFJRQqyH
|
||||
Io+6inYWjlb5o79z0tJzAvG1mgplLRppMBjoVJ/RJ+gT+QE70kokR6wvsgDqsKNd
|
||||
mvB0TNj0zY0g6Is6V3XMyf0u+6BtLTbua913HPiqBfErgeV58vzsst+y0It42TXi
|
||||
aw+UF2Kw/YhPq1rZFxgnAVcMja3qlXWpH57gmgIPovBCsPsiywWiHLsSHRzAI22b
|
||||
zeTsUST/4toR/ruZVbUZvWoWAR4tzsSuwXJFx/hhinTQQTNHErXASOX986UaL9L7
|
||||
o2/pTKLe
|
||||
=IeBY
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,14 @@
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: Sun, 23 Nov 2025 04:05:21 +0000
|
||||
MD5Sum:
|
||||
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
|
||||
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
|
||||
SHA256:
|
||||
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
|
||||
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
|
||||
@@ -0,0 +1,14 @@
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAABCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAEACgkQESwaeYRl
|
||||
+/KzeAv+OUIbxud5FboerwpAJULV+rS3+VX4kvwg/daVZ3yX3tJNrsyNCHgmWLVu
|
||||
fLeEFFc2Ax9GvFW4jrbxRAGD+3TXQEEFkb5lGzYyDjlgVzR6wLiVTTrmzWoK+cbB
|
||||
4DMozqeLiZFfQjq4UFn3+mwiYFX9Dj7PVF0M60XAUJSObbJFmaEPZIfx6wcZfkiL
|
||||
lLLk1eeU5MPiyudPOhVGgaD76KrUCw+8DBNKoCKIEcCY0LvuKtUK8mWYXRSPSved
|
||||
4Znd3QZz063Z6R+Lj1XlGLoTPResna28T/Nca+2JgLhbrihsLMcHoFxmrvFP9FpT
|
||||
MChKngj7NnGt0yqHH5J16hdwMra/vvhmF0yoQ0loIcy+q06tYEqOcau8tvAjfbId
|
||||
k3rgQgnxxVE8WUmV9Bugp7jhNMO+ImKWMwzEr6wGd9ZHqpknUlAaWeO73VP+qtAN
|
||||
6mEqWhkqvXGg+srH6qp3Sg0W28dYG29X3Kx8jOp7HeyvA/gLZRN7L+bq/XaA7WFA
|
||||
1hba6LIY
|
||||
=QoLf
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,38 @@
|
||||
Package: socktop
|
||||
Version: 1.50.0-1
|
||||
Architecture: amd64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 3459
|
||||
Filename: pool/main/socktop_1.50.0-1_amd64.deb
|
||||
Size: 1278940
|
||||
MD5sum: 0215e178e306d9379669065e8c78582b
|
||||
SHA1: 04e0416389f5cecd584fd1f6b3568711f2645eee
|
||||
SHA256: 69eb04b1de48541c95950a97b16357fcd9c51ffaceb143f63de4a9d758fad297
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Homepage: https://github.com/jasonwitty/socktop
|
||||
Description: Remote system monitor over WebSocket, TUI like top
|
||||
socktop is a remote system monitor with a rich terminal user interface (TUI)
|
||||
that connects to remote hosts running the socktop_agent over WebSocket. It
|
||||
provides real-time monitoring of CPU, memory, processes, and more with an
|
||||
interface similar to the traditional 'top' command.
|
||||
|
||||
Package: socktop-agent
|
||||
Version: 1.50.2-1
|
||||
Architecture: amd64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 6793
|
||||
Filename: pool/main/socktop-agent_1.50.2-1_amd64.deb
|
||||
Size: 1896272
|
||||
MD5sum: 22e78d03e83dcf84d6ec4a009b285902
|
||||
SHA1: 26a9f4fedfdba06a047044027223f2944cf72ba6
|
||||
SHA256: 11922af475146f60347a9c52cff4bbce1ce524bdb4293b2c436f3c71876e17d5
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Homepage: https://github.com/jasonwitty/socktop
|
||||
Description: Socktop agent daemon. Serves host metrics over WebSocket.
|
||||
socktop_agent is the daemon component that runs on remote hosts to collect and
|
||||
serve system metrics over WebSocket. It gathers CPU, memory, disk, network,
|
||||
GPU, and process information that can be monitored remotely by the socktop TUI
|
||||
client.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
Archive: stable
|
||||
Component: main
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Architecture: amd64
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>socktop APT Repository</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1 { color: #333; }
|
||||
h2 { color: #555; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>socktop APT Repository</h1>
|
||||
<p>System monitor with remote agent support for Linux systems.</p>
|
||||
|
||||
<h2>Adding this repository</h2>
|
||||
<pre><code># Add the GPG key
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
# Add the repository
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Update and install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent</code></pre>
|
||||
|
||||
<h2>Manual Installation</h2>
|
||||
<p>Download packages from <a href="pool/main/">pool/main/</a></p>
|
||||
|
||||
<h2>Supported Architectures</h2>
|
||||
<ul>
|
||||
<li>amd64 (x86_64)</li>
|
||||
<li>arm64 (aarch64)</li>
|
||||
<li>armhf (32-bit ARM)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Source Code</h2>
|
||||
<p>Visit the <a href="https://github.com/jasonwitty/socktop">GitHub repository</a></p>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,274 @@
|
||||
# Debian Packaging for socktop
|
||||
|
||||
This document describes how to build and use Debian packages for socktop and socktop_agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install `cargo-deb`:
|
||||
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
```
|
||||
|
||||
## Building Packages Locally
|
||||
|
||||
### Build for your current architecture (x86_64)
|
||||
|
||||
```bash
|
||||
# Build socktop TUI client
|
||||
cargo deb --package socktop
|
||||
|
||||
# Build socktop_agent daemon
|
||||
cargo deb --package socktop_agent
|
||||
```
|
||||
|
||||
The `.deb` files will be created in `target/debian/`.
|
||||
|
||||
### Cross-compile for ARM64 (Raspberry Pi, etc.)
|
||||
|
||||
First, install cross-compilation tools:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
```
|
||||
|
||||
Add the ARM64 target:
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Configure the linker by creating `.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
```
|
||||
|
||||
Build the packages:
|
||||
|
||||
```bash
|
||||
# Build for ARM64
|
||||
cargo deb --package socktop --target aarch64-unknown-linux-gnu
|
||||
cargo deb --package socktop_agent --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
## Installing Packages
|
||||
|
||||
### Install socktop TUI client
|
||||
|
||||
```bash
|
||||
sudo dpkg -i socktop_*.deb
|
||||
```
|
||||
|
||||
### Install socktop_agent daemon
|
||||
|
||||
```bash
|
||||
sudo dpkg -i socktop_agent_*.deb
|
||||
```
|
||||
|
||||
The agent package will:
|
||||
- Create a `socktop` system user and group
|
||||
- Install the binary to `/usr/bin/socktop_agent`
|
||||
- Install a systemd service file (disabled by default)
|
||||
- Create `/var/lib/socktop` for state files
|
||||
|
||||
### Enable and start the agent service
|
||||
|
||||
```bash
|
||||
# Enable to start on boot
|
||||
sudo systemctl enable socktop-agent
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# Check status
|
||||
sudo systemctl status socktop-agent
|
||||
```
|
||||
|
||||
### Configure the agent
|
||||
|
||||
Edit the systemd service to customize settings:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit socktop-agent
|
||||
```
|
||||
|
||||
Add configuration in the override section:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=SOCKTOP_PORT=8080
|
||||
Environment=SOCKTOP_TOKEN=your-secret-token
|
||||
Environment=RUST_LOG=info
|
||||
```
|
||||
|
||||
Then restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart socktop-agent
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
The project includes a GitHub Actions workflow (`.github/workflows/build-deb.yml`) that automatically builds `.deb` packages for both x86_64 and ARM64 architectures on every push to master or when tags are created.
|
||||
|
||||
### Downloading pre-built packages
|
||||
|
||||
1. Go to the [Actions tab](https://github.com/jasonwitty/socktop/actions)
|
||||
2. Click on the latest "Build Debian Packages" workflow run
|
||||
3. Download the artifacts:
|
||||
- `debian-packages-amd64` - x86_64 packages
|
||||
- `debian-packages-arm64` - ARM64 packages
|
||||
- `all-debian-packages` - All packages combined
|
||||
- `checksums` - SHA256 checksums
|
||||
|
||||
### Release packages
|
||||
|
||||
When you create a git tag starting with `v` (e.g., `v1.50.0`), the workflow will automatically create a GitHub Release with all `.deb` packages attached.
|
||||
|
||||
```bash
|
||||
git tag v1.50.0
|
||||
git push origin v1.50.0
|
||||
```
|
||||
|
||||
## Package Details
|
||||
|
||||
### socktop package
|
||||
|
||||
- **Binary**: `/usr/bin/socktop`
|
||||
- **Documentation**: `/usr/share/doc/socktop/`
|
||||
- **Size**: ~5-8 MB (depends on architecture)
|
||||
|
||||
### socktop_agent package
|
||||
|
||||
- **Binary**: `/usr/bin/socktop_agent`
|
||||
- **Service**: `socktop-agent.service`
|
||||
- **User/Group**: `socktop`
|
||||
- **State directory**: `/var/lib/socktop`
|
||||
- **Config directory**: `/etc/socktop` (created but empty by default)
|
||||
- **Documentation**: `/usr/share/doc/socktop_agent/`
|
||||
- **Size**: ~5-8 MB (depends on architecture)
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
# Remove packages but keep configuration
|
||||
sudo apt remove socktop socktop_agent
|
||||
|
||||
# Remove packages and all configuration (purge)
|
||||
sudo apt purge socktop socktop_agent
|
||||
```
|
||||
|
||||
When purging `socktop_agent`, the following are removed:
|
||||
- The `socktop` user and group
|
||||
- `/var/lib/socktop` directory
|
||||
- Empty `/etc/socktop` directory (if empty)
|
||||
|
||||
## Verifying Packages
|
||||
|
||||
Check package contents:
|
||||
|
||||
```bash
|
||||
dpkg -c socktop_*.deb
|
||||
dpkg -c socktop_agent_*.deb
|
||||
```
|
||||
|
||||
Check package information:
|
||||
|
||||
```bash
|
||||
dpkg -I socktop_*.deb
|
||||
dpkg -I socktop_agent_*.deb
|
||||
```
|
||||
|
||||
After installation, verify files:
|
||||
|
||||
```bash
|
||||
dpkg -L socktop
|
||||
dpkg -L socktop-agent
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service fails to start
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u socktop-agent -f
|
||||
```
|
||||
|
||||
Verify the socktop user exists:
|
||||
|
||||
```bash
|
||||
id socktop
|
||||
```
|
||||
|
||||
### Permission issues
|
||||
|
||||
Ensure the state directory has correct permissions:
|
||||
|
||||
```bash
|
||||
sudo chown -R socktop:socktop /var/lib/socktop
|
||||
sudo chmod 755 /var/lib/socktop
|
||||
```
|
||||
|
||||
### Missing dependencies
|
||||
|
||||
If installation fails due to missing dependencies:
|
||||
|
||||
```bash
|
||||
sudo apt --fix-broken install
|
||||
```
|
||||
|
||||
## Creating a Local APT Repository (Advanced)
|
||||
|
||||
To create your own APT repository for easy installation:
|
||||
|
||||
1. Install required tools:
|
||||
```bash
|
||||
sudo apt install dpkg-dev
|
||||
```
|
||||
|
||||
2. Create repository structure:
|
||||
```bash
|
||||
mkdir -p ~/socktop-repo/pool/main
|
||||
cp *.deb ~/socktop-repo/pool/main/
|
||||
```
|
||||
|
||||
3. Generate package index:
|
||||
```bash
|
||||
cd ~/socktop-repo
|
||||
dpkg-scanpackages pool/main /dev/null | gzip -9c > pool/main/Packages.gz
|
||||
```
|
||||
|
||||
4. Serve via HTTP (for testing):
|
||||
```bash
|
||||
cd ~/socktop-repo
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
|
||||
5. Add to sources on client machines:
|
||||
```bash
|
||||
echo "deb [trusted=yes] http://your-server:8000 pool/main/" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features that affect packaging:
|
||||
|
||||
1. Update `Cargo.toml` metadata in the `[package.metadata.deb]` section
|
||||
2. Add new assets to the `assets` array if needed
|
||||
3. Update maintainer scripts in `socktop_agent/debian/` if needed
|
||||
4. Test package building locally before committing
|
||||
5. Update this documentation
|
||||
|
||||
## References
|
||||
|
||||
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
|
||||
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
|
||||
- [systemd service files](https://www.freedesktop.org/software/systemd/man/systemd.service.html)
|
||||
+13
-10
@@ -2,6 +2,8 @@
|
||||
|
||||
This guide explains how to cross-compile the socktop_agent on various host systems and deploy it to a Raspberry Pi. Cross-compiling is particularly useful for older or resource-constrained Pi models where native compilation might be slow.
|
||||
|
||||
**Note:** GPU monitoring support is not available on ARMv7 (32-bit) and RISC-V architectures due to library limitations. When building for these platforms, the `--no-default-features` flag must be used to disable GPU support.
|
||||
|
||||
## Cross-Compilation Host Setup
|
||||
|
||||
Choose your host operating system:
|
||||
@@ -23,8 +25,9 @@ sudo apt update
|
||||
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
|
||||
|
||||
# For 32-bit Raspberry Pi (armv7)
|
||||
# Note: GPU support not available on armv7
|
||||
sudo apt update
|
||||
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross libdrm-dev:armhf
|
||||
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
||||
```
|
||||
|
||||
### Setup Rust Cross-Compilation Targets
|
||||
@@ -65,9 +68,8 @@ sudo pacman -S aarch64-linux-gnu-gcc
|
||||
yay -S aarch64-linux-gnu-libdrm
|
||||
|
||||
# For 32-bit Raspberry Pi (armv7)
|
||||
# Note: GPU support not available on armv7
|
||||
sudo pacman -S arm-linux-gnueabihf-gcc
|
||||
# Install libdrm for armv7 using an AUR helper
|
||||
yay -S arm-linux-gnueabihf-libdrm
|
||||
```
|
||||
|
||||
### Setup Rust Cross-Compilation Targets
|
||||
@@ -114,8 +116,8 @@ cd path/to/socktop
|
||||
# For 64-bit Raspberry Pi
|
||||
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:aarch64-musl cargo build --release --target aarch64-unknown-linux-musl -p socktop_agent
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent
|
||||
# For 32-bit Raspberry Pi (without GPU support)
|
||||
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
The compiled binaries will be available in your local target directory.
|
||||
@@ -133,11 +135,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:
|
||||
|
||||
```bash
|
||||
# For 64-bit Raspberry Pi
|
||||
# For 64-bit Raspberry Pi (with GPU support)
|
||||
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
|
||||
|
||||
# For 32-bit Raspberry Pi
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent
|
||||
# For 32-bit Raspberry Pi (without GPU support)
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
## Transfer the Binary to Your Raspberry Pi
|
||||
@@ -161,11 +163,12 @@ SSH into your Raspberry Pi and install the required dependencies:
|
||||
```bash
|
||||
ssh pi@raspberry-pi-ip
|
||||
|
||||
# For Raspberry Pi OS (Debian-based)
|
||||
# For Raspberry Pi OS (Debian-based) - 64-bit only
|
||||
# (32-bit armv7 builds don't require these)
|
||||
sudo apt update
|
||||
sudo apt install libdrm-dev libdrm-amdgpu1
|
||||
|
||||
# For Arch Linux ARM
|
||||
# For Arch Linux ARM - 64-bit only
|
||||
sudo pacman -Syu
|
||||
sudo pacman -S libdrm
|
||||
```
|
||||
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build socktop + socktop_agent from source and install them.
|
||||
#
|
||||
# Works on Linux (x86_64, arm64/armv7, riscv64) and macOS. Handles fresh
|
||||
# installs and upgrades; if a systemd socktop-agent service is present, its
|
||||
# binary is replaced in place and the service restarted.
|
||||
#
|
||||
# ./scripts/install.sh # build HEAD of the repo you're in
|
||||
# ./scripts/install.sh --ref v1.60.0 # build a tag/branch (clones if needed)
|
||||
# ./scripts/install.sh --ref master # or any branch
|
||||
# ./scripts/install.sh --prefix ~/.local/bin --no-service
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://github.com/jasonwitty/socktop.git"
|
||||
REF=""
|
||||
PREFIX=""
|
||||
NO_SERVICE=0
|
||||
SRC_DIR="${SOCKTOP_SRC_DIR:-$HOME/.cache/socktop-src}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ref) REF="$2"; shift 2 ;;
|
||||
--prefix) PREFIX="$2"; shift 2 ;;
|
||||
--no-service) NO_SERVICE=1; shift ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# The entire remainder runs inside main(), invoked on the LAST line. This
|
||||
# makes the script safe against being MODIFIED WHILE RUNNING: when executed
|
||||
# from the clone it manages, the git checkout below replaces this very file,
|
||||
# and bash reads scripts lazily by byte offset — without this wrapper it
|
||||
# resumes parsing the NEW file at the OLD offset and executes an arbitrary
|
||||
# tail of it (observed: the fresh-service path ran on a host whose unit
|
||||
# already existed). With main(), the whole script is parsed before any of
|
||||
# it executes.
|
||||
main() {
|
||||
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
# ---------- toolchain ----------
|
||||
command -v git >/dev/null || die "git is required"
|
||||
if ! command -v cargo >/dev/null; then
|
||||
# rustup may be installed but not on PATH in this shell
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
fi
|
||||
if ! command -v cargo >/dev/null; then
|
||||
say "Rust toolchain not found — installing via rustup (stable, default profile)"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
. "$HOME/.cargo/env"
|
||||
fi
|
||||
command -v cc >/dev/null || warn "no C compiler found (apt: build-essential / brew: xcode-select --install) — the build may fail"
|
||||
case "$ARCH" in
|
||||
riscv64*)
|
||||
# protoc-bin-vendored ships no riscv64 binary; the build falls back to
|
||||
# the system protoc (see build.rs).
|
||||
command -v protoc >/dev/null || die "riscv64 needs a system protoc: sudo apt install protobuf-compiler"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------- source ----------
|
||||
# If run from inside a socktop checkout and no --ref given, build that tree
|
||||
# as-is (whatever is checked out, including local changes).
|
||||
if [ -z "$REF" ] && git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
&& grep -qs '^name = "socktop"' "$(git rev-parse --show-toplevel)/socktop/Cargo.toml" 2>/dev/null; then
|
||||
SRC_DIR="$(git rev-parse --show-toplevel)"
|
||||
say "Building the current checkout: $SRC_DIR ($(git -C "$SRC_DIR" describe --always --dirty 2>/dev/null))"
|
||||
else
|
||||
REF="${REF:-master}"
|
||||
if [ ! -d "$SRC_DIR/.git" ]; then
|
||||
say "Cloning $REPO_URL -> $SRC_DIR"
|
||||
git clone "$REPO_URL" "$SRC_DIR"
|
||||
fi
|
||||
say "Checking out $REF"
|
||||
git -C "$SRC_DIR" fetch --tags origin
|
||||
git -C "$SRC_DIR" checkout -q "$REF"
|
||||
# fast-forward when REF is a branch
|
||||
git -C "$SRC_DIR" merge --ff-only "origin/$REF" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ---------- build ----------
|
||||
say "Building release binaries (this can take a while on SBCs)"
|
||||
( cd "$SRC_DIR" && cargo build --release -p socktop -p socktop_agent )
|
||||
CLIENT="$SRC_DIR/target/release/socktop"
|
||||
AGENT="$SRC_DIR/target/release/socktop_agent"
|
||||
|
||||
# ---------- install ----------
|
||||
if [ -z "$PREFIX" ]; then
|
||||
PREFIX="/usr/local/bin"
|
||||
fi
|
||||
SUDO=""
|
||||
if [ ! -w "$PREFIX" ]; then
|
||||
if command -v sudo >/dev/null; then SUDO="sudo"; else
|
||||
PREFIX="$HOME/.local/bin"; mkdir -p "$PREFIX"
|
||||
warn "no sudo — installing to $PREFIX (ensure it is on your PATH)"
|
||||
fi
|
||||
fi
|
||||
say "Installing to $PREFIX"
|
||||
$SUDO install -m 755 "$CLIENT" "$PREFIX/socktop"
|
||||
$SUDO install -m 755 "$AGENT" "$PREFIX/socktop_agent"
|
||||
|
||||
# Update every other copy on PATH as well. A stale `cargo install` in
|
||||
# ~/.cargo/bin would otherwise SHADOW the fresh binary (~/.cargo/bin
|
||||
# usually precedes /usr/local/bin on PATH), leaving `socktop --version`
|
||||
# stuck on the old release after a "successful" install.
|
||||
update_path_copies() {
|
||||
local name="$1" src="$2" copy dir
|
||||
# type -ap lists every match on PATH (bash builtin, symlinks not resolved)
|
||||
for copy in $(type -ap "$name" | sort -u); do
|
||||
[ "$copy" = "$PREFIX/$name" ] && continue
|
||||
dir="$(dirname "$copy")"
|
||||
say "Updating additional copy on PATH: $copy"
|
||||
if [ -w "$copy" ] || [ -w "$dir" ]; then
|
||||
install -m 755 "$src" "$copy"
|
||||
else
|
||||
# Non-fatal: an un-updatable extra copy shouldn't kill the install,
|
||||
# but the user must know it may shadow the fresh binary.
|
||||
$SUDO install -m 755 "$src" "$copy" || warn "could not update $copy — it may shadow $PREFIX/$name"
|
||||
fi
|
||||
done
|
||||
}
|
||||
update_path_copies socktop "$CLIENT"
|
||||
update_path_copies socktop_agent "$AGENT"
|
||||
|
||||
# ---------- systemd service (Linux only) ----------
|
||||
# System-level operations (unit files, users, service control) need root no
|
||||
# matter where the binaries were installed — decide independently of PREFIX.
|
||||
SYS_SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if command -v sudo >/dev/null; then SYS_SUDO="sudo"; else SYS_SUDO="__none__"; fi
|
||||
fi
|
||||
if [ "$SYS_SUDO" = "__none__" ] && [ "$NO_SERVICE" -eq 0 ]; then
|
||||
warn "no sudo available — skipping systemd service management"
|
||||
NO_SERVICE=1
|
||||
fi
|
||||
if [ "$OS" = "Linux" ] && [ "$NO_SERVICE" -eq 0 ] && command -v systemctl >/dev/null; then
|
||||
if systemctl cat socktop-agent.service >/dev/null 2>&1; then
|
||||
# UPGRADE: the unit file is the operator's (SSL, tokens, ports may be
|
||||
# configured there) — never overwrite it. Only the binary it points at
|
||||
# is replaced, then the service is restarted.
|
||||
say "Existing socktop-agent.service found — preserving unit file, refreshing binary"
|
||||
UNIT_BIN="$(systemctl show -p ExecStart socktop-agent.service 2>/dev/null \
|
||||
| sed -n 's/.*path=\([^ ;]*\).*/\1/p' | head -1)"
|
||||
if [ -n "$UNIT_BIN" ] && [ "$UNIT_BIN" != "$PREFIX/socktop_agent" ]; then
|
||||
$SYS_SUDO systemctl stop socktop-agent.service
|
||||
$SYS_SUDO install -m 755 "$AGENT" "$UNIT_BIN"
|
||||
$SYS_SUDO systemctl start socktop-agent.service
|
||||
else
|
||||
$SYS_SUDO systemctl restart socktop-agent.service
|
||||
fi
|
||||
else
|
||||
# FRESH INSTALL: unit + the system user it runs as + its state dir,
|
||||
# then enable and start. Mirrors the deb package's postinst and
|
||||
# https://www.socktop.io/assets/docs/installation/agent-service.html
|
||||
say "No socktop-agent.service found — installing and enabling it"
|
||||
|
||||
if ! getent group socktop >/dev/null; then
|
||||
$SYS_SUDO groupadd --system socktop
|
||||
fi
|
||||
if ! getent passwd socktop >/dev/null; then
|
||||
NOLOGIN="$(command -v nologin || echo /usr/sbin/nologin)"
|
||||
$SYS_SUDO useradd --system -g socktop -d /var/lib/socktop -M -s "$NOLOGIN" socktop
|
||||
fi
|
||||
$SYS_SUDO mkdir -p /var/lib/socktop
|
||||
$SYS_SUDO chown socktop:socktop /var/lib/socktop
|
||||
$SYS_SUDO chmod 755 /var/lib/socktop
|
||||
|
||||
UNIT_TMP="$(mktemp)"
|
||||
if [ -f "$SRC_DIR/docs/socktop-agent.service" ]; then
|
||||
cp "$SRC_DIR/docs/socktop-agent.service" "$UNIT_TMP"
|
||||
else
|
||||
# Fallback for refs that predate docs/socktop-agent.service
|
||||
cat > "$UNIT_TMP" <<'UNIT'
|
||||
[Unit]
|
||||
Description=Socktop agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/socktop_agent --port 3000
|
||||
Environment=RUST_LOG=info
|
||||
# Optional auth:
|
||||
# Environment=SOCKTOP_TOKEN=changeme
|
||||
# TLS (self-signed cert on first run, default port 8443):
|
||||
# Environment=SOCKTOP_ENABLE_SSL=1
|
||||
Restart=on-failure
|
||||
User=socktop
|
||||
Group=socktop
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
fi
|
||||
# Pick the agent port: 3000 by default, but NEVER bind onto a port that
|
||||
# something else already holds (e.g. Gitea/Umami and friends love 3000)
|
||||
# — that puts the fresh service straight into a crash-restart loop.
|
||||
AGENT_PORT=""
|
||||
for p in 3000 3001 3010 3231 3232; do
|
||||
if ! ss -tln 2>/dev/null | awk '{print $4}' | grep -q ":${p}\$"; then
|
||||
AGENT_PORT="$p"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$AGENT_PORT" ]; then
|
||||
AGENT_PORT=3000
|
||||
warn "no free port among the defaults — using 3000; edit the unit if the service fails to start"
|
||||
elif [ "$AGENT_PORT" != "3000" ]; then
|
||||
warn "port 3000 is already in use by another service — configuring the agent on port $AGENT_PORT"
|
||||
fi
|
||||
|
||||
# Point ExecStart at wherever this run installed the agent, on the chosen port.
|
||||
sed -i.bak -e "s|^ExecStart=[^ ]*socktop_agent|ExecStart=$PREFIX/socktop_agent|" \
|
||||
-e "s|--port [0-9]*|--port $AGENT_PORT|" "$UNIT_TMP"
|
||||
rm -f "$UNIT_TMP.bak"
|
||||
|
||||
$SYS_SUDO install -o root -g root -m 0644 "$UNIT_TMP" /etc/systemd/system/socktop-agent.service
|
||||
rm -f "$UNIT_TMP"
|
||||
$SYS_SUDO systemctl daemon-reload
|
||||
$SYS_SUDO systemctl enable --now socktop-agent.service
|
||||
say "Service installed — agent URL: ws://$(hostname):$AGENT_PORT/ws"
|
||||
say "To enable TLS or a token, edit /etc/systemd/system/socktop-agent.service, then: sudo systemctl daemon-reload && sudo systemctl restart socktop-agent"
|
||||
fi
|
||||
sleep 1
|
||||
systemctl --no-pager -l status socktop-agent.service | head -5 || true
|
||||
fi
|
||||
|
||||
say "Installed:"
|
||||
"$PREFIX/socktop" --version
|
||||
"$PREFIX/socktop_agent" --version
|
||||
say "Active on PATH: $(type -p socktop || true) / $(type -p socktop_agent || true)"
|
||||
socktop --version
|
||||
|
||||
}
|
||||
|
||||
# exit in the same parse unit as the call: after main returns, bash must not
|
||||
# read another byte from this (possibly replaced) file.
|
||||
main "$@"; exit $?
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Sync this repo to the 'gitea' remote as a mirror.
|
||||
# - Mirrors ALL refs (branches, tags) and prunes removed ones.
|
||||
# - This makes the Gitea repo match GitHub exactly.
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "Error: not inside a git repo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git remote get-url gitea >/dev/null 2>&1; then
|
||||
echo "Missing 'gitea' remote. Add it with:" >&2
|
||||
echo " git remote add gitea https://gt.wittyoneoff.com/jason/socktop.git" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching from origin (pruning)..."
|
||||
git fetch origin --prune --tags
|
||||
|
||||
echo "Pushing mirror to gitea..."
|
||||
git push gitea --mirror
|
||||
|
||||
echo "Done: Gitea should now match origin (GitHub)."
|
||||
|
||||
+23
-4
@@ -1,15 +1,17 @@
|
||||
[package]
|
||||
name = "socktop"
|
||||
version = "1.40.0"
|
||||
version = "1.60.0"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
description = "Remote system monitor over WebSocket, TUI like top"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
homepage = "https://github.com/jasonwitty/socktop"
|
||||
repository = "https://github.com/jasonwitty/socktop"
|
||||
|
||||
[dependencies]
|
||||
# socktop connector for agent communication
|
||||
socktop_connector = { path = "../socktop_connector" }
|
||||
socktop_connector = { version = "1.60.0", path = "../socktop_connector" }
|
||||
|
||||
tokio = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -18,10 +20,27 @@ serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
dirs-next = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
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"],
|
||||
]
|
||||
|
||||
+887
-511
File diff suppressed because it is too large
Load Diff
+16
-7
@@ -2,16 +2,25 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||
if dq.len() == cap {
|
||||
dq.pop_front();
|
||||
}
|
||||
/// Push a value into a capped deque. Returns the evicted front element if any.
|
||||
/// Callers maintaining a running sum can use this to update the sum without
|
||||
/// re-iterating the whole deque.
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> {
|
||||
let evicted = if dq.len() == cap {
|
||||
dq.pop_front()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
dq.push_back(v);
|
||||
evicted
|
||||
}
|
||||
|
||||
// Keeps a history deque per core with a fixed capacity
|
||||
// Keeps a history deque per core with a fixed capacity.
|
||||
// Storage is u64 so sparkline rendering can hand the slice directly to
|
||||
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
|
||||
// allocation or widening conversion.
|
||||
pub struct PerCoreHistory {
|
||||
pub deques: Vec<VecDeque<u16>>,
|
||||
pub deques: Vec<VecDeque<u64>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
@@ -35,7 +44,7 @@ impl PerCoreHistory {
|
||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||
self.ensure_cores(samples.len());
|
||||
for (i, v) in samples.iter().enumerate() {
|
||||
let val = v.clamp(0.0, 100.0).round() as u16;
|
||||
let val = v.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.deques[i], val, self.cap);
|
||||
}
|
||||
}
|
||||
|
||||
+77
-12
@@ -22,6 +22,7 @@ pub(crate) struct ParsedArgs {
|
||||
metrics_interval_ms: Option<u64>,
|
||||
processes_interval_ms: Option<u64>,
|
||||
verify_hostname: bool,
|
||||
compact: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||
@@ -36,11 +37,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
let mut metrics_interval_ms: Option<u64> = None;
|
||||
let mut processes_interval_ms: Option<u64> = None;
|
||||
let mut verify_hostname = false;
|
||||
let mut compact = false;
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => {
|
||||
return Err(format!(
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
));
|
||||
}
|
||||
"--tls-ca" | "-t" => {
|
||||
@@ -61,6 +63,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
"--demo" => {
|
||||
demo = true;
|
||||
}
|
||||
"--compact" => {
|
||||
// Force the small-window layout at any terminal size. Without it the
|
||||
// layout switches on its own once the window gets too short.
|
||||
compact = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
// intentionally undocumented
|
||||
dry_run = true;
|
||||
@@ -100,7 +107,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
url = Some(arg);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -116,6 +123,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
metrics_interval_ms,
|
||||
processes_interval_ms,
|
||||
verify_hostname,
|
||||
compact,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
}
|
||||
|
||||
let profiles_file = load_profiles();
|
||||
@@ -241,7 +249,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if (1..=names.len()).contains(&idx) {
|
||||
let name = &names[idx - 1];
|
||||
if name == "demo" {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
}
|
||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||
(
|
||||
@@ -301,7 +309,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.");
|
||||
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
} else {
|
||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||
return Ok(());
|
||||
@@ -315,7 +323,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let has_token = url.contains("token=");
|
||||
let mut app = App::new()
|
||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||
.with_status(is_tls, has_token);
|
||||
.with_status(is_tls, has_token)
|
||||
.with_compact(parsed.compact);
|
||||
if parsed.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -379,11 +388,23 @@ fn gather_intervals(
|
||||
}
|
||||
|
||||
// Demo mode implementation
|
||||
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn run_demo_mode(
|
||||
_tls_ca: Option<&str>,
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
let child = spawn_demo_agent(port)?;
|
||||
let mut app = App::new();
|
||||
let child = match spawn_demo_agent(port) {
|
||||
Ok(child) => child,
|
||||
// The agent ships as its own binary, so a missing one is a setup problem,
|
||||
// not a crash: tell the user how to fix it instead of dumping an io error.
|
||||
Err(e @ DemoAgentError::NotFound(_)) => {
|
||||
eprintln!("{e}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut app = App::new().with_compact(compact);
|
||||
// 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(()) } }
|
||||
}
|
||||
@@ -399,9 +420,50 @@ impl Drop for DemoGuard {
|
||||
eprintln!("Stopped demo agent on port {}", self.port);
|
||||
}
|
||||
}
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||
#[derive(Debug)]
|
||||
enum DemoAgentError {
|
||||
/// The socktop_agent executable could not be located.
|
||||
NotFound(std::path::PathBuf),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DemoAgentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(candidate) => write!(
|
||||
f,
|
||||
"Could not start demo mode: '{}' was not found{}.\n\
|
||||
\n\
|
||||
Demo mode runs a local agent, which is shipped as a separate binary\n\
|
||||
and is not installed alongside the socktop TUI. Install it with:\n\
|
||||
\n cargo install socktop_agent\n\n\
|
||||
then run socktop again. See {} for other install options.",
|
||||
candidate.display(),
|
||||
// A bare file name means find_agent_executable() fell back to a PATH lookup.
|
||||
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
|
||||
" on your PATH"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
env!("CARGO_PKG_HOMEPAGE"),
|
||||
),
|
||||
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DemoAgentError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::NotFound(_) => None,
|
||||
Self::Io(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
|
||||
let candidate = find_agent_executable();
|
||||
let mut cmd = std::process::Command::new(candidate);
|
||||
let mut cmd = std::process::Command::new(&candidate);
|
||||
cmd.arg("--port").arg(port.to_string());
|
||||
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
||||
|
||||
@@ -409,7 +471,10 @@ fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>>
|
||||
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
||||
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let child = cmd.spawn().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
|
||||
_ => DemoAgentError::Io(e),
|
||||
})?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
Ok(DemoGuard {
|
||||
port,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+351
-66
@@ -7,11 +7,17 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||
widgets::{
|
||||
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::history::PerCoreHistory;
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::fit::{cols, pick_pair};
|
||||
|
||||
/// Columns kept clear between the CPU title and the temperature readout.
|
||||
const TITLE_GAP: u16 = 2;
|
||||
|
||||
/// State for dragging the scrollbar thumb
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -42,8 +48,8 @@ pub fn per_core_content_area(area: Rect) -> Rect {
|
||||
/// Handles key events for per-core CPU bars.
|
||||
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
|
||||
match key.code {
|
||||
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::PageUp => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
@@ -133,11 +139,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
}
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let top_for_offset = |off: usize| -> usize {
|
||||
if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * off + max_off / 2) / max_off
|
||||
}
|
||||
((track - thumb_len) * off + max_off / 2)
|
||||
.checked_div(max_off)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let thumb_top = top_for_offset(offset);
|
||||
|
||||
@@ -190,11 +194,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
// Inverse mapping top -> offset
|
||||
if track > thumb_len {
|
||||
let denom = track - thumb_len;
|
||||
offset = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
(new_top * max_off + denom / 2) / denom
|
||||
};
|
||||
offset = (new_top * max_off + denom / 2)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0);
|
||||
} else {
|
||||
offset = 0;
|
||||
}
|
||||
@@ -234,26 +236,117 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
|
||||
}
|
||||
|
||||
/// Draws the CPU average sparkline graph.
|
||||
///
|
||||
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
|
||||
/// fold the (up to 600-element) deque on every frame.
|
||||
pub fn draw_cpu_avg_graph(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
hist: &mut std::collections::VecDeque<u64>,
|
||||
hist_sum: u64,
|
||||
m: Option<&Metrics>,
|
||||
) {
|
||||
let title = if let Some(mm) = m {
|
||||
format!("CPU avg (now: {:>5.1}%)", mm.cpu_total)
|
||||
let avg_cpu = if hist.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
"CPU avg".into()
|
||||
hist_sum as f64 / hist.len() as f64
|
||||
};
|
||||
|
||||
let (title, top_right_info) = cpu_title_for_width(
|
||||
m.map(|mm| mm.cpu_total),
|
||||
avg_cpu,
|
||||
m.and_then(|mm| mm.cpu_temp_c),
|
||||
area.width,
|
||||
);
|
||||
|
||||
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||
// us skip the per-frame Vec allocation .collect() used to do.
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
|
||||
// Temperature overlays the top border, right-aligned inside the corner. The title
|
||||
// above is sized so the two cannot collide.
|
||||
if !top_right_info.is_empty() {
|
||||
let w = cols(&top_right_info);
|
||||
let info_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(w + 1),
|
||||
y: area.y,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
let info_line = Line::from(Span::raw(top_right_info));
|
||||
f.render_widget(Paragraph::new(info_line), info_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Health glyph for a CPU temperature.
|
||||
fn temp_icon(t: f32) -> &'static str {
|
||||
if t < 50.0 {
|
||||
"😎"
|
||||
} else if t < 85.0 {
|
||||
"⚠️"
|
||||
} else {
|
||||
"🔥"
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the CPU pane's title and its right-aligned temperature readout for a pane
|
||||
/// `width` columns wide.
|
||||
///
|
||||
/// Both are painted onto the pane's top border, so without a shared budget the
|
||||
/// temperature simply overwrites the tail of the title on a narrow pane. Detail is given
|
||||
/// up in this order: the `CPU Temp:` label, then the `now:`/`avg:` labels, then the
|
||||
/// average reading, then the decimal on the temperature, and only last the temperature
|
||||
/// itself — the readings are what the pane is for, but a thermal warning is worth more
|
||||
/// than a second decimal place.
|
||||
fn cpu_title_for_width(
|
||||
cpu_now: Option<f32>,
|
||||
avg_cpu: f64,
|
||||
temp_c: Option<f32>,
|
||||
width: u16,
|
||||
) -> (String, String) {
|
||||
let Some(now) = cpu_now else {
|
||||
return ("CPU avg".into(), String::new());
|
||||
};
|
||||
|
||||
// Two borders, plus a column of breathing room at each end of the title.
|
||||
let budget = width.saturating_sub(4);
|
||||
|
||||
let labelled = format!("CPU (now: {now:>5.1}% | avg: {avg_cpu:>5.1}%)");
|
||||
let bare = format!("CPU ({now:.1}% | {avg_cpu:.1}%)");
|
||||
let now_only = format!("CPU ({now:.1}%)");
|
||||
|
||||
let (temp_labelled, temp_plain, temp_coarse) = match temp_c {
|
||||
Some(t) => {
|
||||
let icon = temp_icon(t);
|
||||
(
|
||||
format!("CPU Temp: {t:.1}°C {icon}"),
|
||||
format!("{t:.1}°C {icon}"),
|
||||
format!("{t:.0}°C {icon}"),
|
||||
)
|
||||
}
|
||||
None => ("CPU Temp: N/A".into(), "N/A".into(), "N/A".into()),
|
||||
};
|
||||
|
||||
let ladder = [
|
||||
(labelled.as_str(), temp_labelled.as_str()),
|
||||
(labelled.as_str(), temp_plain.as_str()),
|
||||
(bare.as_str(), temp_plain.as_str()),
|
||||
(bare.as_str(), temp_coarse.as_str()),
|
||||
(now_only.as_str(), temp_coarse.as_str()),
|
||||
(now_only.as_str(), ""),
|
||||
];
|
||||
let (title, temp) = pick_pair(budget, TITLE_GAP, &ladder);
|
||||
(title.to_string(), temp.to_string())
|
||||
}
|
||||
|
||||
/// Draws the per-core CPU bars with sparklines and trends.
|
||||
@@ -261,7 +354,7 @@ pub fn draw_per_core_bars(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
per_core_hist: &PerCoreHistory,
|
||||
per_core_hist: &mut PerCoreHistory,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
f.render_widget(
|
||||
@@ -306,7 +399,7 @@ pub fn draw_per_core_bars(
|
||||
let rect = vchunks[i];
|
||||
let hchunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
||||
.split(rect);
|
||||
|
||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||
@@ -317,12 +410,17 @@ pub fn draw_per_core_bars(
|
||||
.map(|v| v as f32)
|
||||
.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 {
|
||||
"↑"
|
||||
} else if curr + 0.2 < older {
|
||||
"↓"
|
||||
} else {
|
||||
"╌"
|
||||
" "
|
||||
};
|
||||
|
||||
let fg = match curr {
|
||||
@@ -331,24 +429,24 @@ pub fn draw_per_core_bars(
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
let hist: Vec<u64> = per_core_hist
|
||||
.deques
|
||||
.get(idx)
|
||||
.map(|d| {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
d.iter().skip(start).map(|&v| v as u64).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
||||
// to Sparkline without allocating a fresh Vec each frame.
|
||||
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
let slice = &d.make_contiguous()[start..];
|
||||
let spark = Sparkline::default()
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
}
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.data(&hist)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
|
||||
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
|
||||
// Hard space between the trend mark and the number — even if the
|
||||
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
||||
// them, this space prevents the trend mark from visually joining
|
||||
// `100.0` to look like a negative value.
|
||||
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
||||
let line = Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
@@ -356,38 +454,225 @@ pub fn draw_per_core_bars(
|
||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||
}
|
||||
|
||||
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
||||
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
||||
// content fits in the viewport, matching the previous behaviour.
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
if scroll_area.height >= 3 {
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let thumb_top = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
||||
};
|
||||
|
||||
// Build lines: top arrow, track (with thumb), bottom arrow
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
|
||||
lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW))));
|
||||
for i in 0..track {
|
||||
if i >= thumb_top && i < thumb_top + thumb_len {
|
||||
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
||||
|
||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.thumb_symbol("█")
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_style(Style::default().fg(SB_THUMB))
|
||||
.track_style(Style::default().fg(SB_TRACK))
|
||||
.begin_style(Style::default().fg(SB_ARROW))
|
||||
.end_style(Style::default().fg(SB_ARROW));
|
||||
let mut state = ScrollbarState::new(max_off).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_tests {
|
||||
use super::*;
|
||||
|
||||
/// The defect this replaces: the temperature was painted over the title's tail on a
|
||||
/// narrow pane. Whatever the width, the two must fit side by side on the border.
|
||||
#[test]
|
||||
fn title_and_temperature_never_overlap() {
|
||||
for width in 0..=200u16 {
|
||||
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||
let budget = width.saturating_sub(4);
|
||||
if temp.is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
|
||||
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current CPU reading is the one thing the pane must always show.
|
||||
#[test]
|
||||
fn the_current_reading_always_survives() {
|
||||
for width in 20..=200u16 {
|
||||
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||
assert!(
|
||||
title.contains("3.4"),
|
||||
"width {width}: lost the reading ({title:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder from the design: temp label, then now/avg labels, then the average,
|
||||
/// then the temperature's decimal, then the temperature.
|
||||
#[test]
|
||||
fn detail_is_dropped_in_priority_order() {
|
||||
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
|
||||
|
||||
let (title, temp) = at(80);
|
||||
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
|
||||
|
||||
// The "CPU Temp:" label goes first; the readings keep their labels.
|
||||
let (title, temp) = at(50);
|
||||
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||
assert_eq!(temp, "43.0°C 😎");
|
||||
|
||||
// Then the now:/avg: labels.
|
||||
let (title, temp) = at(40);
|
||||
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||
assert_eq!(temp, "43.0°C 😎");
|
||||
|
||||
// Then the temperature's decimal.
|
||||
let (title, temp) = at(31);
|
||||
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||
assert_eq!(temp, "43°C 😎");
|
||||
|
||||
// Then the average reading.
|
||||
let (title, temp) = at(26);
|
||||
assert_eq!(title, "CPU (0.7%)");
|
||||
assert_eq!(temp, "43°C 😎");
|
||||
|
||||
// Last of all, the temperature itself.
|
||||
let (title, temp) = at(15);
|
||||
assert_eq!(title, "CPU (0.7%)");
|
||||
assert_eq!(temp, "");
|
||||
}
|
||||
|
||||
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
|
||||
/// reading at every tier that shows a temperature at all.
|
||||
#[test]
|
||||
fn the_thermal_glyph_tracks_the_temperature() {
|
||||
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
|
||||
for width in 26..=80u16 {
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
|
||||
assert!(
|
||||
temp.contains(icon),
|
||||
"width {width} at {t}°C: expected {icon} in {temp:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent that reports no temperature must not leave a stray label behind.
|
||||
#[test]
|
||||
fn a_missing_temperature_degrades_to_nothing() {
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
|
||||
assert_eq!(temp, "CPU Temp: N/A");
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
|
||||
assert_eq!(temp, "");
|
||||
}
|
||||
|
||||
/// Before the first payload arrives there are no readings to show.
|
||||
#[test]
|
||||
fn no_metrics_yet_shows_the_placeholder() {
|
||||
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
|
||||
assert_eq!(title, "CPU avg");
|
||||
assert!(temp.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use socktop_connector::Metrics;
|
||||
|
||||
fn fake_metrics(cores: Vec<f32>) -> Metrics {
|
||||
Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: cores,
|
||||
mem_total: 1024,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: "t".into(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
process_count: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump(terminal: &Terminal<TestBackend>) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area().height {
|
||||
for x in 0..buf.area().width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
|
||||
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
|
||||
/// coverage. When a core sat at exactly 100% the label rendered as
|
||||
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
|
||||
/// literal space for the flat case AND insert a hard space between every
|
||||
/// trend mark and the number, so no glyph substitution can produce a
|
||||
/// "-100" substring. We assert that across flat AND transitioning cores.
|
||||
#[test]
|
||||
fn percore_label_never_renders_as_negative() {
|
||||
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
|
||||
let mut hist = PerCoreHistory::new(60);
|
||||
hist.ensure_cores(4);
|
||||
// First sample: history is empty, no trend on first frame.
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
// Second sample: identical values → flat trend (the user's complaint).
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---flat 100% render---\n{out}");
|
||||
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
|
||||
|
||||
// Decreasing trend at saturation: hist was high, current drops a bit.
|
||||
let mut hist2 = PerCoreHistory::new(60);
|
||||
hist2.ensure_cores(4);
|
||||
for _ in 0..25 {
|
||||
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
|
||||
}
|
||||
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
|
||||
hist2.push_samples(&m2.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---decreasing render---\n{out}");
|
||||
assert!(
|
||||
!out.contains("-100"),
|
||||
"found '-100' in decreasing-trend render"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("-80"),
|
||||
"found '-80' in decreasing-trend render"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-8
@@ -1,7 +1,8 @@
|
||||
//! Disk cards with per-device gauge and title line.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::{disk_icon, human, truncate_middle};
|
||||
use crate::ui::fit::truncate_middle_cols;
|
||||
use crate::ui::util::{disk_icon, human};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
@@ -24,6 +25,9 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplication is performed once on the App side when fresh disk data
|
||||
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
||||
// here would rebuild a HashSet ~10x per refresh for no reason).
|
||||
let per_disk_h = 3u16;
|
||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||
|
||||
@@ -53,23 +57,43 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
ratatui::style::Color::Red
|
||||
};
|
||||
|
||||
// Add indentation for partitions
|
||||
let indent = if d.is_partition { "└─" } else { "" };
|
||||
|
||||
// Add temperature if available
|
||||
let temp_str = d
|
||||
.temperature
|
||||
.map(|t| format!(" {}°C", t.round() as i32))
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = format!(
|
||||
"{} {} {} / {} ({}%)",
|
||||
"{}{}{}{} {} / {} ({}%)",
|
||||
indent,
|
||||
disk_icon(&d.name),
|
||||
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||
truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2),
|
||||
temp_str,
|
||||
human(used),
|
||||
human(d.total),
|
||||
pct
|
||||
);
|
||||
|
||||
// Indent the entire card (block) for partitions to align with └─ prefix (4 chars)
|
||||
let card_indent = if d.is_partition { 4 } else { 0 };
|
||||
let card_rect = Rect {
|
||||
x: slot.x + card_indent,
|
||||
y: slot.y,
|
||||
width: slot.width.saturating_sub(card_indent),
|
||||
height: slot.height,
|
||||
};
|
||||
|
||||
let card = Block::default().borders(Borders::ALL).title(title);
|
||||
f.render_widget(card, *slot);
|
||||
f.render_widget(card, card_rect);
|
||||
|
||||
let inner_card = Rect {
|
||||
x: slot.x + 1,
|
||||
y: slot.y + 1,
|
||||
width: slot.width.saturating_sub(2),
|
||||
height: slot.height.saturating_sub(2),
|
||||
x: card_rect.x + 1,
|
||||
y: card_rect.y + 1,
|
||||
width: card_rect.width.saturating_sub(2),
|
||||
height: card_rect.height.saturating_sub(2),
|
||||
};
|
||||
if inner_card.height == 0 {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Fitting text to the columns actually available.
|
||||
//!
|
||||
//! Several panes paint two independent pieces of text onto one row — a left title and a
|
||||
//! right-aligned readout. Nothing reserves space for the right piece, so on a narrow
|
||||
//! terminal the right one is simply painted over the tail of the left one and the title
|
||||
//! is clobbered mid-word. The helpers here let a caller measure in real terminal columns
|
||||
//! and pick the richest wording that still fits, so the two never overlap.
|
||||
//!
|
||||
//! Note that `str::len()` is a byte count and must not be used for this: `⏱` is three
|
||||
//! bytes wide but one column, and `🔒` is four bytes but two columns.
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Terminal columns `s` occupies, saturating at `u16::MAX`.
|
||||
pub fn cols(s: &str) -> u16 {
|
||||
UnicodeWidthStr::width(s).min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
/// Shortens `s` to at most `max` columns, marking the cut with `…`.
|
||||
///
|
||||
/// Cuts on character boundaries and accounts for wide characters, so the result never
|
||||
/// exceeds `max` columns and never splits a multi-byte character.
|
||||
pub fn truncate_cols(s: &str, max: u16) -> String {
|
||||
if cols(s) <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// Reserve one column for the ellipsis.
|
||||
let budget = max.saturating_sub(1);
|
||||
let mut used = 0u16;
|
||||
let mut out = String::new();
|
||||
for ch in s.chars() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > budget {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
out.push(ch);
|
||||
}
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// Shortens `s` to at most `max` columns by cutting the MIDDLE, marking the
|
||||
/// cut with `…` — device names like `/dev/nvme0n1p1` keep their distinctive
|
||||
/// prefix and suffix. Column- and char-boundary-safe; the byte-slicing
|
||||
/// predecessor in `util.rs` panicked on non-ASCII names.
|
||||
pub fn truncate_middle_cols(s: &str, max: u16) -> String {
|
||||
if cols(s) <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max <= 1 {
|
||||
return truncate_cols(s, max);
|
||||
}
|
||||
// Reserve one column for the ellipsis; split the rest left/right.
|
||||
let left_budget = (max - 1) / 2;
|
||||
let right_budget = max - 1 - left_budget;
|
||||
|
||||
let mut left_end = 0; // byte index
|
||||
let mut used = 0u16;
|
||||
for (i, ch) in s.char_indices() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > left_budget {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
left_end = i + ch.len_utf8();
|
||||
}
|
||||
|
||||
let mut right_start = s.len();
|
||||
let mut used = 0u16;
|
||||
for (i, ch) in s.char_indices().rev() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > right_budget || i < left_end {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
right_start = i;
|
||||
}
|
||||
|
||||
format!("{}…{}", &s[..left_end], &s[right_start..])
|
||||
}
|
||||
|
||||
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
|
||||
/// with at least `gap` columns between them.
|
||||
///
|
||||
/// Candidates are ordered most- to least-detailed; the last one is the floor and is
|
||||
/// returned even if it does not fit, so callers always get something to render.
|
||||
pub fn pick_pair<'a>(
|
||||
width: u16,
|
||||
gap: u16,
|
||||
candidates: &[(&'a str, &'a str)],
|
||||
) -> (&'a str, &'a str) {
|
||||
let fits = |left: &str, right: &str| {
|
||||
let needed = cols(left)
|
||||
.saturating_add(cols(right))
|
||||
.saturating_add(if right.is_empty() { 0 } else { gap });
|
||||
needed <= width
|
||||
};
|
||||
for &(left, right) in candidates {
|
||||
if fits(left, right) {
|
||||
return (left, right);
|
||||
}
|
||||
}
|
||||
candidates.last().copied().unwrap_or(("", ""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bug these helpers exist to prevent: byte length overstates the width of the
|
||||
/// glyphs socktop puts in its header, which is what pushed the right-hand text into
|
||||
/// the title in the first place.
|
||||
#[test]
|
||||
fn cols_counts_columns_not_bytes() {
|
||||
assert_eq!(cols("abc"), 3);
|
||||
// Stopwatch: 3 bytes, 1 column.
|
||||
assert_eq!("⏱".len(), 3);
|
||||
assert_eq!(cols("⏱"), 1);
|
||||
// Lock: 4 bytes, 2 columns.
|
||||
assert_eq!("🔒".len(), 4);
|
||||
assert_eq!(cols("🔒"), 2);
|
||||
assert_eq!(cols("⏱ 500ms metrics | 2000ms procs"), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_respects_the_column_budget() {
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 20), "cachyos-gaming");
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 14), "cachyos-gaming");
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 10), "cachyos-g…");
|
||||
assert_eq!(cols(&truncate_cols("cachyos-gaming", 10)), 10);
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 1), "…");
|
||||
assert_eq!(truncate_cols("cachyos-gaming", 0), "");
|
||||
}
|
||||
|
||||
/// Truncation must never land mid-character or overrun the budget on wide glyphs.
|
||||
#[test]
|
||||
fn truncate_handles_wide_and_multibyte_characters() {
|
||||
for max in 0..12u16 {
|
||||
let out = truncate_cols("🔒🔒🔒 TLS", max);
|
||||
assert!(cols(&out) <= max, "{out:?} exceeds {max} columns");
|
||||
assert!(out.chars().all(|c| c != '\u{fffd}'), "{out:?} split a char");
|
||||
}
|
||||
// A wide glyph that cannot fit beside the ellipsis is dropped whole.
|
||||
assert_eq!(truncate_cols("🔒ab", 2), "…");
|
||||
}
|
||||
|
||||
/// Middle truncation keeps both ends — the parts that identify a device —
|
||||
/// and must never exceed the budget or split a character.
|
||||
#[test]
|
||||
fn truncate_middle_keeps_both_ends_within_budget() {
|
||||
assert_eq!(truncate_middle_cols("/dev/nvme0n1p1", 20), "/dev/nvme0n1p1");
|
||||
let out = truncate_middle_cols("/dev/nvme0n1p1", 9);
|
||||
assert_eq!(cols(&out), 9);
|
||||
assert!(out.starts_with("/dev"), "{out}");
|
||||
assert!(out.ends_with("1p1"), "{out}");
|
||||
assert!(out.contains('…'), "{out}");
|
||||
// Non-ASCII names must not panic (the old byte-slicing version did).
|
||||
for max in 0..12u16 {
|
||||
let out = truncate_middle_cols("диск-🗄️-данные", max);
|
||||
assert!(cols(&out) <= max.max(1), "{out:?} exceeds {max}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_pair_takes_the_richest_that_fits() {
|
||||
let candidates = [
|
||||
("full left text", "full right text"),
|
||||
("left text", "right text"),
|
||||
("left", "right"),
|
||||
];
|
||||
assert_eq!(pick_pair(80, 2, &candidates), candidates[0]);
|
||||
assert_eq!(pick_pair(24, 2, &candidates), candidates[1]);
|
||||
assert_eq!(pick_pair(12, 2, &candidates), candidates[2]);
|
||||
// Below the floor the last candidate is still returned.
|
||||
assert_eq!(pick_pair(1, 2, &candidates), candidates[2]);
|
||||
}
|
||||
|
||||
/// The gap is what keeps the two pieces from touching; it must not be charged when
|
||||
/// there is no right-hand piece to separate.
|
||||
#[test]
|
||||
fn pick_pair_only_charges_the_gap_when_both_sides_are_present() {
|
||||
let candidates = [("0123456789", "x"), ("0123456789", "")];
|
||||
assert_eq!(pick_pair(11, 2, &candidates), candidates[1]);
|
||||
assert_eq!(pick_pair(13, 2, &candidates), candidates[0]);
|
||||
}
|
||||
}
|
||||
@@ -121,3 +121,210 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
+226
-46
@@ -1,52 +1,232 @@
|
||||
//! Top header with hostname and CPU temperature indicator.
|
||||
//! Top header with hostname, connection status and polling intervals.
|
||||
//!
|
||||
//! The row carries two pieces of text — session identity on the left, polling intervals
|
||||
//! on the right — and both matter. Rather than let the right one overwrite the left when
|
||||
//! they no longer both fit, the header drops detail in priority order: the hostname and
|
||||
//! the intervals are what survive longest, because they are what tells you *which* host
|
||||
//! you are looking at and how fresh the numbers are.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::fit::{cols, pick_pair, truncate_cols};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn draw_header(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
is_tls: bool,
|
||||
has_token: bool,
|
||||
metrics_interval: Duration,
|
||||
procs_interval: Duration,
|
||||
) {
|
||||
let base = if let Some(mm) = m {
|
||||
let temp = mm
|
||||
.cpu_temp_c
|
||||
.map(|t| {
|
||||
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());
|
||||
format!("socktop — host: {} | {}", mm.hostname, temp)
|
||||
} else {
|
||||
"socktop — connecting...".into()
|
||||
};
|
||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||
// Token indicator
|
||||
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let mut parts = vec![base, tls_txt.into()];
|
||||
if !tok_txt.is_empty() {
|
||||
parts.push(tok_txt.into());
|
||||
}
|
||||
parts.push(intervals);
|
||||
parts.push("(q to quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
/// Columns kept clear between the left and right halves.
|
||||
const GAP: u16 = 2;
|
||||
/// Never shorten the hostname below this before dropping the intervals instead.
|
||||
const HOSTNAME_FLOOR: u16 = 8;
|
||||
|
||||
/// Session state the header renders.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct HeaderState<'a> {
|
||||
pub hostname: Option<&'a str>,
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
pub metrics_ms: u128,
|
||||
pub procs_ms: u128,
|
||||
}
|
||||
|
||||
/// Builds the left and right halves of the header for a row `width` columns wide.
|
||||
///
|
||||
/// Detail is dropped in this order as the row narrows: the key hints, then the TLS/token
|
||||
/// badges, then the `socktop — host:` prefix (leaving the bare hostname), then the
|
||||
/// `metrics`/`procs` words, and only then is the hostname itself shortened. The two
|
||||
/// halves are always sized to sit side by side, so neither can paint over the other.
|
||||
///
|
||||
/// Callers cache the result and rebuild it only when the state or the width changes.
|
||||
pub fn build_header(state: HeaderState<'_>, width: u16) -> (String, String) {
|
||||
let host = state.hostname.unwrap_or("connecting...");
|
||||
let tls = if state.is_tls {
|
||||
"🔒 TLS"
|
||||
} else {
|
||||
"🔒✗ TLS"
|
||||
};
|
||||
let badges = if state.has_token {
|
||||
format!("{tls} | 🔑 token")
|
||||
} else {
|
||||
tls.to_string()
|
||||
};
|
||||
|
||||
let named = format!("socktop — host: {host}");
|
||||
let with_badges = format!("{named} | {badges}");
|
||||
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
|
||||
|
||||
let intervals = format!(
|
||||
"⏱ {}ms metrics | {}ms procs",
|
||||
state.metrics_ms, state.procs_ms
|
||||
);
|
||||
let intervals_short = format!("⏱ {}ms | {}ms", state.metrics_ms, state.procs_ms);
|
||||
|
||||
// Richest first. The bare hostname is reached before the intervals lose their
|
||||
// labels, and the hostname is only shortened once nothing else is left to give.
|
||||
let ladder = [
|
||||
(with_keys.as_str(), intervals.as_str()),
|
||||
(with_badges.as_str(), intervals.as_str()),
|
||||
(named.as_str(), intervals.as_str()),
|
||||
(host, intervals.as_str()),
|
||||
(host, intervals_short.as_str()),
|
||||
];
|
||||
let (left, right) = pick_pair(width, GAP, &ladder);
|
||||
if cols(left) + cols(right) + GAP <= width {
|
||||
return (left.to_string(), right.to_string());
|
||||
}
|
||||
|
||||
// Past the floor of the ladder: shorten the hostname, and give up the intervals only
|
||||
// if even a stub of a hostname will not fit beside them.
|
||||
let room = width
|
||||
.saturating_sub(cols(&intervals_short))
|
||||
.saturating_sub(GAP);
|
||||
if room >= HOSTNAME_FLOOR {
|
||||
return (truncate_cols(host, room), intervals_short);
|
||||
}
|
||||
(truncate_cols(host, width), String::new())
|
||||
}
|
||||
|
||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
|
||||
if intervals.is_empty() {
|
||||
return;
|
||||
}
|
||||
let intervals_width = cols(intervals);
|
||||
if area.width >= intervals_width {
|
||||
let right_area = Rect {
|
||||
x: area.x + area.width - intervals_width,
|
||||
y: area.y,
|
||||
width: intervals_width,
|
||||
height: 1,
|
||||
};
|
||||
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(hostname: Option<&str>) -> HeaderState<'_> {
|
||||
HeaderState {
|
||||
hostname,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
metrics_ms: 500,
|
||||
procs_ms: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The defect this replaces: the two halves were painted independently, so below
|
||||
/// ~105 columns the right half landed on top of the title. Whatever the width, they
|
||||
/// must now fit side by side.
|
||||
#[test]
|
||||
fn halves_never_overlap_at_any_width() {
|
||||
for width in 0..=200u16 {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||
let used = cols(&left) + cols(&right);
|
||||
if right.is_empty() {
|
||||
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
|
||||
} else {
|
||||
assert!(
|
||||
used + GAP <= width,
|
||||
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostname and intervals are the two things worth keeping; everything else is
|
||||
/// context that can go.
|
||||
#[test]
|
||||
fn hostname_and_intervals_survive_longest() {
|
||||
for width in 34..=200u16 {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||
assert!(
|
||||
left.contains("cachyos-gaming"),
|
||||
"width {width}: lost the hostname ({left:?})"
|
||||
);
|
||||
assert!(
|
||||
right.contains("500ms") && right.contains("2000ms"),
|
||||
"width {width}: lost the intervals ({right:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder from the design: key hints, then badges, then the prefix, then the
|
||||
/// interval labels, then the hostname itself.
|
||||
#[test]
|
||||
fn detail_is_dropped_in_priority_order() {
|
||||
let s = state(Some("cachyos-gaming"));
|
||||
|
||||
let (left, right) = build_header(s, 120);
|
||||
assert_eq!(
|
||||
left,
|
||||
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
|
||||
);
|
||||
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||
|
||||
// Key hints go first.
|
||||
let (left, _) = build_header(s, 80);
|
||||
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
|
||||
|
||||
// Then the badges.
|
||||
let (left, _) = build_header(s, 70);
|
||||
assert_eq!(left, "socktop — host: cachyos-gaming");
|
||||
|
||||
// Then the prefix, leaving the bare hostname.
|
||||
let (left, right) = build_header(s, 50);
|
||||
assert_eq!(left, "cachyos-gaming");
|
||||
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||
|
||||
// Then the interval labels.
|
||||
let (left, right) = build_header(s, 34);
|
||||
assert_eq!(left, "cachyos-gaming");
|
||||
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||
|
||||
// Only then is the hostname itself shortened.
|
||||
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
|
||||
let (left, right) = build_header(s, 30);
|
||||
assert_eq!(left, "cachyos-gam…");
|
||||
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||
}
|
||||
|
||||
/// A long hostname must not push the intervals off the row.
|
||||
#[test]
|
||||
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
|
||||
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
|
||||
for width in 30..=100u16 {
|
||||
let (left, right) = build_header(state(Some(long)), width);
|
||||
assert!(!right.is_empty(), "width {width}: intervals were dropped");
|
||||
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Widths too small for both: the hostname is the last thing standing.
|
||||
#[test]
|
||||
fn hostname_is_the_final_survivor() {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
|
||||
assert!(right.is_empty(), "intervals should have been dropped");
|
||||
assert!(!left.is_empty());
|
||||
assert!(cols(&left) <= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_and_token_badges_appear_when_there_is_room() {
|
||||
let s = HeaderState {
|
||||
hostname: Some("host"),
|
||||
is_tls: true,
|
||||
has_token: true,
|
||||
metrics_ms: 500,
|
||||
procs_ms: 2000,
|
||||
};
|
||||
let (left, _) = build_header(s, 200);
|
||||
assert!(left.contains("🔒 TLS"), "{left}");
|
||||
assert!(left.contains("🔑 token"), "{left}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_hostname_reads_as_connecting() {
|
||||
let (left, _) = build_header(state(None), 120);
|
||||
assert!(left.contains("connecting"), "{left}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Root layout computation, shared by the draw path and the input hit-testing paths.
|
||||
//!
|
||||
//! Two modes:
|
||||
//!
|
||||
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
|
||||
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
|
||||
//! network graphs next to the process table.
|
||||
//!
|
||||
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
|
||||
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
|
||||
//! by side into the space it vacated, the GPU collapses to a single full-width line
|
||||
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
|
||||
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
|
||||
//! long before the rest of the panes stop being useful.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Which of the two layouts [`compute`] produced.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LayoutMode {
|
||||
Normal,
|
||||
Compact,
|
||||
}
|
||||
|
||||
impl LayoutMode {
|
||||
pub fn is_compact(self) -> bool {
|
||||
matches!(self, LayoutMode::Compact)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows the Disks pane needs before it can show one disk card: the card itself is
|
||||
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
|
||||
const DISKS_MIN_H: u16 = 5;
|
||||
|
||||
/// Header line.
|
||||
const HEADER_H: u16 = 1;
|
||||
/// Memory and Swap gauges: 1 content row between borders.
|
||||
const GAUGE_H: u16 = 3;
|
||||
/// A network graph at its preferred height.
|
||||
const NET_H: u16 = 5;
|
||||
|
||||
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
|
||||
// borders) before the network graphs are allowed to shrink, because restoring the CPU
|
||||
// panes is the entire point of the mode.
|
||||
const TOP_MIN_H: u16 = 5;
|
||||
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
|
||||
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
|
||||
|
||||
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AppLayout {
|
||||
pub mode: LayoutMode,
|
||||
pub header: Rect,
|
||||
pub cpu: Rect,
|
||||
pub per_core: Rect,
|
||||
pub gpu: Option<Rect>,
|
||||
pub mem: Rect,
|
||||
pub swap: Rect,
|
||||
pub disks: Option<Rect>,
|
||||
pub download: Rect,
|
||||
pub upload: Rect,
|
||||
pub procs: Rect,
|
||||
}
|
||||
|
||||
/// Splits `area` into pane rects.
|
||||
///
|
||||
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
|
||||
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
|
||||
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
|
||||
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
|
||||
if force_compact {
|
||||
return compact(area, has_gpu);
|
||||
}
|
||||
let normal = normal(area);
|
||||
match normal.disks {
|
||||
Some(d) if d.height >= DISKS_MIN_H => normal,
|
||||
_ => compact(area, has_gpu),
|
||||
}
|
||||
}
|
||||
|
||||
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
|
||||
Layout::default()
|
||||
.direction(dir)
|
||||
.constraints(constraints)
|
||||
.split(area)
|
||||
}
|
||||
|
||||
/// 66/34 split used by every full-width row in the normal layout.
|
||||
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
|
||||
split(
|
||||
area,
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(66), Constraint::Percentage(34)],
|
||||
)
|
||||
}
|
||||
|
||||
fn normal(area: Rect) -> AppLayout {
|
||||
let rows = split(
|
||||
area,
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Length(HEADER_H), // header
|
||||
Constraint::Ratio(1, 3), // top row
|
||||
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
|
||||
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
|
||||
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
|
||||
],
|
||||
);
|
||||
|
||||
let top = left_right(rows[1]);
|
||||
let mem_lr = left_right(rows[2]);
|
||||
let swap_lr = left_right(rows[3]);
|
||||
|
||||
// GPU spans the same vertical space as Memory + Swap.
|
||||
let gpu = Rect {
|
||||
x: mem_lr[1].x,
|
||||
y: mem_lr[1].y,
|
||||
width: mem_lr[1].width,
|
||||
height: mem_lr[1].height + swap_lr[1].height,
|
||||
};
|
||||
|
||||
let bottom = split(
|
||||
rows[4],
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||
);
|
||||
let left_stack = split(
|
||||
bottom[0],
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Min(4), // disks absorbs the slack
|
||||
Constraint::Length(NET_H), // download
|
||||
Constraint::Length(NET_H), // upload
|
||||
],
|
||||
);
|
||||
|
||||
AppLayout {
|
||||
mode: LayoutMode::Normal,
|
||||
header: rows[0],
|
||||
cpu: top[0],
|
||||
per_core: top[1],
|
||||
gpu: Some(gpu),
|
||||
mem: mem_lr[0],
|
||||
swap: swap_lr[0],
|
||||
disks: Some(left_stack[0]),
|
||||
download: left_stack[1],
|
||||
upload: left_stack[2],
|
||||
procs: bottom[1],
|
||||
}
|
||||
}
|
||||
|
||||
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
|
||||
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
|
||||
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
|
||||
|
||||
// Give the top row its floor first, then share any surplus with the bottom so the
|
||||
// process table keeps growing with the window instead of staying pinned at 13 rows.
|
||||
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
|
||||
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
|
||||
(top, avail - top)
|
||||
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
|
||||
(TOP_MIN_H, avail - TOP_MIN_H)
|
||||
} else {
|
||||
// Smaller than both floors: the network graphs are already at their minimum, so
|
||||
// the top row takes what is left (panes clip below this point).
|
||||
let bottom = BOTTOM_MIN_H.min(avail);
|
||||
(avail - bottom, bottom)
|
||||
};
|
||||
|
||||
let rows = split(
|
||||
area,
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Length(HEADER_H),
|
||||
Constraint::Length(top_h),
|
||||
Constraint::Length(gpu_h),
|
||||
Constraint::Length(bottom_h),
|
||||
],
|
||||
);
|
||||
|
||||
let top = left_right(rows[1]);
|
||||
|
||||
let bottom = split(
|
||||
rows[3],
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||
);
|
||||
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
|
||||
let left_stack = split(
|
||||
bottom[0],
|
||||
Direction::Vertical,
|
||||
&[
|
||||
Constraint::Length(GAUGE_H),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
],
|
||||
);
|
||||
let gauges = split(
|
||||
left_stack[0],
|
||||
Direction::Horizontal,
|
||||
&[Constraint::Percentage(50), Constraint::Percentage(50)],
|
||||
);
|
||||
|
||||
AppLayout {
|
||||
mode: LayoutMode::Compact,
|
||||
header: rows[0],
|
||||
cpu: top[0],
|
||||
per_core: top[1],
|
||||
gpu: has_gpu.then_some(rows[2]),
|
||||
mem: gauges[0],
|
||||
swap: gauges[1],
|
||||
disks: None,
|
||||
download: left_stack[1],
|
||||
upload: left_stack[2],
|
||||
procs: bottom[1],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn area(w: u16, h: u16) -> Rect {
|
||||
Rect::new(0, 0, w, h)
|
||||
}
|
||||
|
||||
/// The height where the normal layout still fits a full disk card. Below it the CPU
|
||||
/// panes are the ones that collapse, which is what compact mode exists to prevent.
|
||||
#[test]
|
||||
fn tall_window_stays_normal() {
|
||||
let l = compute(area(120, 40), false, true);
|
||||
assert_eq!(l.mode, LayoutMode::Normal);
|
||||
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_window_switches_to_compact() {
|
||||
let l = compute(area(120, 24), false, true);
|
||||
assert_eq!(l.mode, LayoutMode::Compact);
|
||||
assert!(l.disks.is_none());
|
||||
}
|
||||
|
||||
/// The switch happens exactly when Disks can no longer show one card, and never
|
||||
/// oscillates: every height above the crossover is normal, every height below is
|
||||
/// compact.
|
||||
#[test]
|
||||
fn mode_is_monotonic_in_height() {
|
||||
let mut first_normal = None;
|
||||
for h in 10..=60u16 {
|
||||
let mode = compute(area(120, h), false, true).mode;
|
||||
match (mode, first_normal) {
|
||||
(LayoutMode::Normal, None) => first_normal = Some(h),
|
||||
(LayoutMode::Compact, Some(prev)) => {
|
||||
panic!("height {h} went back to compact after normal at {prev}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(first_normal.is_some(), "never reached the normal layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_compact_overrides_a_tall_window() {
|
||||
let l = compute(area(200, 80), true, true);
|
||||
assert_eq!(l.mode, LayoutMode::Compact);
|
||||
assert!(l.disks.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_drops_the_gpu_row_without_a_gpu() {
|
||||
let with = compute(area(120, 24), true, true);
|
||||
let without = compute(area(120, 24), true, false);
|
||||
assert!(with.gpu.is_some());
|
||||
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
|
||||
assert!(without.gpu.is_none());
|
||||
// The rows a GPU-less host saves are shared between the CPU panes and the
|
||||
// bottom half, and none of them are left as a gap.
|
||||
assert!(without.cpu.height > with.cpu.height);
|
||||
assert!(without.procs.height > with.procs.height);
|
||||
assert_eq!(without.procs.y + without.procs.height, 24);
|
||||
}
|
||||
|
||||
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
|
||||
/// content rows inside their borders.
|
||||
#[test]
|
||||
fn compact_keeps_the_cpu_panes_drawable() {
|
||||
for h in 18..=32u16 {
|
||||
let l = compute(area(120, h), false, true);
|
||||
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
|
||||
assert!(
|
||||
l.cpu.height >= TOP_MIN_H,
|
||||
"height {h}: cpu pane only {} rows",
|
||||
l.cpu.height
|
||||
);
|
||||
assert_eq!(l.per_core.height, l.cpu.height);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
|
||||
/// left the top row with no drawable interior at all.
|
||||
#[test]
|
||||
fn compact_beats_the_fixed_layout_at_18_rows() {
|
||||
let compact = compute(area(120, 18), false, true);
|
||||
let fixed = normal(area(120, 18));
|
||||
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
|
||||
assert!(compact.cpu.height > fixed.cpu.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_panes_tile_the_area_without_gaps() {
|
||||
for h in 16..=32u16 {
|
||||
for has_gpu in [true, false] {
|
||||
let l = compute(area(120, h), true, has_gpu);
|
||||
assert_eq!(l.header.y, 0);
|
||||
assert_eq!(l.cpu.y, l.header.y + l.header.height);
|
||||
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
|
||||
|
||||
let after_cpu = l.cpu.y + l.cpu.height;
|
||||
let bottom_y = match l.gpu {
|
||||
Some(g) => {
|
||||
assert_eq!(g.y, after_cpu);
|
||||
assert_eq!(g.width, 120, "gpu strip spans the full width");
|
||||
g.y + g.height
|
||||
}
|
||||
None => after_cpu,
|
||||
};
|
||||
assert_eq!(l.mem.y, bottom_y);
|
||||
// Memory and Swap sit side by side on one row.
|
||||
assert_eq!(l.swap.y, l.mem.y);
|
||||
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
|
||||
assert_eq!(l.mem.height, GAUGE_H);
|
||||
assert_eq!(l.download.y, l.mem.y + l.mem.height);
|
||||
assert_eq!(l.upload.y, l.download.y + l.download.height);
|
||||
assert_eq!(l.procs.y, bottom_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate size must not panic or produce rects outside the frame.
|
||||
#[test]
|
||||
fn tiny_windows_stay_inside_the_frame() {
|
||||
for h in 0..=16u16 {
|
||||
for w in [0u16, 1, 20, 80] {
|
||||
let l = compute(area(w, h), false, true);
|
||||
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
|
||||
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
|
||||
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
pub mod cpu;
|
||||
pub mod disks;
|
||||
pub mod fit;
|
||||
pub mod gpu;
|
||||
pub mod header;
|
||||
pub mod layout;
|
||||
pub mod mem;
|
||||
pub mod modal;
|
||||
pub mod modal_connection;
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
@@ -22,6 +23,7 @@ pub struct ModalManager {
|
||||
pub journal_scroll_offset: usize,
|
||||
pub thread_scroll_max: usize,
|
||||
pub journal_scroll_max: usize,
|
||||
pub help_scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl ModalManager {
|
||||
@@ -33,6 +35,7 @@ impl ModalManager {
|
||||
journal_scroll_offset: 0,
|
||||
thread_scroll_max: 0,
|
||||
journal_scroll_max: 0,
|
||||
help_scroll_offset: 0,
|
||||
}
|
||||
}
|
||||
pub fn is_active(&self) -> bool {
|
||||
@@ -55,6 +58,12 @@ impl ModalManager {
|
||||
self.journal_scroll_max = 0;
|
||||
ModalButton::Ok
|
||||
}
|
||||
Some(ModalType::About) => ModalButton::Ok,
|
||||
Some(ModalType::Help) => {
|
||||
// Reset scroll state for help modal
|
||||
self.help_scroll_offset = 0;
|
||||
ModalButton::Ok
|
||||
}
|
||||
Some(ModalType::Confirmation { .. }) => ModalButton::Confirm,
|
||||
Some(ModalType::Info { .. }) => ModalButton::Ok,
|
||||
None => ModalButton::Ok,
|
||||
@@ -66,6 +75,8 @@ impl ModalManager {
|
||||
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,
|
||||
};
|
||||
@@ -192,6 +203,22 @@ impl ModalManager {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if matches!(self.stack.last(), Some(ModalType::Help)) {
|
||||
self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if matches!(self.stack.last(), Some(ModalType::Help)) {
|
||||
self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
_ => ModalAction::None,
|
||||
}
|
||||
}
|
||||
@@ -205,6 +232,14 @@ impl ModalManager {
|
||||
self.pop_modal();
|
||||
ModalAction::Dismiss
|
||||
}
|
||||
(Some(ModalType::About), ModalButton::Ok) => {
|
||||
self.pop_modal();
|
||||
ModalAction::Dismiss
|
||||
}
|
||||
(Some(ModalType::Help), ModalButton::Ok) => {
|
||||
self.pop_modal();
|
||||
ModalAction::Dismiss
|
||||
}
|
||||
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
|
||||
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
|
||||
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
|
||||
@@ -253,6 +288,14 @@ impl ModalManager {
|
||||
// Process details modal uses almost full screen (95% width, 90% height)
|
||||
self.centered_rect(95, 90, area)
|
||||
}
|
||||
ModalType::About => {
|
||||
// About modal uses medium size
|
||||
self.centered_rect(90, 90, area)
|
||||
}
|
||||
ModalType::Help => {
|
||||
// Help modal uses medium size
|
||||
self.centered_rect(70, 80, area)
|
||||
}
|
||||
_ => {
|
||||
// Other modals use smaller size
|
||||
self.centered_rect(70, 50, area)
|
||||
@@ -276,6 +319,8 @@ impl ModalManager {
|
||||
ModalType::ProcessDetails { pid } => {
|
||||
self.render_process_details(f, modal_area, *pid, data)
|
||||
}
|
||||
ModalType::About => self.render_about(f, modal_area),
|
||||
ModalType::Help => self.render_help(f, modal_area),
|
||||
ModalType::Confirmation {
|
||||
title,
|
||||
message,
|
||||
@@ -378,6 +423,196 @@ impl ModalManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn render_about(&self, f: &mut Frame, area: Rect) {
|
||||
//get ASCII art from a constant stored in theme.rs
|
||||
use super::theme::ASCII_ART;
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let about_text = format!(
|
||||
"{}\n\
|
||||
Version {}\n\
|
||||
\n\
|
||||
A terminal first remote monitoring tool\n\
|
||||
\n\
|
||||
Website: https://socktop.io\n\
|
||||
GitHub: https://github.com/jasonwitty/socktop\n\
|
||||
\n\
|
||||
License: MIT License\n\
|
||||
\n\
|
||||
Created by Jason Witty\n\
|
||||
jasonpwitty+socktop@proton.me",
|
||||
ASCII_ART, version
|
||||
);
|
||||
|
||||
// Render the border block
|
||||
let block = Block::default()
|
||||
.title(" About socktop ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Calculate inner area manually to avoid any parent styling
|
||||
let inner_area = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2), // Leave room for button at bottom
|
||||
};
|
||||
|
||||
// Render content area with explicit black background
|
||||
f.render_widget(
|
||||
Paragraph::new(about_text)
|
||||
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner_area,
|
||||
);
|
||||
|
||||
// Button area
|
||||
let button_area = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + area.height.saturating_sub(2),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
|
||||
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).bg(Color::Black)
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("[ Enter ] Close")
|
||||
.style(ok_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_help(&self, f: &mut Frame, area: Rect) {
|
||||
let help_lines = vec![
|
||||
"GLOBAL",
|
||||
" q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help",
|
||||
"",
|
||||
"PROCESS LIST",
|
||||
" / .............. Start/edit fuzzy search",
|
||||
" c/C ............ Clear search filter",
|
||||
" ↑/↓ ............ Select/navigate processes",
|
||||
" Enter .......... Open Process Details",
|
||||
" x/X ............ Clear selection",
|
||||
" Click header ... Sort by column (CPU/Mem)",
|
||||
" Click row ...... Select process",
|
||||
"",
|
||||
"SEARCH MODE (after pressing /)",
|
||||
" Type ........... Enter search query (fuzzy match)",
|
||||
" ↑/↓ ............ Navigate results while typing",
|
||||
" Esc ............ Cancel search and clear filter",
|
||||
" Enter .......... Apply filter and select first result",
|
||||
"",
|
||||
"CPU PER-CORE",
|
||||
" ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down",
|
||||
" Home/End ....... Jump to first/last core",
|
||||
"",
|
||||
"PROCESS DETAILS MODAL",
|
||||
" x/X ............ Close modal (all parent modals)",
|
||||
" p/P ............ Navigate to parent process",
|
||||
" j/k ............ Scroll threads ↓/↑ (1 line)",
|
||||
" d/u ............ Scroll threads ↓/↑ (10 lines)",
|
||||
" [ / ] .......... Scroll journal ↑/↓",
|
||||
" Esc/Enter ...... Close modal",
|
||||
"",
|
||||
"MODAL NAVIGATION",
|
||||
" Tab/→ .......... Next button │ Shift+Tab/← ... Previous button",
|
||||
" Enter .......... Confirm/OK │ Esc ............ Cancel/Close",
|
||||
];
|
||||
|
||||
// Render the border block
|
||||
let block = Block::default()
|
||||
.title(" Hotkey Help (use ↑/↓ to scroll) ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Split into content area and button area
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
||||
.split(Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
});
|
||||
|
||||
let content_area = chunks[0];
|
||||
let button_area = chunks[1];
|
||||
|
||||
// Calculate visible window
|
||||
let visible_height = content_area.height as usize;
|
||||
let total_lines = help_lines.len();
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll_offset = self.help_scroll_offset.min(max_scroll);
|
||||
|
||||
// Get visible lines
|
||||
let visible_lines: Vec<Line> = help_lines
|
||||
.iter()
|
||||
.skip(scroll_offset)
|
||||
.take(visible_height)
|
||||
.map(|s| Line::from(*s))
|
||||
.collect();
|
||||
|
||||
// Render scrollable content
|
||||
f.render_widget(
|
||||
Paragraph::new(visible_lines)
|
||||
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
|
||||
.alignment(Alignment::Left),
|
||||
content_area,
|
||||
);
|
||||
|
||||
// Render scrollbar if needed
|
||||
if total_lines > visible_height {
|
||||
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
|
||||
|
||||
let scrollbar_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(2),
|
||||
y: area.y + 1,
|
||||
width: 1,
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll_offset);
|
||||
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("↑"))
|
||||
.end_symbol(Some("↓"))
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
|
||||
}
|
||||
|
||||
// Button area
|
||||
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).bg(Color::Black)
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("[ Enter ] Close")
|
||||
.style(ok_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
|
||||
@@ -41,11 +41,12 @@ impl ModalManager {
|
||||
])
|
||||
.split(area);
|
||||
let block = Block::default()
|
||||
.title(ICON_WARNING_TITLE)
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
.title(
|
||||
Line::from(ICON_WARNING_TITLE).style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||
|
||||
+140
-73
@@ -16,6 +16,15 @@ use super::modal_format::{calculate_dynamic_y_max, format_uptime, normalize_cpu_
|
||||
use super::modal_types::{ProcessModalData, ScatterPlotParams};
|
||||
use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT};
|
||||
|
||||
/// Parameters for rendering memory and I/O graphs
|
||||
struct MemoryIoParams<'a> {
|
||||
process: &'a socktop_connector::DetailedProcessInfo,
|
||||
mem_history: &'a std::collections::VecDeque<u64>,
|
||||
io_read_history: &'a std::collections::VecDeque<u64>,
|
||||
io_write_history: &'a std::collections::VecDeque<u64>,
|
||||
max_mem_bytes: u64,
|
||||
}
|
||||
|
||||
impl ModalManager {
|
||||
pub(super) fn render_process_details(
|
||||
&mut self,
|
||||
@@ -51,16 +60,20 @@ impl ModalManager {
|
||||
main_chunks[0],
|
||||
&details.process,
|
||||
data.history.cpu,
|
||||
data.history.cpu_sum,
|
||||
);
|
||||
|
||||
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
||||
self.render_middle_row_with_metadata(
|
||||
f,
|
||||
main_chunks[1],
|
||||
&details.process,
|
||||
data.history.mem,
|
||||
data.history.io_read,
|
||||
data.history.io_write,
|
||||
MemoryIoParams {
|
||||
process: &details.process,
|
||||
mem_history: data.history.mem,
|
||||
io_read_history: data.history.io_read,
|
||||
io_write_history: data.history.io_write,
|
||||
max_mem_bytes: data.max_mem_bytes,
|
||||
},
|
||||
);
|
||||
|
||||
// Bottom Row: Journal Events
|
||||
@@ -169,22 +182,14 @@ impl ModalManager {
|
||||
f.render_widget(plot_block, area);
|
||||
}
|
||||
|
||||
fn render_memory_io_graphs(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
mem_history: &std::collections::VecDeque<u64>,
|
||||
io_read_history: &std::collections::VecDeque<u64>,
|
||||
io_write_history: &std::collections::VecDeque<u64>,
|
||||
) {
|
||||
fn render_memory_io_graphs(&self, f: &mut Frame, area: Rect, params: MemoryIoParams) {
|
||||
let graphs_block = Block::default()
|
||||
.title("Memory & I/O")
|
||||
.borders(Borders::ALL)
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let mem_mb = process.mem_bytes as f64 / 1_048_576.0;
|
||||
let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0;
|
||||
let mem_mb = params.process.mem_bytes as f64 / 1_048_576.0;
|
||||
let virtual_mb = params.process.virtual_mem_bytes as f64 / 1_048_576.0;
|
||||
|
||||
let mut content_lines = vec![
|
||||
Line::from(vec![
|
||||
@@ -198,8 +203,12 @@ impl ModalManager {
|
||||
];
|
||||
|
||||
// Add memory sparkline if we have history
|
||||
if mem_history.len() >= 2 {
|
||||
let mem_data: Vec<u64> = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB
|
||||
if params.mem_history.len() >= 2 {
|
||||
let mem_data: Vec<u64> = params
|
||||
.mem_history
|
||||
.iter()
|
||||
.map(|&bytes| bytes / 1_048_576)
|
||||
.collect(); // Convert to MB
|
||||
let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1);
|
||||
|
||||
// Create mini sparkline using Unicode blocks
|
||||
@@ -228,8 +237,23 @@ impl ModalManager {
|
||||
Span::raw(format!("{virtual_mb:.1} MB")),
|
||||
]));
|
||||
|
||||
// Add max memory if we have tracked it
|
||||
if params.max_mem_bytes > 0 {
|
||||
let max_mb = params.max_mem_bytes as f64 / 1_048_576.0;
|
||||
content_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
" Max Memory: ",
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{max_mb:.1} MB"),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Add shared memory if available
|
||||
if let Some(shared_bytes) = process.shared_mem_bytes {
|
||||
if let Some(shared_bytes) = params.process.shared_mem_bytes {
|
||||
let shared_mb = shared_bytes as f64 / 1_048_576.0;
|
||||
content_lines.push(Line::from(vec![
|
||||
Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)),
|
||||
@@ -244,7 +268,7 @@ impl ModalManager {
|
||||
]));
|
||||
|
||||
// Add I/O stats if available
|
||||
match (process.read_bytes, process.write_bytes) {
|
||||
match (params.process.read_bytes, params.process.write_bytes) {
|
||||
(Some(read), Some(write)) => {
|
||||
let read_mb = read as f64 / 1_048_576.0;
|
||||
let write_mb = write as f64 / 1_048_576.0;
|
||||
@@ -254,8 +278,9 @@ impl ModalManager {
|
||||
]));
|
||||
|
||||
// Add read I/O sparkline if we have history
|
||||
if io_read_history.len() >= 2 {
|
||||
let read_data: Vec<u64> = io_read_history
|
||||
if params.io_read_history.len() >= 2 {
|
||||
let read_data: Vec<u64> = params
|
||||
.io_read_history
|
||||
.iter()
|
||||
.map(|&bytes| bytes / 1_048_576)
|
||||
.collect(); // Convert to MB
|
||||
@@ -282,8 +307,9 @@ impl ModalManager {
|
||||
]));
|
||||
|
||||
// Add write I/O sparkline if we have history
|
||||
if io_write_history.len() >= 2 {
|
||||
let write_data: Vec<u64> = io_write_history
|
||||
if params.io_write_history.len() >= 2 {
|
||||
let write_data: Vec<u64> = params
|
||||
.io_write_history
|
||||
.iter()
|
||||
.map(|&bytes| bytes / 1_048_576)
|
||||
.collect(); // Convert to MB
|
||||
@@ -394,7 +420,7 @@ impl ModalManager {
|
||||
)
|
||||
.header(header)
|
||||
.block(block)
|
||||
.highlight_style(Style::default());
|
||||
.row_highlight_style(Style::default());
|
||||
|
||||
f.render_widget(table, area);
|
||||
|
||||
@@ -446,13 +472,28 @@ impl ModalManager {
|
||||
.borders(Borders::ALL);
|
||||
|
||||
let content_lines: Vec<Line> = if journal.entries.is_empty() {
|
||||
vec![
|
||||
let mut lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"No journal entries found for this process",
|
||||
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 {
|
||||
journal
|
||||
.entries
|
||||
@@ -539,8 +580,13 @@ impl ModalManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a 2D grid to represent the plot
|
||||
let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
|
||||
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation
|
||||
// instead of `plot_height` inner Vec<char>s like the old version did.
|
||||
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
|
||||
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
|
||||
@@ -548,7 +594,7 @@ impl ModalManager {
|
||||
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
|
||||
);
|
||||
if main_x < plot_width && main_y < plot_height {
|
||||
plot_grid[main_y][main_x] = '●'; // Main process marker
|
||||
put(&mut plot_grid, main_x, main_y, '●');
|
||||
}
|
||||
|
||||
// Plot threads (use different marker)
|
||||
@@ -562,13 +608,13 @@ impl ModalManager {
|
||||
);
|
||||
|
||||
if thread_x < plot_width && thread_y < plot_height {
|
||||
if plot_grid[thread_y][thread_x] == ' ' {
|
||||
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
|
||||
}
|
||||
let ch = cell(&plot_grid, thread_x, thread_y);
|
||||
let next = match ch {
|
||||
' ' => '○',
|
||||
'○' => '◎',
|
||||
_ => '◉',
|
||||
};
|
||||
put(&mut plot_grid, thread_x, thread_y, next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,28 +629,47 @@ impl ModalManager {
|
||||
);
|
||||
|
||||
if child_x < plot_width && child_y < plot_height {
|
||||
if plot_grid[child_y][child_x] == ' ' {
|
||||
plot_grid[child_y][child_x] = '•'; // Child process marker
|
||||
} else {
|
||||
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
|
||||
}
|
||||
let ch = cell(&plot_grid, child_x, child_y);
|
||||
let next = if ch == ' ' { '•' } else { '◉' };
|
||||
put(&mut plot_grid, child_x, child_y, next);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the plot
|
||||
let mut lines = Vec::new();
|
||||
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
|
||||
// labels + axis title + (top) Y-axis title + legend + spacing.
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
|
||||
|
||||
// Add Y-axis labels and plot content
|
||||
for (i, row) in plot_grid.iter().enumerate() {
|
||||
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
|
||||
// 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}")
|
||||
// Format a CPU-time value: whole ms once past 100, one decimal below.
|
||||
let fmt_ms = |v: f64| {
|
||||
if v >= 100.0 {
|
||||
format!("{v:.0}")
|
||||
} else {
|
||||
format!("{y_value:>4.1}")
|
||||
};
|
||||
format!("{v:.1}")
|
||||
}
|
||||
};
|
||||
|
||||
let plot_content: String = row.iter().collect();
|
||||
// Y-axis labels, right-aligned to the widest value this frame so the
|
||||
// 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![
|
||||
Span::styled(y_label, Style::default()),
|
||||
@@ -613,8 +678,8 @@ impl ModalManager {
|
||||
]));
|
||||
}
|
||||
|
||||
// Add X-axis
|
||||
let x_axis_padding = " ".to_string(); // Match Y-axis label width
|
||||
// Add X-axis (padding = Y label width + the space before the bar)
|
||||
let x_axis_padding = " ".repeat(y_label_w + 1);
|
||||
let x_axis_line = "─".repeat(plot_width + 1);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(x_axis_padding, Style::default()),
|
||||
@@ -622,13 +687,14 @@ impl ModalManager {
|
||||
]));
|
||||
|
||||
// Add X-axis labels
|
||||
let x_label_start = "0.0".to_string();
|
||||
let x_label_mid = format!("{:.1}", params.max_user / 2.0);
|
||||
let x_label_end = format!("{:.1}", params.max_user);
|
||||
let x_label_start = fmt_ms(0.0);
|
||||
let x_label_mid = fmt_ms(params.max_user / 2.0);
|
||||
let x_label_end = fmt_ms(params.max_user);
|
||||
|
||||
let spacing = plot_width / 3;
|
||||
let x_labels = format!(
|
||||
" {}{}{}{}{}",
|
||||
"{}{}{}{}{}{}",
|
||||
" ".repeat(y_label_w + 1),
|
||||
x_label_start,
|
||||
" ".repeat(spacing.saturating_sub(x_label_start.len())),
|
||||
x_label_mid,
|
||||
@@ -640,7 +706,7 @@ impl ModalManager {
|
||||
|
||||
// Add axis titles with better visibility
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" User CPU Time (ms) →",
|
||||
format!("{}User CPU Time (ms) →", " ".repeat(y_label_w + 1)),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -808,6 +874,7 @@ impl ModalManager {
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
cpu_history: &std::collections::VecDeque<f32>,
|
||||
cpu_history_sum: f32,
|
||||
) {
|
||||
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
||||
let top_chunks = Layout::default()
|
||||
@@ -818,7 +885,7 @@ impl ModalManager {
|
||||
])
|
||||
.split(area);
|
||||
|
||||
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history);
|
||||
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum);
|
||||
self.render_thread_scatter_plot(f, top_chunks[1], process);
|
||||
}
|
||||
|
||||
@@ -828,6 +895,7 @@ impl ModalManager {
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
cpu_history: &std::collections::VecDeque<f32>,
|
||||
cpu_history_sum: f32,
|
||||
) {
|
||||
// Normalize CPU to 0-100% by dividing by thread count
|
||||
// This shows per-core utilization rather than total utilization across all cores
|
||||
@@ -839,10 +907,9 @@ impl ModalManager {
|
||||
let avg_cpu = if cpu_history.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let total: f32 = cpu_history.iter().sum();
|
||||
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
|
||||
normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count)
|
||||
};
|
||||
let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)");
|
||||
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
|
||||
|
||||
// Similar to main CPU rendering but for process CPU
|
||||
if cpu_history.len() < 2 {
|
||||
@@ -913,10 +980,7 @@ impl ModalManager {
|
||||
&mut self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
mem_history: &std::collections::VecDeque<u64>,
|
||||
io_read_history: &std::collections::VecDeque<u64>,
|
||||
io_write_history: &std::collections::VecDeque<u64>,
|
||||
params: MemoryIoParams,
|
||||
) {
|
||||
// Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%)
|
||||
let middle_chunks = Layout::default()
|
||||
@@ -931,13 +995,16 @@ impl ModalManager {
|
||||
self.render_memory_io_graphs(
|
||||
f,
|
||||
middle_chunks[0],
|
||||
process,
|
||||
mem_history,
|
||||
io_read_history,
|
||||
io_write_history,
|
||||
MemoryIoParams {
|
||||
process: params.process,
|
||||
mem_history: params.mem_history,
|
||||
io_read_history: params.io_read_history,
|
||||
io_write_history: params.io_write_history,
|
||||
max_mem_bytes: params.max_mem_bytes,
|
||||
},
|
||||
);
|
||||
self.render_thread_table(f, middle_chunks[1], process);
|
||||
self.render_command_and_metadata(f, middle_chunks[2], process);
|
||||
self.render_thread_table(f, middle_chunks[1], params.process);
|
||||
self.render_command_and_metadata(f, middle_chunks[2], params.process);
|
||||
}
|
||||
|
||||
fn render_command_and_metadata(
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::time::Instant;
|
||||
/// History data for process metrics rendering
|
||||
pub struct ProcessHistoryData<'a> {
|
||||
pub cpu: &'a std::collections::VecDeque<f32>,
|
||||
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
|
||||
pub cpu_sum: f32,
|
||||
pub mem: &'a std::collections::VecDeque<u64>,
|
||||
pub io_read: &'a std::collections::VecDeque<u64>,
|
||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||
@@ -15,6 +17,7 @@ pub struct ProcessModalData<'a> {
|
||||
pub details: Option<&'a socktop_connector::ProcessMetricsResponse>,
|
||||
pub journal: Option<&'a socktop_connector::JournalResponse>,
|
||||
pub history: ProcessHistoryData<'a>,
|
||||
pub max_mem_bytes: u64,
|
||||
pub unsupported: bool,
|
||||
}
|
||||
|
||||
@@ -38,6 +41,8 @@ pub enum ModalType {
|
||||
ProcessDetails {
|
||||
pid: u32,
|
||||
},
|
||||
About,
|
||||
Help,
|
||||
#[allow(dead_code)]
|
||||
Confirmation {
|
||||
title: String,
|
||||
|
||||
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
hist: &VecDeque<u64>,
|
||||
hist: &mut VecDeque<u64>,
|
||||
color: Color,
|
||||
) {
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(
|
||||
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string()),
|
||||
)
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.style(Style::default().fg(color));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
|
||||
+715
-220
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -49,7 +49,7 @@ pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
|
||||
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
|
||||
pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
|
||||
|
||||
// Large multi-line warning icon
|
||||
// warning icon
|
||||
pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" /\\ ",
|
||||
" / \\ ",
|
||||
@@ -60,3 +60,29 @@ pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" / !! \\ ",
|
||||
" /______________\\ ",
|
||||
];
|
||||
|
||||
//about logo
|
||||
pub const ASCII_ART: &str = r#"
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀
|
||||
⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀
|
||||
⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀
|
||||
⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
|
||||
███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗
|
||||
██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
|
||||
███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝
|
||||
╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝
|
||||
███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║
|
||||
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝
|
||||
"#;
|
||||
|
||||
@@ -22,19 +22,6 @@ pub fn human(b: u64) -> String {
|
||||
format!("{tb:.2}TB")
|
||||
}
|
||||
|
||||
pub fn truncate_middle(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max <= 3 {
|
||||
return "...".into();
|
||||
}
|
||||
let keep = max - 3;
|
||||
let left = keep / 2;
|
||||
let right = keep - left;
|
||||
format!("{}...{}", &s[..left], &s[s.len() - right..])
|
||||
}
|
||||
|
||||
pub fn disk_icon(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains(':') {
|
||||
|
||||
@@ -73,3 +73,36 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
|
||||
);
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
#[allow(dead_code)] // touch crate
|
||||
fn touch() {
|
||||
let _ = socktop::types::Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 0,
|
||||
|
||||
+41
-11
@@ -1,41 +1,71 @@
|
||||
[package]
|
||||
name = "socktop_agent"
|
||||
version = "1.40.70"
|
||||
version = "1.60.0"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
description = "Socktop agent daemon. Serves host metrics over WebSocket."
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
homepage = "https://github.com/jasonwitty/socktop"
|
||||
repository = "https://github.com/jasonwitty/socktop"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# Tokio: minimal features instead of "full" to reduce binary size.
|
||||
# rt-multi-thread (runtime), net (WebSocket), sync (Mutex/oneshot),
|
||||
# macros (#[tokio::test]), process (async journalctl).
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "process"] }
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
futures-util = "0.3.31"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now)
|
||||
gfxinfo = "0.1.2"
|
||||
tracing = { version = "0.1", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
gfxinfo = { version = "0.1.2", optional = true }
|
||||
# Direct NVML fallback for distros that ship only libnvidia-ml.so.1 (Debian
|
||||
# and derivatives) — gfxinfo's default init dlopens the unversioned name.
|
||||
# Same version gfxinfo already pulls in, so this adds no new build cost.
|
||||
nvml-wrapper = { version = "0.10", optional = true }
|
||||
once_cell = "1.19"
|
||||
axum-server = { version = "0.6", features = ["tls-rustls"] }
|
||||
rustls = "0.23"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
rustls-pemfile = "2.1"
|
||||
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build)
|
||||
rcgen = "0.13"
|
||||
anyhow = "1"
|
||||
hostname = "0.3"
|
||||
prost = { workspace = true }
|
||||
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
|
||||
# For executing journalctl commands
|
||||
tokio-process = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
gpu = ["gfxinfo", "nvml-wrapper"]
|
||||
logging = ["tracing", "tracing-subscriber"]
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
tonic-build = { version = "0.12", default-features = false, optional = true }
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3.10"
|
||||
tokio-tungstenite = "0.21"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
|
||||
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
|
||||
license-file = ["../LICENSE", "4"]
|
||||
extended-description = """\
|
||||
socktop_agent is the daemon component that runs on remote hosts to collect \
|
||||
and serve system metrics over WebSocket. It gathers CPU, memory, disk, network, \
|
||||
GPU, and process information that can be monitored remotely by the socktop TUI client."""
|
||||
depends = "$auto"
|
||||
section = "admin"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/socktop_agent", "usr/bin/", "755"],
|
||||
["../README.md", "usr/share/doc/socktop_agent/", "644"],
|
||||
]
|
||||
maintainer-scripts = "debian/"
|
||||
systemd-units = { unit-name = "socktop-agent", unit-scripts = ".", enable = false }
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
fn main() {
|
||||
// Vendored protoc for reproducible builds
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||
|
||||
println!("cargo:rerun-if-changed=proto/processes.proto");
|
||||
|
||||
// Compile protobuf definitions for processes
|
||||
let mut cfg = prost_build::Config::new();
|
||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||
cfg.protoc_executable(protoc); // Use the vendored protoc directly
|
||||
// Vendored protoc for reproducible builds where available. It ships no
|
||||
// riscv64 binary, so on such hosts fall through to $PROTOC / PATH
|
||||
// (prost-build's default lookup) — apt: protobuf-compiler.
|
||||
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
|
||||
cfg.protoc_executable(protoc);
|
||||
}
|
||||
// Use local path (ensures file is inside published crate tarball)
|
||||
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
|
||||
.expect("compile protos");
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Create socktop user and group if they don't exist
|
||||
if ! getent group socktop >/dev/null; then
|
||||
addgroup --system socktop
|
||||
fi
|
||||
|
||||
if ! getent passwd socktop >/dev/null; then
|
||||
adduser --system --ingroup socktop --home /var/lib/socktop \
|
||||
--no-create-home --disabled-password --disabled-login \
|
||||
--gecos "Socktop Agent" socktop
|
||||
fi
|
||||
|
||||
# Create state directory
|
||||
mkdir -p /var/lib/socktop
|
||||
chown socktop:socktop /var/lib/socktop
|
||||
chmod 755 /var/lib/socktop
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
mkdir -p /etc/socktop
|
||||
chmod 755 /etc/socktop
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
# Print helpful message to the user
|
||||
cat <<EOF
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ socktop-agent has been installed successfully! │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ The systemd service has been installed but is NOT enabled by │
|
||||
│ default. To enable and start the service: │
|
||||
│ │
|
||||
│ sudo systemctl enable --now socktop-agent │
|
||||
│ │
|
||||
│ To start without enabling on boot: │
|
||||
│ │
|
||||
│ sudo systemctl start socktop-agent │
|
||||
│ │
|
||||
│ To check service status: │
|
||||
│ │
|
||||
│ sudo systemctl status socktop-agent │
|
||||
│ │
|
||||
│ Default settings: │
|
||||
│ - Port: 3000 (use -p or --port to change) │
|
||||
│ - SSL/TLS: disabled (use --enableSSL to enable) │
|
||||
│ │
|
||||
│ For more information, see: │
|
||||
│ /usr/share/doc/socktop_agent/README.md │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Remove user and group on purge
|
||||
if getent passwd socktop >/dev/null; then
|
||||
deluser --quiet socktop || true
|
||||
fi
|
||||
|
||||
if getent group socktop >/dev/null; then
|
||||
delgroup --quiet socktop || true
|
||||
fi
|
||||
|
||||
# Remove state directory on purge
|
||||
rm -rf /var/lib/socktop
|
||||
|
||||
# Remove config directory if empty
|
||||
rmdir --ignore-fail-on-non-empty /etc/socktop 2>/dev/null || true
|
||||
;;
|
||||
|
||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
# Do nothing on remove/upgrade
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=Socktop Agent - Remote System Monitor
|
||||
Documentation=https://github.com/jasonwitty/socktop
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/socktop_agent --port 3000
|
||||
Environment=RUST_LOG=info
|
||||
# Optional authentication token:
|
||||
# Environment=SOCKTOP_TOKEN=changeme
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=socktop
|
||||
Group=socktop
|
||||
NoNewPrivileges=true
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/socktop
|
||||
StateDirectory=socktop
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,95 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
+114
-13
@@ -1,5 +1,4 @@
|
||||
// gpu.rs
|
||||
use gfxinfo::active_gpu;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct GpuMetrics {
|
||||
@@ -9,16 +8,118 @@ pub struct GpuMetrics {
|
||||
pub mem_total_bytes: u64,
|
||||
}
|
||||
|
||||
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
|
||||
let gpu = active_gpu()?; // Use ? to unwrap Result
|
||||
let info = gpu.info();
|
||||
|
||||
let metrics = GpuMetrics {
|
||||
name: gpu.model().to_string(),
|
||||
utilization_gpu_pct: info.load_pct() as u32,
|
||||
mem_used_bytes: info.used_vram(),
|
||||
mem_total_bytes: info.total_vram(),
|
||||
};
|
||||
|
||||
Ok(vec![metrics])
|
||||
/// Collect metrics for the active GPU. `None` when there is no usable GPU.
|
||||
///
|
||||
/// Runs on a dedicated worker thread (see `worker`): gfxinfo's handle holds
|
||||
/// an `Rc<Nvml>` (not `Send`), and *creating* it runs a full NVML library
|
||||
/// init — ~20ms of blocking work that used to execute on the async runtime
|
||||
/// for every collection. The worker owns one handle for the process lifetime,
|
||||
/// so steady-state collection is just NVML queries. Measured on an RTX 5080
|
||||
/// box, re-initing per collect was ~80% of the agent's entire active CPU.
|
||||
#[cfg(feature = "gpu")]
|
||||
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
|
||||
worker::collect().await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gpu"))]
|
||||
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "gpu")]
|
||||
mod worker {
|
||||
use super::GpuMetrics;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::mpsc;
|
||||
|
||||
type Reply = tokio::sync::oneshot::Sender<Option<Vec<GpuMetrics>>>;
|
||||
static TX: OnceCell<mpsc::Sender<Reply>> = OnceCell::new();
|
||||
|
||||
pub async fn collect() -> Option<Vec<GpuMetrics>> {
|
||||
let tx = TX.get_or_init(spawn);
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(reply_tx).ok()?;
|
||||
reply_rx.await.ok().flatten()
|
||||
}
|
||||
|
||||
fn spawn() -> mpsc::Sender<Reply> {
|
||||
let (tx, rx) = mpsc::channel::<Reply>();
|
||||
std::thread::Builder::new()
|
||||
.name("socktop-gpu".into())
|
||||
.spawn(move || run(rx))
|
||||
.expect("spawn gpu worker thread");
|
||||
tx
|
||||
}
|
||||
|
||||
enum Handle {
|
||||
/// gfxinfo's own detection (AMD sysfs, NVIDIA via unversioned NVML).
|
||||
Gfx(Box<dyn gfxinfo::Gpu>),
|
||||
/// Direct NVML with an explicit versioned soname. Debian & friends
|
||||
/// ship only libnvidia-ml.so.1 (the unversioned symlink lives in the
|
||||
/// dev package), so gfxinfo's default dlopen fails there even though
|
||||
/// the driver is fully functional.
|
||||
Nvml(Box<nvml_wrapper::Nvml>),
|
||||
}
|
||||
|
||||
fn probe() -> Option<Handle> {
|
||||
if let Ok(g) = gfxinfo::active_gpu() {
|
||||
return Some(Handle::Gfx(g));
|
||||
}
|
||||
nvml_wrapper::Nvml::builder()
|
||||
.lib_path(std::ffi::OsStr::new("libnvidia-ml.so.1"))
|
||||
.init()
|
||||
.ok()
|
||||
.map(|nvml| Handle::Nvml(Box::new(nvml)))
|
||||
}
|
||||
|
||||
fn collect_from(handle: &Handle) -> Option<Vec<GpuMetrics>> {
|
||||
match handle {
|
||||
Handle::Gfx(gpu) => {
|
||||
let info = gpu.info();
|
||||
Some(vec![GpuMetrics {
|
||||
name: gpu.model().to_string(),
|
||||
utilization_gpu_pct: info.load_pct().clamp(0, 100),
|
||||
mem_used_bytes: info.used_vram(),
|
||||
mem_total_bytes: info.total_vram(),
|
||||
}])
|
||||
}
|
||||
Handle::Nvml(nvml) => {
|
||||
let device = nvml.device_by_index(0).ok()?;
|
||||
let mem = device.memory_info().ok()?;
|
||||
Some(vec![GpuMetrics {
|
||||
name: device.name().unwrap_or_else(|_| "NVIDIA GPU".into()),
|
||||
utilization_gpu_pct: device
|
||||
.utilization_rates()
|
||||
.map(|u| u.gpu.clamp(0, 100))
|
||||
.unwrap_or(0),
|
||||
mem_used_bytes: mem.used,
|
||||
mem_total_bytes: mem.total,
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(rx: mpsc::Receiver<Reply>) {
|
||||
let mut handle: Option<Handle> = None;
|
||||
// Probing failed: remember and answer None without re-initing the GPU
|
||||
// stack per request. The agent's negative cache stops asking anyway.
|
||||
let mut probe_failed = false;
|
||||
while let Ok(reply) = rx.recv() {
|
||||
if handle.is_none() && !probe_failed {
|
||||
handle = probe();
|
||||
probe_failed = handle.is_none();
|
||||
}
|
||||
let out = handle.as_ref().and_then(collect_from);
|
||||
// A live GPU cannot report 0 total VRAM; zeros mean the session
|
||||
// died (e.g. driver reload). Drop the handle so the next request
|
||||
// re-probes.
|
||||
if let Some(v) = &out
|
||||
&& !v.is_empty()
|
||||
&& v.iter().all(|g| g.mem_total_bytes == 0)
|
||||
{
|
||||
handle = None;
|
||||
}
|
||||
let _ = reply.send(out.filter(|v| !v.is_empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,53 @@ fn arg_value(name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async 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")]
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Configure Tokio runtime with optimized thread pool for reduced overhead.
|
||||
//
|
||||
// The agent is primarily I/O-bound (WebSocket, /proc file reads, sysinfo)
|
||||
// with no CPU-intensive or blocking operations, so a smaller thread pool
|
||||
// is beneficial:
|
||||
//
|
||||
// Benefits:
|
||||
// - Lower memory footprint (~1-2MB per thread saved)
|
||||
// - Reduced context switching overhead
|
||||
// - Fewer idle threads consuming resources
|
||||
// - Better for resource-constrained systems
|
||||
//
|
||||
// Trade-offs:
|
||||
// - Slightly reduced throughput under very high concurrent connections
|
||||
// - Could introduce latency if blocking operations are added (don't do this!)
|
||||
//
|
||||
// Default: 2 threads (sufficient for typical workloads with 1-10 clients)
|
||||
// Override: Set SOCKTOP_WORKER_THREADS=4 to use more threads if needed
|
||||
//
|
||||
// Note: Default Tokio uses num_cpus threads which is excessive for this workload.
|
||||
|
||||
let worker_threads = std::env::var("SOCKTOP_WORKER_THREADS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, 16); // Ensure 1-16 threads
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(worker_threads)
|
||||
.thread_name("socktop-agent")
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
runtime.block_on(async_main())
|
||||
}
|
||||
|
||||
async fn async_main() -> anyhow::Result<()> {
|
||||
// Version flag (print and exit). Keep before heavy initialization.
|
||||
if arg_flag("--version") || arg_flag("-V") {
|
||||
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
+675
-382
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,10 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
|
||||
pub struct ProcCpuTracker {
|
||||
pub last_total: u64,
|
||||
pub last_per_pid: HashMap<u32, u64>,
|
||||
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
|
||||
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
|
||||
/// the same `String`s on every processes poll (~once per 1.5s).
|
||||
pub names: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -70,6 +74,55 @@ pub struct AppState {
|
||||
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
|
||||
}
|
||||
|
||||
/// TTL-gated value behind a std Mutex, for `static` caches on hot paths.
|
||||
/// Replaces the hand-rolled TempCache/GpuCache/refresh-timestamp statics
|
||||
/// that each reimplemented the same at/value pair.
|
||||
pub struct TtlCell<T> {
|
||||
inner: std::sync::Mutex<CacheEntry<T>>,
|
||||
}
|
||||
|
||||
impl<T: Clone> Default for TtlCell<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> TtlCell<T> {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
inner: std::sync::Mutex::new(CacheEntry::new()),
|
||||
}
|
||||
}
|
||||
/// The stored value, only while fresh. Poisoned lock reads as a miss.
|
||||
pub fn get_fresh(&self, ttl: Duration) -> Option<T> {
|
||||
let g = self.inner.lock().ok()?;
|
||||
if g.is_fresh(ttl) {
|
||||
g.value.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn set(&self, v: T) {
|
||||
if let Ok(mut g) = self.inner.lock() {
|
||||
g.set(v);
|
||||
}
|
||||
}
|
||||
/// True exactly once per TTL window: restamps and tells the caller to do
|
||||
/// the refresh. Atomic check-and-stamp so concurrent callers don't both
|
||||
/// refresh.
|
||||
pub fn claim_stale(&self, ttl: Duration) -> bool {
|
||||
let Ok(mut g) = self.inner.lock() else {
|
||||
return false;
|
||||
};
|
||||
if g.at.is_none_or(|t| t.elapsed() >= ttl) {
|
||||
g.at = Some(Instant::now());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CacheEntry<T> {
|
||||
pub at: Option<Instant>,
|
||||
@@ -83,7 +136,7 @@ impl<T> Default for CacheEntry<T> {
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
pub fn new() -> Self {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
at: None,
|
||||
value: None,
|
||||
|
||||
@@ -24,6 +24,17 @@ pub fn cert_paths() -> (PathBuf, PathBuf) {
|
||||
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
||||
let (cert_path, key_path) = cert_paths();
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
// Keys generated by agents older than 1.60 were written with the
|
||||
// default umask (typically 0644): tighten them on startup.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = fs::metadata(&key_path)
|
||||
&& meta.permissions().mode() & 0o077 != 0
|
||||
{
|
||||
let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
return Ok((cert_path, key_path));
|
||||
}
|
||||
fs::create_dir_all(cert_path.parent().unwrap())?;
|
||||
@@ -79,7 +90,16 @@ pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
||||
|
||||
let mut f = fs::File::create(&cert_path)?;
|
||||
f.write_all(cert_pem.as_bytes())?;
|
||||
let mut k = fs::File::create(&key_path)?;
|
||||
// The private key must not be world-readable (File::create honors the
|
||||
// umask, which typically yields 0644).
|
||||
let mut key_opts = fs::OpenOptions::new();
|
||||
key_opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
key_opts.mode(0o600);
|
||||
}
|
||||
let mut k = key_opts.open(&key_path)?;
|
||||
k.write_all(key_pem.as_bytes())?;
|
||||
|
||||
println!(
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
pub temperature: Option<f32>,
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -28,6 +30,11 @@ pub struct ProcessInfo {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Metrics {
|
||||
/// Epoch ms when this snapshot was actually collected. The agent serves
|
||||
/// TTL-cached snapshots, so the client needs the AGENT's sample time to
|
||||
/// compute rates — measuring against client receive time turned cache
|
||||
/// hits into a 0-then-2x sawtooth in the network graphs.
|
||||
pub sampled_at_ms: u64,
|
||||
pub cpu_total: f32,
|
||||
pub cpu_per_core: Vec<f32>,
|
||||
pub mem_total: u64,
|
||||
@@ -91,7 +98,8 @@ pub struct ProcessMetricsResponse {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalEntry {
|
||||
pub timestamp: String, // ISO 8601 formatted timestamp
|
||||
pub timestamp: String, // RFC 3339 UTC, for display
|
||||
pub timestamp_us: u64, // epoch microseconds, for sorting/formatting
|
||||
pub priority: LogLevel,
|
||||
pub message: String,
|
||||
pub unit: Option<String>, // systemd unit name
|
||||
@@ -118,5 +126,9 @@ pub struct JournalResponse {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
pub total_count: u32,
|
||||
pub truncated: bool,
|
||||
/// journalctl's own explanation when the result is empty because of
|
||||
/// journal ACCESS (not absence of logs) — e.g. a user-run agent asking
|
||||
/// about a system service. None when entries exist or nothing to say.
|
||||
pub notice: Option<String>,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
+107
-94
@@ -16,9 +16,7 @@ use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all}
|
||||
use crate::proto::pb;
|
||||
use crate::state::AppState;
|
||||
|
||||
// Compression threshold based on typical payload size
|
||||
// Temporarily increased for testing - revert to 768 for production
|
||||
//const COMPRESSION_THRESHOLD: usize = 50_000;
|
||||
// Payloads at or below this many bytes are sent as-is; larger ones are gzipped.
|
||||
const COMPRESSION_THRESHOLD: usize = 768;
|
||||
|
||||
// Reusable buffer for compression to avoid allocations
|
||||
@@ -52,6 +50,66 @@ pub async fn ws_handler(
|
||||
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) {
|
||||
state
|
||||
.client_count
|
||||
@@ -69,12 +127,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
Message::Text(ref text) if text == "get_processes" => {
|
||||
let payload = collect_processes_all(&state).await;
|
||||
|
||||
// Map to protobuf message
|
||||
// Get cached buffers
|
||||
// Get cached buffers. The Vec capacity is preserved across
|
||||
// calls (with_capacity(512) seeds it, then we swap-back after
|
||||
// encode so the allocation outlives any single request).
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
// Reuse process vector to build the list
|
||||
cache.processes_vec.clear();
|
||||
cache
|
||||
.processes_vec
|
||||
@@ -85,29 +143,38 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
mem_bytes: p.mem_bytes,
|
||||
}));
|
||||
|
||||
let pb = pb::Processes {
|
||||
// Move the populated Vec into the proto, encode, then move it
|
||||
// BACK into the cache so the next call reuses the same heap
|
||||
// allocation. The previous code did `mem::take(...)` here but
|
||||
// then dropped `pb` (and the Vec along with it), leaving the
|
||||
// cache holding an empty zero-capacity Vec — defeating the
|
||||
// whole point of `with_capacity(512)`.
|
||||
let mut pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows: std::mem::take(&mut cache.processes_vec),
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let encode_result = prost::Message::encode(&pb, &mut buf);
|
||||
// Restore the (now-encoded-from) Vec to the cache before pb is
|
||||
// dropped. We `take` it out of pb to leave that field empty,
|
||||
// and the next request will `.clear()` before refilling.
|
||||
cache.processes_vec = std::mem::take(&mut pb.rows);
|
||||
|
||||
if encode_result.is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// compress if large
|
||||
if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,84 +184,30 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
if let Some(pid_str) = text.strip_prefix("get_process_metrics:")
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
{
|
||||
let ttl = std::time::Duration::from_millis(250); // 250ms TTL
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
if let Some(entry) = cache.get(&pid)
|
||||
&& entry.is_fresh(ttl)
|
||||
&& let Some(cached_response) = entry.get()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
respond_per_pid_cached(
|
||||
&mut socket,
|
||||
&state.cache_process_metrics,
|
||||
pid,
|
||||
std::time::Duration::from_millis(250),
|
||||
"get_process_metrics",
|
||||
|| crate::metrics::collect_process_metrics(pid, &state),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_journal_entries:")
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
{
|
||||
let ttl = std::time::Duration::from_secs(1); // 1s TTL
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
if let Some(entry) = cache.get(&pid)
|
||||
&& entry.is_fresh(ttl)
|
||||
&& let Some(cached_response) = entry.get()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
respond_per_pid_cached(
|
||||
&mut socket,
|
||||
&state.cache_journal_entries,
|
||||
pid,
|
||||
std::time::Duration::from_secs(1),
|
||||
"get_journal_entries",
|
||||
|| crate::metrics::collect_journal_entries(pid),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn test_process_cache_ttl() {
|
||||
};
|
||||
|
||||
let journal_response = JournalResponse {
|
||||
notice: None,
|
||||
entries: vec![],
|
||||
total_count: 0,
|
||||
truncated: false,
|
||||
|
||||
@@ -33,7 +33,7 @@ async fn test_collect_journal_entries_self() {
|
||||
// Test collecting journal entries for our own process
|
||||
let pid = process::id();
|
||||
|
||||
match collect_journal_entries(pid) {
|
||||
match collect_journal_entries(pid).await {
|
||||
Ok(response) => {
|
||||
assert!(response.cached_at > 0);
|
||||
println!(
|
||||
@@ -74,7 +74,7 @@ async fn test_collect_journal_entries_invalid_pid() {
|
||||
// Test with an invalid PID - journalctl might still return empty results
|
||||
let invalid_pid = 999999;
|
||||
|
||||
match collect_journal_entries(invalid_pid) {
|
||||
match collect_journal_entries(invalid_pid).await {
|
||||
Ok(response) => {
|
||||
println!(
|
||||
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
|
||||
@@ -87,3 +87,19 @@ 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,4 +1,3 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -17,7 +16,7 @@ fn generates_self_signed_cert_and_key_in_xdg_path() {
|
||||
let xdg = tmpdir.path().to_path_buf();
|
||||
|
||||
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed
|
||||
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("socktop_agent"));
|
||||
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
|
||||
cmd.env("XDG_CONFIG_HOME", &xdg)
|
||||
.arg("--enableSSL")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "socktop_connector"
|
||||
version = "0.1.6"
|
||||
version = "1.60.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "WebSocket connector library for socktop agent communication"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Set the protoc binary path to use the vendored version for CI compatibility
|
||||
// SAFETY: We're only setting PROTOC in a build script environment, which is safe
|
||||
unsafe {
|
||||
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
|
||||
// Vendored protoc for reproducible builds where available. It ships no
|
||||
// riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to
|
||||
// prost-build (apt: protobuf-compiler).
|
||||
// SAFETY: We're only setting PROTOC in a build script environment.
|
||||
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
|
||||
unsafe {
|
||||
std::env::set_var("PROTOC", protoc);
|
||||
}
|
||||
}
|
||||
|
||||
prost_build::compile_protos(&["processes.proto"], &["."])?;
|
||||
|
||||
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::sync::Arc;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "tls")]
|
||||
@@ -15,7 +15,7 @@ use {
|
||||
rustls::{
|
||||
DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
crypto::ring,
|
||||
crypto::{WebPkiSupportedAlgorithms, ring},
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
},
|
||||
rustls_pemfile::Item,
|
||||
@@ -64,7 +64,8 @@ async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> R
|
||||
);
|
||||
}
|
||||
|
||||
let (ws, _) = connect_async(req).await?;
|
||||
// `true` disables Nagle: small request/response frames, latency matters.
|
||||
let (ws, _) = tokio_tungstenite::connect_async_with_config(req, None, true).await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
@@ -85,7 +86,12 @@ async fn connect_with_ca_and_config(
|
||||
der_certs.push(der);
|
||||
}
|
||||
}
|
||||
root.add_parsable_certificates(der_certs);
|
||||
if der_certs.is_empty() {
|
||||
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()
|
||||
.with_root_certificates(root)
|
||||
@@ -114,57 +120,89 @@ async fn connect_with_ca_and_config(
|
||||
}
|
||||
|
||||
if !config.verify_hostname {
|
||||
#[derive(Debug)]
|
||||
struct NoVerify;
|
||||
impl ServerCertVerifier for NoVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> std::result::Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
vec![
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
]
|
||||
}
|
||||
}
|
||||
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
|
||||
// Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
|
||||
// Default mode: certificate PINNING without hostname verification.
|
||||
// The server must present a certificate byte-identical to one in the
|
||||
// --tls-ca file. This intentionally ignores expiry and chain building
|
||||
// (the operator pinned this exact cert), but unlike a blanket accept
|
||||
// it makes MITM certs fail the handshake.
|
||||
cfg.dangerous()
|
||||
.set_certificate_verifier(Arc::new(PinnedCertVerifier::new(der_certs)));
|
||||
}
|
||||
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(
|
||||
req,
|
||||
None,
|
||||
config.verify_hostname,
|
||||
true,
|
||||
Some(Connector::Rustls(cfg)),
|
||||
)
|
||||
.await?;
|
||||
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"))]
|
||||
async fn connect_with_ca_and_config(
|
||||
_url: &str,
|
||||
@@ -181,3 +219,73 @@ async fn connect_with_ca_and_config(
|
||||
fn ensure_crypto_provider() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -50,6 +54,10 @@ pub struct GpuInfo {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
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_per_core: Vec<f32>,
|
||||
pub mem_total: u64,
|
||||
@@ -143,6 +151,10 @@ pub struct JournalResponse {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
pub total_count: u32,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ pub async fn send_request_and_wait(
|
||||
// For now, return a placeholder metrics response indicating binary data received
|
||||
// TODO: Implement proper protobuf decoding for binary data
|
||||
let placeholder_metrics = Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![0.0],
|
||||
mem_total: 0,
|
||||
|
||||
Generated
+3
-5
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
@@ -475,9 +475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socktop_connector"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a63dadaa5105df11b0684759a829012257d48e72a469cc554c0cf4394605f5a"
|
||||
version = "1.51.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
|
||||
@@ -10,8 +10,8 @@ edition = "2021"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# Use WASM features for WebSocket connectivity (published version)
|
||||
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||
# Use WASM features for WebSocket connectivity (in-repo connector via path)
|
||||
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
Generated
+4384
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@ name = "zellij_socktop_plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Standalone package, not part of the parent workspace (same as socktop_wasm_test)
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
@@ -10,7 +13,7 @@ crate-type = ["cdylib"]
|
||||
zellij-tile = "0.40.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
|
||||
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
|
||||
futures = "0.3"
|
||||
|
||||
[dependencies.chrono]
|
||||
|
||||
Reference in New Issue
Block a user