Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a04dae471 | |||
| fd82cc234b | |||
| 92c503ad84 | |||
| f5d267c08a | |||
| 4eddb19d59 | |||
| 745a681de7 | |||
| a9366d069d | |||
| bf1b4f70c3 | |||
| f73e198a66 | |||
| f7b095eb4a | |||
| 180186e2cc | |||
| cc167f71d3 | |||
| dd1dbdf29b | |||
| 524af0d123 | |||
| 619c288e9e | |||
| e04e344e2c | |||
| 8c531f9981 | |||
| 7fe302a3e2 | |||
| 31c2b59fd4 | |||
| f9462a1633 | |||
| 512913e897 | |||
| 64b641368c | |||
| f4b6faffaa | |||
| 4b9d11dc9e | |||
| c80f8cc363 | |||
| ed3d43ff7a | |||
| 350611b3b1 | |||
| 518ae8c2bf | |||
| 6eb1809309 | |||
| 1c01902a71 | |||
| 9d302ad475 | |||
| 7875f132f7 | |||
| 0d789fb97c | |||
| 5ddaed298b | |||
| 1528568c30 | |||
| 6f238cdf25 | |||
| ffe451edaa | |||
| c9bde52cb1 | |||
| 0603746d7c | |||
| 25632f3427 |
@@ -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 }}
|
||||
@@ -1,7 +1,16 @@
|
||||
/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
|
||||
|
||||
Generated
+497
-989
File diff suppressed because it is too large
Load Diff
@@ -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`
|
||||
@@ -51,15 +51,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)_
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+22
-3
@@ -1,15 +1,17 @@
|
||||
[package]
|
||||
name = "socktop"
|
||||
version = "1.40.0"
|
||||
version = "1.50.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 = "1.50.0"
|
||||
|
||||
tokio = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -24,4 +26,21 @@ 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"],
|
||||
]
|
||||
|
||||
+159
-24
@@ -29,12 +29,14 @@ use crate::ui::cpu::{
|
||||
};
|
||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||
use crate::ui::processes::{
|
||||
ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection,
|
||||
ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection,
|
||||
processes_handle_mouse_with_selection,
|
||||
};
|
||||
use crate::ui::{
|
||||
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
|
||||
swap::draw_swap,
|
||||
};
|
||||
|
||||
use socktop_connector::{
|
||||
AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent,
|
||||
connect_to_socktop_agent_with_tls,
|
||||
@@ -83,6 +85,10 @@ pub struct App {
|
||||
pub selected_process_index: Option<usize>, // Index in the visible/sorted list
|
||||
prev_selected_process_pid: Option<u32>, // Track previous selection to detect changes
|
||||
|
||||
// Process search state
|
||||
pub process_search_active: bool,
|
||||
pub process_search_query: String,
|
||||
|
||||
last_procs_poll: Instant,
|
||||
last_disks_poll: Instant,
|
||||
procs_interval: Duration,
|
||||
@@ -98,7 +104,8 @@ pub struct App {
|
||||
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
|
||||
last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation
|
||||
last_io_write_bytes: Option<u64>, // Previous write bytes for delta calculation
|
||||
pub process_details_unsupported: bool, // Track if agent doesn't support process details
|
||||
pub max_process_mem_bytes: u64, // Maximum memory usage observed for current process
|
||||
pub process_details_unsupported: bool, // Track if agent doesn't support process details
|
||||
last_process_details_poll: Instant,
|
||||
last_journal_poll: Instant,
|
||||
process_details_interval: Duration,
|
||||
@@ -145,6 +152,8 @@ impl App {
|
||||
selected_process_pid: None,
|
||||
selected_process_index: None,
|
||||
prev_selected_process_pid: None,
|
||||
process_search_active: false,
|
||||
process_search_query: String::new(),
|
||||
last_procs_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||
@@ -162,6 +171,7 @@ impl App {
|
||||
process_io_write_history: VecDeque::with_capacity(600),
|
||||
last_io_read_bytes: None,
|
||||
last_io_write_bytes: None,
|
||||
max_process_mem_bytes: 0,
|
||||
process_details_unsupported: false,
|
||||
last_process_details_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(10))
|
||||
@@ -614,7 +624,8 @@ impl App {
|
||||
{
|
||||
self.clear_process_details();
|
||||
}
|
||||
// Modal was dismissed, continue to normal processing
|
||||
// Modal was dismissed, skip normal key processing
|
||||
continue;
|
||||
}
|
||||
ModalAction::Confirm => {
|
||||
// Handle confirmation action here if needed in the future
|
||||
@@ -647,6 +658,53 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle search mode
|
||||
if self.process_search_active {
|
||||
match k.code {
|
||||
KeyCode::Esc => {
|
||||
// Exit search mode
|
||||
self.process_search_active = false;
|
||||
self.process_search_query.clear();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Exit search mode, keep filter active, and auto-select first result
|
||||
self.process_search_active = false;
|
||||
|
||||
// Auto-select first filtered result
|
||||
if let Some(m) = self.last_metrics.as_ref() {
|
||||
let idxs = get_filtered_sorted_indices(
|
||||
m,
|
||||
&self.process_search_query,
|
||||
self.procs_sort_by,
|
||||
);
|
||||
if !idxs.is_empty() {
|
||||
let first_idx = idxs[0];
|
||||
self.selected_process_index = Some(first_idx);
|
||||
self.selected_process_pid =
|
||||
Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.process_search_query.pop();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.process_search_query.push(c);
|
||||
continue;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Down => {
|
||||
// Allow arrow keys to navigate even while in search mode
|
||||
// Fall through to normal navigation handling
|
||||
}
|
||||
_ => {
|
||||
continue; // Block other keys in search mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal key handling (only if no modal is active or modal didn't consume the key)
|
||||
if matches!(
|
||||
k.code,
|
||||
@@ -654,6 +712,35 @@ impl App {
|
||||
) {
|
||||
self.should_quit = true;
|
||||
}
|
||||
|
||||
// Activate search mode on '/' (clears query if starting new search, or edits existing)
|
||||
if matches!(k.code, KeyCode::Char('/')) {
|
||||
self.process_search_active = true;
|
||||
// Don't clear query - allow editing existing search
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear search filter on 'c' or 'C' (when not in search mode)
|
||||
if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
|
||||
&& !self.process_search_query.is_empty()
|
||||
&& !self.process_search_active
|
||||
{
|
||||
self.process_search_query.clear();
|
||||
self.selected_process_pid = None;
|
||||
self.selected_process_index = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Show About modal on 'a' or 'A'
|
||||
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
|
||||
self.modal_manager.push_modal(ModalType::About);
|
||||
}
|
||||
|
||||
// Show Help modal on 'h' or 'H'
|
||||
if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) {
|
||||
self.modal_manager.push_modal(ModalType::Help);
|
||||
}
|
||||
|
||||
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
|
||||
let sz = terminal.size()?;
|
||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||
@@ -674,22 +761,15 @@ impl App {
|
||||
let content = per_core_content_area(top[1]);
|
||||
|
||||
// First try process selection (only handles arrows if a process is selected)
|
||||
let process_handled = if let Some(p_area) = self.last_procs_area {
|
||||
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
|
||||
let total_rows = self
|
||||
.last_metrics
|
||||
.as_ref()
|
||||
.map(|m| m.top_processes.len())
|
||||
.unwrap_or(0);
|
||||
processes_handle_key_with_selection(
|
||||
&mut self.procs_scroll_offset,
|
||||
&mut self.selected_process_pid,
|
||||
&mut self.selected_process_index,
|
||||
k,
|
||||
page,
|
||||
total_rows,
|
||||
self.last_metrics.as_ref(),
|
||||
)
|
||||
let process_handled = if self.last_procs_area.is_some() {
|
||||
processes_handle_key_with_selection(ProcessKeyParams {
|
||||
selected_process_pid: &mut self.selected_process_pid,
|
||||
selected_process_index: &mut self.selected_process_index,
|
||||
key: k,
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
sort_by: self.procs_sort_by,
|
||||
search_query: &self.process_search_query,
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -703,6 +783,46 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-scroll to keep selected process visible
|
||||
if let (Some(selected_idx), Some(p_area)) =
|
||||
(self.selected_process_index, self.last_procs_area)
|
||||
&& let Some(m) = self.last_metrics.as_ref()
|
||||
{
|
||||
// Get filtered and sorted indices (same as display)
|
||||
let idxs = get_filtered_sorted_indices(
|
||||
m,
|
||||
&self.process_search_query,
|
||||
self.procs_sort_by,
|
||||
);
|
||||
|
||||
// Find the display position of the selected process in filtered list
|
||||
if let Some(display_pos) =
|
||||
idxs.iter().position(|&idx| idx == selected_idx)
|
||||
{
|
||||
// Calculate viewport size
|
||||
// Account for: borders (2) + header (1) + search box if active (3)
|
||||
let extra_rows = if self.process_search_active
|
||||
|| !self.process_search_query.is_empty()
|
||||
{
|
||||
3 // search box with border
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let viewport_rows =
|
||||
p_area.height.saturating_sub(3 + extra_rows) as usize;
|
||||
|
||||
// Adjust scroll offset to keep selection visible
|
||||
if display_pos < self.procs_scroll_offset {
|
||||
// Selection is above viewport, scroll up
|
||||
self.procs_scroll_offset = display_pos;
|
||||
} else if display_pos >= self.procs_scroll_offset + viewport_rows {
|
||||
// Selection is below viewport, scroll down
|
||||
self.procs_scroll_offset =
|
||||
display_pos.saturating_sub(viewport_rows - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if process selection changed and clear details if so
|
||||
if self.selected_process_pid != self.prev_selected_process_pid {
|
||||
self.clear_process_details();
|
||||
@@ -799,6 +919,7 @@ impl App {
|
||||
total_rows: mm.top_processes.len(),
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
sort_by: self.procs_sort_by,
|
||||
search_query: &self.process_search_query,
|
||||
})
|
||||
{
|
||||
self.procs_sort_by = new_sort;
|
||||
@@ -884,6 +1005,11 @@ impl App {
|
||||
let mem_bytes = details.process.mem_bytes;
|
||||
push_capped(&mut self.process_mem_history, mem_bytes, 600);
|
||||
|
||||
// Track maximum memory usage
|
||||
if mem_bytes > self.max_process_mem_bytes {
|
||||
self.max_process_mem_bytes = mem_bytes;
|
||||
}
|
||||
|
||||
// I/O bytes from agent are cumulative, calculate deltas
|
||||
if let Some(read) = details.process.read_bytes {
|
||||
let delta = if let Some(last) = self.last_io_read_bytes
|
||||
@@ -989,6 +1115,7 @@ impl App {
|
||||
self.process_io_write_history.clear();
|
||||
self.last_io_read_bytes = None;
|
||||
self.last_io_write_bytes = None;
|
||||
self.max_process_mem_bytes = 0;
|
||||
self.process_details_unsupported = false;
|
||||
}
|
||||
|
||||
@@ -1148,11 +1275,15 @@ impl App {
|
||||
crate::ui::processes::draw_top_processes(
|
||||
f,
|
||||
procs_area,
|
||||
self.last_metrics.as_ref(),
|
||||
self.procs_scroll_offset,
|
||||
self.procs_sort_by,
|
||||
self.selected_process_pid,
|
||||
self.selected_process_index,
|
||||
crate::ui::processes::ProcessDisplayParams {
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
scroll_offset: self.procs_scroll_offset,
|
||||
sort_by: self.procs_sort_by,
|
||||
selected_process_pid: self.selected_process_pid,
|
||||
selected_process_index: self.selected_process_index,
|
||||
search_query: &self.process_search_query,
|
||||
search_active: self.process_search_active,
|
||||
},
|
||||
);
|
||||
|
||||
// Render modals on top of everything else
|
||||
@@ -1169,6 +1300,7 @@ impl App {
|
||||
io_read: &self.process_io_read_history,
|
||||
io_write: &self.process_io_write_history,
|
||||
},
|
||||
max_mem_bytes: self.max_process_mem_bytes,
|
||||
unsupported: self.process_details_unsupported,
|
||||
},
|
||||
);
|
||||
@@ -1197,6 +1329,8 @@ impl Default for App {
|
||||
selected_process_pid: None,
|
||||
selected_process_index: None,
|
||||
prev_selected_process_pid: None,
|
||||
process_search_active: false,
|
||||
process_search_query: String::new(),
|
||||
last_procs_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||
@@ -1214,6 +1348,7 @@ impl Default for App {
|
||||
process_io_write_history: VecDeque::with_capacity(600),
|
||||
last_io_read_bytes: None,
|
||||
last_io_write_bytes: None,
|
||||
max_process_mem_bytes: 0,
|
||||
process_details_unsupported: false,
|
||||
last_process_details_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(10))
|
||||
|
||||
+44
-3
@@ -42,8 +42,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);
|
||||
@@ -240,20 +240,61 @@ pub fn draw_cpu_avg_graph(
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
m: Option<&Metrics>,
|
||||
) {
|
||||
// Calculate average CPU over the monitoring period
|
||||
let avg_cpu = if !hist.is_empty() {
|
||||
let sum: u64 = hist.iter().sum();
|
||||
sum as f64 / hist.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let title = if let Some(mm) = m {
|
||||
format!("CPU avg (now: {:>5.1}%)", mm.cpu_total)
|
||||
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
|
||||
} else {
|
||||
"CPU avg".into()
|
||||
};
|
||||
|
||||
// Build the top-right info (CPU temp and polling intervals)
|
||||
let top_right_info = if let Some(mm) = m {
|
||||
mm.cpu_temp_c
|
||||
.map(|t| {
|
||||
let icon = if t < 50.0 {
|
||||
"😎"
|
||||
} else if t < 85.0 {
|
||||
"⚠️"
|
||||
} else {
|
||||
"🔥"
|
||||
};
|
||||
format!("CPU Temp: {t:.1}°C {icon}")
|
||||
})
|
||||
.unwrap_or_else(|| "CPU Temp: N/A".into())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
// Render the sparkline with title on left
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(&data)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
|
||||
// Render the top-right info as text overlay in the top-right corner
|
||||
if !top_right_info.is_empty() {
|
||||
let info_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
|
||||
y: area.y,
|
||||
width: top_right_info.len() as u16 + 1,
|
||||
height: 1,
|
||||
};
|
||||
let info_line = Line::from(Span::raw(top_right_info));
|
||||
f.render_widget(Paragraph::new(info_line), info_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the per-core CPU bars with sparklines and trends.
|
||||
|
||||
+23
-20
@@ -3,7 +3,8 @@
|
||||
use crate::types::Metrics;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -17,20 +18,7 @@ pub fn draw_header(
|
||||
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)
|
||||
format!("socktop — host: {}", mm.hostname)
|
||||
} else {
|
||||
"socktop — connecting...".into()
|
||||
};
|
||||
@@ -38,15 +26,30 @@ pub fn draw_header(
|
||||
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());
|
||||
parts.push("(a: about, h: help, q: quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
|
||||
// Render the block with left-aligned title
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
|
||||
// Render polling intervals on the right side
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let intervals_width = intervals.len() as u16;
|
||||
|
||||
if area.width > intervals_width + 2 {
|
||||
let right_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
||||
y: area.y,
|
||||
width: intervals_width,
|
||||
height: 1,
|
||||
};
|
||||
let intervals_line = Line::from(Span::raw(intervals));
|
||||
f.render_widget(Paragraph::new(intervals_line), right_area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -57,10 +66,13 @@ impl ModalManager {
|
||||
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 +181,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 +202,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 +236,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 +267,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 +277,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 +306,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
|
||||
@@ -842,7 +867,7 @@ impl ModalManager {
|
||||
let total: f32 = cpu_history.iter().sum();
|
||||
normalize_cpu_usage(total / 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 +938,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 +953,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(
|
||||
|
||||
@@ -15,6 +15,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 +39,8 @@ pub enum ModalType {
|
||||
ProcessDetails {
|
||||
pid: u32,
|
||||
},
|
||||
About,
|
||||
Help,
|
||||
#[allow(dead_code)]
|
||||
Confirmation {
|
||||
title: String,
|
||||
|
||||
+212
-65
@@ -18,6 +18,66 @@ use crate::ui::theme::{
|
||||
};
|
||||
use crate::ui::util::human;
|
||||
|
||||
/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
|
||||
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let haystack_lower = haystack.to_lowercase();
|
||||
let needle_lower = needle.to_lowercase();
|
||||
let mut haystack_chars = haystack_lower.chars();
|
||||
|
||||
for needle_char in needle_lower.chars() {
|
||||
if !haystack_chars.any(|c| c == needle_char) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Get filtered and sorted process indices based on search query and sort order
|
||||
pub fn get_filtered_sorted_indices(
|
||||
metrics: &Metrics,
|
||||
search_query: &str,
|
||||
sort_by: ProcSortBy,
|
||||
) -> Vec<usize> {
|
||||
// Filter processes by search query (fuzzy match)
|
||||
let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
|
||||
(0..metrics.top_processes.len()).collect()
|
||||
} else {
|
||||
(0..metrics.top_processes.len())
|
||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Sort filtered rows
|
||||
match sort_by {
|
||||
ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| {
|
||||
let aa = metrics.top_processes[a].cpu_usage;
|
||||
let bb = metrics.top_processes[b].cpu_usage;
|
||||
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
|
||||
}),
|
||||
ProcSortBy::MemDesc => filtered_idxs.sort_by(|&a, &b| {
|
||||
let aa = metrics.top_processes[a].mem_bytes;
|
||||
let bb = metrics.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
|
||||
filtered_idxs
|
||||
}
|
||||
|
||||
/// Parameters for drawing the top processes table
|
||||
pub struct ProcessDisplayParams<'a> {
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
pub scroll_offset: usize,
|
||||
pub sort_by: ProcSortBy,
|
||||
pub selected_process_pid: Option<u32>,
|
||||
pub selected_process_index: Option<usize>,
|
||||
pub search_query: &'a str,
|
||||
pub search_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ProcSortBy {
|
||||
#[default]
|
||||
@@ -34,30 +94,61 @@ const COLS: [Constraint; 5] = [
|
||||
Constraint::Length(8), // Mem %
|
||||
];
|
||||
|
||||
pub fn draw_top_processes(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
scroll_offset: usize,
|
||||
sort_by: ProcSortBy,
|
||||
selected_process_pid: Option<u32>,
|
||||
selected_process_index: Option<usize>,
|
||||
) {
|
||||
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
|
||||
// Draw outer block and title
|
||||
let Some(mm) = m else { return };
|
||||
let Some(mm) = params.metrics else { return };
|
||||
let total = mm.process_count.unwrap_or(mm.top_processes.len());
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!("Top Processes ({total} total)"));
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Inner area and content area (reserve 2 columns for scrollbar)
|
||||
// Inner area (reserve space for search box if active)
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
|
||||
// Draw search box if active
|
||||
let content_start_y = if params.search_active || !params.search_query.is_empty() {
|
||||
let search_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width,
|
||||
height: 3, // Height for border + content
|
||||
};
|
||||
|
||||
let search_text = if params.search_active {
|
||||
format!("Search: {}_", params.search_query)
|
||||
} else {
|
||||
format!(
|
||||
"Filter: {} (press / to edit, c to clear)",
|
||||
params.search_query
|
||||
)
|
||||
};
|
||||
|
||||
let search_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
let search_paragraph = Paragraph::new(search_text)
|
||||
.block(search_block)
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
f.render_widget(search_paragraph, search_area);
|
||||
|
||||
inner.y + 3
|
||||
} else {
|
||||
inner.y
|
||||
};
|
||||
|
||||
// Content area (reserve 2 columns for scrollbar)
|
||||
let inner = Rect {
|
||||
x: inner.x,
|
||||
y: content_start_y,
|
||||
width: inner.width,
|
||||
height: inner.height.saturating_sub(content_start_y - (area.y + 1)),
|
||||
};
|
||||
if inner.height < 1 || inner.width < 3 {
|
||||
return;
|
||||
}
|
||||
@@ -68,27 +159,15 @@ pub fn draw_top_processes(
|
||||
height: inner.height,
|
||||
};
|
||||
|
||||
// Sort rows (by CPU% or Mem bytes), descending.
|
||||
let mut idxs: Vec<usize> = (0..mm.top_processes.len()).collect();
|
||||
match sort_by {
|
||||
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = mm.top_processes[a].cpu_usage;
|
||||
let bb = mm.top_processes[b].cpu_usage;
|
||||
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
|
||||
}),
|
||||
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = mm.top_processes[a].mem_bytes;
|
||||
let bb = mm.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
// Get filtered and sorted indices
|
||||
let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by);
|
||||
|
||||
// Scrolling
|
||||
let total_rows = idxs.len();
|
||||
let header_rows = 1usize;
|
||||
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
|
||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||
let offset = scroll_offset.min(max_off);
|
||||
let offset = params.scroll_offset.min(max_off);
|
||||
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||
|
||||
// Build visible rows
|
||||
@@ -122,9 +201,9 @@ pub fn draw_top_processes(
|
||||
};
|
||||
|
||||
// Check if this process is selected - prioritize PID matching
|
||||
let is_selected = if let Some(selected_pid) = selected_process_pid {
|
||||
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
|
||||
selected_pid == p.pid
|
||||
} else if let Some(selected_idx) = selected_process_index {
|
||||
} else if let Some(selected_idx) = params.selected_process_index {
|
||||
selected_idx == ix // ix is the absolute index in the sorted list
|
||||
} else {
|
||||
false
|
||||
@@ -153,11 +232,11 @@ pub fn draw_top_processes(
|
||||
});
|
||||
|
||||
// Header with sort indicator
|
||||
let cpu_hdr = match sort_by {
|
||||
let cpu_hdr = match params.sort_by {
|
||||
ProcSortBy::CpuDesc => "CPU % •",
|
||||
_ => "CPU %",
|
||||
};
|
||||
let mem_hdr = match sort_by {
|
||||
let mem_hdr = match params.sort_by {
|
||||
ProcSortBy::MemDesc => "Mem •",
|
||||
_ => "Mem",
|
||||
};
|
||||
@@ -174,9 +253,9 @@ pub fn draw_top_processes(
|
||||
f.render_widget(table, content);
|
||||
|
||||
// Draw tooltip if a process is selected
|
||||
if let Some(selected_pid) = selected_process_pid {
|
||||
if let Some(selected_pid) = params.selected_process_pid {
|
||||
// Find the selected process to get its name
|
||||
let process_info = if let Some(metrics) = m {
|
||||
let process_info = if let Some(metrics) = params.metrics {
|
||||
metrics
|
||||
.top_processes
|
||||
.iter()
|
||||
@@ -254,6 +333,16 @@ fn fmt_cpu_pct(v: f32) -> String {
|
||||
}
|
||||
|
||||
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
||||
/// Parameters for process key event handling
|
||||
pub struct ProcessKeyParams<'a> {
|
||||
pub selected_process_pid: &'a mut Option<u32>,
|
||||
pub selected_process_index: &'a mut Option<usize>,
|
||||
pub key: crossterm::event::KeyEvent,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
pub sort_by: ProcSortBy,
|
||||
pub search_query: &'a str,
|
||||
}
|
||||
|
||||
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
|
||||
#[allow(dead_code)]
|
||||
pub fn processes_handle_key(
|
||||
@@ -264,24 +353,85 @@ pub fn processes_handle_key(
|
||||
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
|
||||
}
|
||||
|
||||
/// Enhanced keyboard handler that also manages process selection
|
||||
pub fn processes_handle_key_with_selection(
|
||||
_scroll_offset: &mut usize,
|
||||
selected_process_pid: &mut Option<u32>,
|
||||
selected_process_index: &mut Option<usize>,
|
||||
key: crossterm::event::KeyEvent,
|
||||
_page_size: usize,
|
||||
_total_rows: usize,
|
||||
_metrics: Option<&Metrics>,
|
||||
) -> bool {
|
||||
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
match key.code {
|
||||
match params.key.code {
|
||||
KeyCode::Up => {
|
||||
// Navigate through filtered and sorted results
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
if idxs.is_empty() {
|
||||
// No filtered results, clear selection
|
||||
*params.selected_process_index = None;
|
||||
*params.selected_process_pid = None;
|
||||
} else if params.selected_process_index.is_none()
|
||||
|| params.selected_process_pid.is_none()
|
||||
{
|
||||
// No selection - select the first process in filtered/sorted order
|
||||
let first_idx = idxs[0];
|
||||
*params.selected_process_index = Some(first_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
||||
} else if let Some(current_idx) = *params.selected_process_index {
|
||||
// Find current position in filtered/sorted list
|
||||
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
|
||||
if pos > 0 {
|
||||
// Move up in filtered/sorted list
|
||||
let new_idx = idxs[pos - 1];
|
||||
*params.selected_process_index = Some(new_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
|
||||
}
|
||||
} else {
|
||||
// Current selection not in filtered list, select first result
|
||||
let first_idx = idxs[0];
|
||||
*params.selected_process_index = Some(first_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
true // Handled
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Navigate through filtered and sorted results
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
if idxs.is_empty() {
|
||||
// No filtered results, clear selection
|
||||
*params.selected_process_index = None;
|
||||
*params.selected_process_pid = None;
|
||||
} else if params.selected_process_index.is_none()
|
||||
|| params.selected_process_pid.is_none()
|
||||
{
|
||||
// No selection - select the first process in filtered/sorted order
|
||||
let first_idx = idxs[0];
|
||||
*params.selected_process_index = Some(first_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
||||
} else if let Some(current_idx) = *params.selected_process_index {
|
||||
// Find current position in filtered/sorted list
|
||||
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
|
||||
if pos + 1 < idxs.len() {
|
||||
// Move down in filtered/sorted list
|
||||
let new_idx = idxs[pos + 1];
|
||||
*params.selected_process_index = Some(new_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
|
||||
}
|
||||
} else {
|
||||
// Current selection not in filtered list, select first result
|
||||
let first_idx = idxs[0];
|
||||
*params.selected_process_index = Some(first_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
true // Handled
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => {
|
||||
// Unselect any selected process
|
||||
if selected_process_pid.is_some() || selected_process_index.is_some() {
|
||||
*selected_process_pid = None;
|
||||
*selected_process_index = None;
|
||||
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() {
|
||||
*params.selected_process_pid = None;
|
||||
*params.selected_process_index = None;
|
||||
true // Handled
|
||||
} else {
|
||||
false // No selection to clear
|
||||
@@ -289,7 +439,7 @@ pub fn processes_handle_key_with_selection(
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Signal that Enter was pressed with a selection
|
||||
selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||
params.selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||
}
|
||||
_ => {
|
||||
// No other keys handled - let scrollbar handle all navigation
|
||||
@@ -377,6 +527,7 @@ pub struct ProcessMouseParams<'a> {
|
||||
pub total_rows: usize,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
pub sort_by: ProcSortBy,
|
||||
pub search_query: &'a str,
|
||||
}
|
||||
|
||||
/// Enhanced mouse handler that also manages process selection
|
||||
@@ -392,11 +543,19 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
if inner.height == 0 || inner.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate content area - must match draw_top_processes exactly!
|
||||
// If search is active or query exists, content starts after search box (3 lines)
|
||||
let search_active = !params.search_query.is_empty();
|
||||
let content_start_y = if search_active { inner.y + 3 } else { inner.y };
|
||||
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
y: content_start_y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
height: inner
|
||||
.height
|
||||
.saturating_sub(if search_active { 3 } else { 0 }),
|
||||
};
|
||||
|
||||
// Scrollbar interactions (click arrows/page/drag)
|
||||
@@ -453,24 +612,12 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
{
|
||||
let clicked_row = (params.mouse.row - data_start_row) as usize;
|
||||
|
||||
// Find the actual process using the same sorting logic as the drawing code
|
||||
// Find the actual process using the same filtering/sorting logic as the drawing code
|
||||
if let Some(m) = params.metrics {
|
||||
// Create the same sorted index array as in draw_top_processes
|
||||
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
|
||||
match params.sort_by {
|
||||
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].cpu_usage;
|
||||
let bb = m.top_processes[b].cpu_usage;
|
||||
bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].mem_bytes;
|
||||
let bb = m.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
// Use the same filtered and sorted indices as display
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
// Calculate which process was actually clicked based on sorted order
|
||||
// Calculate which process was actually clicked based on filtered/sorted order
|
||||
let visible_process_position = *params.scroll_offset + clicked_row;
|
||||
if visible_process_position < idxs.len() {
|
||||
let actual_process_index = idxs[visible_process_position];
|
||||
|
||||
+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#"
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀
|
||||
⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀
|
||||
⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀
|
||||
⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
|
||||
███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗
|
||||
██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
|
||||
███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝
|
||||
╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝
|
||||
███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║
|
||||
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝
|
||||
"#;
|
||||
|
||||
+38
-11
@@ -1,41 +1,68 @@
|
||||
[package]
|
||||
name = "socktop_agent"
|
||||
version = "1.40.70"
|
||||
version = "1.50.2"
|
||||
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: Use minimal features instead of "full" to reduce binary size
|
||||
# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test])
|
||||
# Excluded: io, fs, process, signal, time (not needed for this workload)
|
||||
# Savings: ~200-300KB binary size, faster compile times
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
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 }
|
||||
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"]
|
||||
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 }
|
||||
|
||||
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,4 +1,5 @@
|
||||
// gpu.rs
|
||||
#[cfg(feature = "gpu")]
|
||||
use gfxinfo::active_gpu;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -9,6 +10,7 @@ pub struct GpuMetrics {
|
||||
pub mem_total_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "gpu")]
|
||||
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();
|
||||
@@ -22,3 +24,9 @@ pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>>
|
||||
|
||||
Ok(vec![metrics])
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gpu"))]
|
||||
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
|
||||
// GPU support not available on this platform
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::sync::Mutex;
|
||||
use std::time::Duration as StdDuration;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
|
||||
#[cfg(feature = "logging")]
|
||||
use tracing::warn;
|
||||
|
||||
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
|
||||
@@ -168,11 +169,12 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
}
|
||||
}
|
||||
let mut sys = state.sys.lock().await;
|
||||
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if let Err(_e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
})) {
|
||||
warn!("sysinfo selective refresh panicked: {e:?}");
|
||||
#[cfg(feature = "logging")]
|
||||
warn!("sysinfo selective refresh panicked: {_e:?}");
|
||||
}
|
||||
|
||||
// Get or initialize hostname once
|
||||
@@ -266,8 +268,9 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
let v = match collect_all_gpus() {
|
||||
Ok(v) if !v.is_empty() => Some(v),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("gpu collection failed: {e}");
|
||||
Err(_e) => {
|
||||
#[cfg(feature = "logging")]
|
||||
warn!("gpu collection failed: {_e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -348,6 +351,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
if label.contains("composite")
|
||||
&& let Some(temp) = c.temperature()
|
||||
{
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Found Composite temp: {}°C", temp);
|
||||
composite_temps.push(temp);
|
||||
}
|
||||
@@ -357,9 +361,11 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
let mut temps = std::collections::HashMap::new();
|
||||
for (idx, temp) in composite_temps.iter().enumerate() {
|
||||
let key = format!("nvme{}n1", idx);
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Mapping {} -> {}°C", key, temp);
|
||||
temps.insert(key, *temp);
|
||||
}
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Final disk_temps map: {:?}", temps);
|
||||
temps
|
||||
};
|
||||
@@ -394,6 +400,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
// Try to find temperature for this disk
|
||||
let temperature = disk_temps.iter().find_map(|(key, &temp)| {
|
||||
if name.starts_with(key) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp);
|
||||
Some(temp)
|
||||
} else {
|
||||
@@ -402,6 +409,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
});
|
||||
|
||||
if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("No temperature found for disk: {}", name);
|
||||
}
|
||||
|
||||
@@ -752,6 +760,7 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
proc_cache
|
||||
.names
|
||||
.retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid)));
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!(
|
||||
"Cleaned up {} stale process names in {}ms",
|
||||
proc_cache.names.capacity() - proc_cache.names.len(),
|
||||
|
||||
@@ -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.50.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "WebSocket connector library for socktop agent communication"
|
||||
|
||||
Reference in New Issue
Block a user