Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5310570d9 | |||
| f95a64a18b | |||
| 1c0c44ec3c | |||
| ee4468ca23 | |||
| 697a77bdab | |||
| 8f452a35e6 | |||
| 1d285c3c4e | |||
| 0fb45f6c50 | |||
| be24fa3859 | |||
| 39619b2845 | |||
| 40c925e7f9 | |||
| 8f69e469e6 | |||
| 3024816525 | |||
| 1d7bc42d59 | |||
| 518ae8c2bf | |||
| 6eb1809309 | |||
| 1c01902a71 | |||
| 9d302ad475 | |||
| 7875f132f7 | |||
| 0d789fb97c | |||
| 5ddaed298b | |||
| 1528568c30 | |||
| 6f238cdf25 | |||
| ffe451edaa | |||
| c9bde52cb1 | |||
| 0603746d7c | |||
| 25632f3427 | |||
| e51cdb0c50 | |||
| 1cb05d404b | |||
| 4196066e57 | |||
| 47e96c7d92 | |||
| bae2ecb79a | |||
| bd0d15a1ae | |||
| 689498c5f4 | |||
| 34e260a612 | |||
| 47eff3a75c | |||
| 0210b49219 | |||
| 70a150152c | |||
| f4b54db399 | |||
| e857cfc665 | |||
| e66008f341 | |||
| a238ce320b | |||
| 18b41c1b45 | |||
| b74242e6d9 | |||
| 4e378b882a |
@@ -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 }}
|
||||
+13
@@ -1,3 +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
+1263
-761
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -24,8 +24,8 @@ serde_json = "1.0"
|
||||
sysinfo = "0.37"
|
||||
|
||||
# CLI UI
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.27"
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
|
||||
# web server (remote-agent)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Debian Packaging Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented Debian packaging for socktop using `cargo-deb`, with GitHub Actions automation for building packages for both AMD64 and ARM64 architectures.
|
||||
|
||||
## Branches Created
|
||||
|
||||
1. **`feature/debian-packaging`** - Main branch with debian packaging implementation
|
||||
2. **`feature/man-pages`** - Separate branch for man pages work (to be researched further)
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. Cargo.toml Updates
|
||||
|
||||
Both `socktop/Cargo.toml` and `socktop_agent/Cargo.toml` were updated with:
|
||||
- `[package.metadata.deb]` sections
|
||||
- Package metadata (maintainer, description, dependencies)
|
||||
- Asset definitions (binaries, documentation)
|
||||
- Systemd service configuration (agent only)
|
||||
|
||||
### 2. Systemd Service
|
||||
|
||||
**File**: `socktop_agent/socktop-agent.service`
|
||||
- Runs as `socktop` user/group
|
||||
- Listens on port 3000 by default
|
||||
- Security hardening enabled
|
||||
- Disabled by default (user must explicitly enable)
|
||||
|
||||
### 3. Maintainer Scripts
|
||||
|
||||
**Directory**: `socktop_agent/debian/`
|
||||
|
||||
- **`postinst`**: Creates `socktop` user/group, sets up `/var/lib/socktop` directory
|
||||
- **`postrm`**: Cleanup on package removal/purge
|
||||
|
||||
### 4. GitHub Actions Workflow
|
||||
|
||||
**File**: `.github/workflows/build-deb.yml`
|
||||
|
||||
Features:
|
||||
- Builds for both x86_64 and ARM64
|
||||
- Triggered on:
|
||||
- Push to `master` or `feature/debian-packaging`
|
||||
- Pull requests to `master`
|
||||
- Version tags (v*)
|
||||
- Manual workflow dispatch
|
||||
- Creates artifacts:
|
||||
- `debian-packages-amd64`
|
||||
- `debian-packages-arm64`
|
||||
- `all-debian-packages` (combined)
|
||||
- `checksums` (SHA256SUMS)
|
||||
- Automatic GitHub releases for version tags
|
||||
|
||||
### 5. Documentation
|
||||
|
||||
**File**: `docs/DEBIAN_PACKAGING.md`
|
||||
|
||||
Comprehensive guide covering:
|
||||
- Building packages locally
|
||||
- Cross-compilation for ARM64
|
||||
- Installation and configuration
|
||||
- Using GitHub Actions artifacts
|
||||
- Creating local APT repositories
|
||||
- Troubleshooting
|
||||
|
||||
## Package Details
|
||||
|
||||
### socktop (TUI Client)
|
||||
- **Binary**: `/usr/bin/socktop`
|
||||
- **Size**: ~3.5 MB (x86_64)
|
||||
- **Dependencies**: Auto-detected
|
||||
|
||||
### socktop_agent (Daemon)
|
||||
- **Binary**: `/usr/bin/socktop_agent`
|
||||
- **Service**: `socktop-agent.service`
|
||||
- **User/Group**: `socktop` (created automatically)
|
||||
- **State directory**: `/var/lib/socktop`
|
||||
- **Size**: ~6.7 MB (x86_64)
|
||||
- **Dependencies**: Auto-detected
|
||||
|
||||
## Testing
|
||||
|
||||
Both packages successfully built locally:
|
||||
```
|
||||
✓ socktop_1.50.0-1_amd64.deb
|
||||
✓ socktop-agent_1.50.1-1_amd64.deb
|
||||
```
|
||||
|
||||
Verified:
|
||||
- Package contents (dpkg -c)
|
||||
- Package metadata (dpkg -I)
|
||||
- Systemd service file inclusion
|
||||
- Maintainer scripts inclusion
|
||||
- Documentation inclusion
|
||||
|
||||
## Usage
|
||||
|
||||
### For Users
|
||||
|
||||
Download pre-built packages from GitHub Actions artifacts:
|
||||
1. Go to Actions tab
|
||||
2. Select latest "Build Debian Packages" run
|
||||
3. Download architecture-specific artifact
|
||||
4. Install: `sudo dpkg -i socktop*.deb`
|
||||
|
||||
### For Developers
|
||||
|
||||
Build locally:
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
```
|
||||
|
||||
Cross-compile for ARM64:
|
||||
```bash
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
cargo deb --package socktop --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
To get packages in official APT repositories:
|
||||
|
||||
1. **Short term**: Host packages on GitHub Releases (automated)
|
||||
2. **Medium term**: Create PPA for Ubuntu users
|
||||
3. **Long term**: Submit to Debian/Ubuntu official repositories
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
```
|
||||
Modified:
|
||||
socktop/Cargo.toml
|
||||
socktop_agent/Cargo.toml
|
||||
|
||||
Created:
|
||||
.github/workflows/build-deb.yml
|
||||
docs/DEBIAN_PACKAGING.md
|
||||
socktop_agent/socktop-agent.service
|
||||
socktop_agent/debian/postinst
|
||||
socktop_agent/debian/postrm
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
532ed16 Add Debian packaging support with cargo-deb
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
|
||||
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
|
||||
- Full documentation in `docs/DEBIAN_PACKAGING.md`
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Witty One Off
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -5,6 +5,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
|
||||
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||
[socktop.io](https://www.socktop.io)
|
||||
|
||||
<img src="./docs/socktop_demo.apng" width="100%">
|
||||
|
||||
---
|
||||
@@ -51,15 +53,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
|
||||
```
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
Error: Address already in use (os error 98)
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
|
||||
Error: Address already in use (os error 98)
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
|
||||
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Sync this repo to the 'gitea' remote as a mirror.
|
||||
# - Mirrors ALL refs (branches, tags) and prunes removed ones.
|
||||
# - This makes the Gitea repo match GitHub exactly.
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "Error: not inside a git repo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git remote get-url gitea >/dev/null 2>&1; then
|
||||
echo "Missing 'gitea' remote. Add it with:" >&2
|
||||
echo " git remote add gitea https://gt.wittyoneoff.com/jason/socktop.git" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching from origin (pruning)..."
|
||||
git fetch origin --prune --tags
|
||||
|
||||
echo "Pushing mirror to gitea..."
|
||||
git push gitea --mirror
|
||||
|
||||
echo "Done: Gitea should now match origin (GitHub)."
|
||||
|
||||
+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"],
|
||||
]
|
||||
|
||||
+590
-101
@@ -17,7 +17,7 @@ use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Rect},
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
use crate::history::{PerCoreHistory, push_capped};
|
||||
use crate::retry::{RetryTiming, compute_retry_timing};
|
||||
@@ -28,11 +28,19 @@ use crate::ui::cpu::{
|
||||
per_core_handle_scrollbar_mouse,
|
||||
};
|
||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||
use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse};
|
||||
use crate::ui::processes::{
|
||||
ProcSortBy, ProcessKeyParams, 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,
|
||||
disks::draw_disks,
|
||||
gpu::draw_gpu,
|
||||
header::{build_header_intervals, build_header_title, 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,
|
||||
@@ -42,6 +50,15 @@ use socktop_connector::{
|
||||
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
||||
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
||||
|
||||
/// Drop duplicate-name entries from a disks payload (the agent occasionally
|
||||
/// reports a partition twice). Done once when fresh disk data arrives so the
|
||||
/// per-frame draw path doesn't have to rebuild a HashSet.
|
||||
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
|
||||
let mut seen: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::with_capacity(disks.len());
|
||||
disks.retain(|d| seen.insert(d.name.clone()));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConnectionState {
|
||||
Connected,
|
||||
@@ -53,8 +70,9 @@ pub struct App {
|
||||
// Latest metrics + histories
|
||||
last_metrics: Option<Metrics>,
|
||||
|
||||
// CPU avg history (0..100)
|
||||
// CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame
|
||||
cpu_hist: VecDeque<u64>,
|
||||
cpu_hist_sum: u64,
|
||||
|
||||
// Per-core history (0..100)
|
||||
per_core_hist: PerCoreHistory,
|
||||
@@ -76,12 +94,49 @@ pub struct App {
|
||||
pub procs_sort_by: ProcSortBy,
|
||||
last_procs_area: Option<ratatui::layout::Rect>,
|
||||
|
||||
// Process selection state
|
||||
pub selected_process_pid: Option<u32>,
|
||||
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,
|
||||
|
||||
// Cached filtered + sorted process indices. Refreshed lazily when any of
|
||||
// (metrics, sort order, search query) changes — input handlers, the draw
|
||||
// path, and auto-scroll all read from this slice so we avoid rebuilding
|
||||
// an indices Vec on every event.
|
||||
procs_filtered: Vec<usize>,
|
||||
procs_filter_dirty: bool,
|
||||
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
|
||||
// parallel to `last_metrics.top_processes`.
|
||||
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
|
||||
procs_row_peak_cpu: f32,
|
||||
|
||||
last_procs_poll: Instant,
|
||||
last_disks_poll: Instant,
|
||||
procs_interval: Duration,
|
||||
disks_interval: Duration,
|
||||
metrics_interval: Duration,
|
||||
|
||||
// Process details polling
|
||||
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
|
||||
pub journal_entries: Option<socktop_connector::JournalResponse>,
|
||||
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
|
||||
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
|
||||
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
|
||||
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
|
||||
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 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,
|
||||
journal_interval: Duration,
|
||||
|
||||
// For reconnects
|
||||
ws_url: String,
|
||||
tls_ca: Option<String>,
|
||||
@@ -90,6 +145,17 @@ pub struct App {
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
|
||||
// Cached title strings — only rebuilt when source values change so the
|
||||
// diff renderer can suppress redraws on idle frames.
|
||||
header_title: String,
|
||||
header_title_key: (String, bool, bool),
|
||||
header_intervals_text: String,
|
||||
header_intervals_key: (u128, u128),
|
||||
net_dl_title: String,
|
||||
net_dl_key: (u64, u64),
|
||||
net_ul_title: String,
|
||||
net_ul_key: (u64, u64),
|
||||
|
||||
// Modal system
|
||||
pub modal_manager: crate::ui::modal::ModalManager,
|
||||
|
||||
@@ -107,6 +173,7 @@ impl App {
|
||||
Self {
|
||||
last_metrics: None,
|
||||
cpu_hist: VecDeque::with_capacity(600),
|
||||
cpu_hist_sum: 0,
|
||||
per_core_hist: PerCoreHistory::new(60),
|
||||
last_net_totals: None,
|
||||
rx_hist: VecDeque::with_capacity(600),
|
||||
@@ -120,6 +187,15 @@ impl App {
|
||||
procs_drag: None,
|
||||
procs_sort_by: ProcSortBy::CpuDesc,
|
||||
last_procs_area: None,
|
||||
selected_process_pid: None,
|
||||
selected_process_index: None,
|
||||
prev_selected_process_pid: None,
|
||||
process_search_active: false,
|
||||
process_search_query: String::new(),
|
||||
procs_filtered: Vec::new(),
|
||||
procs_filter_dirty: true,
|
||||
procs_row_cache: Vec::new(),
|
||||
procs_row_peak_cpu: 0.0,
|
||||
last_procs_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||
@@ -129,11 +205,38 @@ impl App {
|
||||
procs_interval: Duration::from_secs(2),
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
process_details: None,
|
||||
journal_entries: None,
|
||||
process_cpu_history: VecDeque::with_capacity(600),
|
||||
process_cpu_history_sum: 0.0,
|
||||
process_mem_history: VecDeque::with_capacity(600),
|
||||
process_io_read_history: VecDeque::with_capacity(600),
|
||||
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))
|
||||
.unwrap_or_else(Instant::now),
|
||||
last_journal_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(10))
|
||||
.unwrap_or_else(Instant::now),
|
||||
process_details_interval: Duration::from_millis(500),
|
||||
journal_interval: Duration::from_secs(5),
|
||||
ws_url: String::new(),
|
||||
tls_ca: None,
|
||||
verify_hostname: false,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
header_title: String::new(),
|
||||
header_title_key: (String::new(), false, false),
|
||||
header_intervals_text: String::new(),
|
||||
header_intervals_key: (u128::MAX, u128::MAX),
|
||||
net_dl_title: String::new(),
|
||||
net_dl_key: (u64::MAX, u64::MAX),
|
||||
net_ul_title: String::new(),
|
||||
net_ul_key: (u64::MAX, u64::MAX),
|
||||
modal_manager: ModalManager::new(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
last_connection_attempt: Instant::now(),
|
||||
@@ -413,7 +516,10 @@ impl App {
|
||||
_url: &str,
|
||||
_tls_ca: Option<&str>,
|
||||
_verify_hostname: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
loop {
|
||||
// Handle input for modal
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
@@ -520,7 +626,10 @@ impl App {
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
mut ws: SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
loop {
|
||||
// Main event loop
|
||||
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
|
||||
@@ -540,7 +649,10 @@ impl App {
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
ws: &mut SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
loop {
|
||||
// Input (non-blocking)
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
@@ -565,14 +677,88 @@ impl App {
|
||||
continue; // Skip normal key processing
|
||||
}
|
||||
ModalAction::Cancel | ModalAction::Dismiss => {
|
||||
// Modal was dismissed, continue to normal processing
|
||||
// If ProcessDetails modal was dismissed, clear the data to save resources
|
||||
if let Some(crate::ui::modal::ModalType::ProcessDetails {
|
||||
..
|
||||
}) = self.modal_manager.current_modal()
|
||||
{
|
||||
self.clear_process_details();
|
||||
}
|
||||
// Modal was dismissed, skip normal key processing
|
||||
continue;
|
||||
}
|
||||
ModalAction::Confirm => {
|
||||
// Handle confirmation action here if needed in the future
|
||||
}
|
||||
ModalAction::SwitchToParentProcess(_current_pid) => {
|
||||
// Get parent PID from current process details
|
||||
if let Some(details) = &self.process_details
|
||||
&& let Some(parent_pid) = details.process.parent_pid
|
||||
{
|
||||
// Clear current process details
|
||||
self.clear_process_details();
|
||||
// Update selected process to parent
|
||||
self.selected_process_pid = Some(parent_pid);
|
||||
// Open modal for parent process
|
||||
self.modal_manager.push_modal(
|
||||
crate::ui::modal::ModalType::ProcessDetails {
|
||||
pid: parent_pid,
|
||||
},
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ModalAction::Handled => {
|
||||
// Modal consumed the key, don't pass to main window
|
||||
continue;
|
||||
}
|
||||
ModalAction::None => {
|
||||
// Modal is still active but didn't consume the key
|
||||
continue; // Skip normal key processing
|
||||
// Modal didn't handle the key, pass through to normal handling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Exit search mode, keep filter active, and auto-select first result
|
||||
self.process_search_active = false;
|
||||
|
||||
// Auto-select first filtered result
|
||||
let first = self.procs_filter().first().copied();
|
||||
if let (Some(first_idx), Some(m)) =
|
||||
(first, self.last_metrics.as_ref())
|
||||
{
|
||||
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();
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.process_search_query.push(c);
|
||||
self.invalidate_procs_filter();
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,6 +770,36 @@ 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;
|
||||
self.invalidate_procs_filter();
|
||||
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);
|
||||
@@ -603,7 +819,81 @@ impl App {
|
||||
.split(rows[1]);
|
||||
let content = per_core_content_area(top[1]);
|
||||
|
||||
per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize);
|
||||
// Refresh the filtered+sorted index cache once before we
|
||||
// borrow individual fields of `self`.
|
||||
let _ = self.procs_filter();
|
||||
|
||||
// First try process selection (only handles arrows if a process is selected)
|
||||
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(),
|
||||
filtered_indices: &self.procs_filtered,
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// If process selection didn't handle it, use CPU scrolling
|
||||
if !process_handled {
|
||||
per_core_handle_key(
|
||||
&mut self.per_core_scroll,
|
||||
k,
|
||||
content.height as usize,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-scroll to keep selected process visible
|
||||
if let (Some(selected_idx), Some(p_area)) =
|
||||
(self.selected_process_index, self.last_procs_area)
|
||||
&& self.last_metrics.is_some()
|
||||
{
|
||||
let idxs = &self.procs_filtered;
|
||||
|
||||
// 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();
|
||||
self.prev_selected_process_pid = self.selected_process_pid;
|
||||
}
|
||||
|
||||
// Check if Enter was pressed with a process selected
|
||||
if process_handled
|
||||
&& k.code == KeyCode::Enter
|
||||
&& let Some(selected_pid) = self.selected_process_pid
|
||||
{
|
||||
self.modal_manager
|
||||
.push_modal(ModalType::ProcessDetails { pid: selected_pid });
|
||||
}
|
||||
|
||||
let total_rows = self
|
||||
.last_metrics
|
||||
@@ -615,14 +905,13 @@ impl App {
|
||||
total_rows,
|
||||
content.height as usize,
|
||||
);
|
||||
|
||||
if let Some(p_area) = self.last_procs_area {
|
||||
// page size = visible rows (inner height minus header = 1)
|
||||
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
|
||||
processes_handle_key(&mut self.procs_scroll_offset, k, page);
|
||||
}
|
||||
}
|
||||
Event::Mouse(m) => {
|
||||
// If modal is active, don't handle mouse events on the main window
|
||||
if self.modal_manager.is_active() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Layout to get areas
|
||||
let sz = terminal.size()?;
|
||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||
@@ -671,18 +960,40 @@ impl App {
|
||||
content.height as usize,
|
||||
);
|
||||
|
||||
// Processes table: sort by column on header click
|
||||
if let (Some(mm), Some(p_area)) =
|
||||
// Refresh filter cache before partial borrows of self.
|
||||
let _ = self.procs_filter();
|
||||
let search_box_visible =
|
||||
self.process_search_active || !self.process_search_query.is_empty();
|
||||
|
||||
// Processes table: sort by column on header click and handle row selection
|
||||
if let (Some(_mm), Some(p_area)) =
|
||||
(self.last_metrics.as_ref(), self.last_procs_area)
|
||||
&& let Some(new_sort) = processes_handle_mouse(
|
||||
&mut self.procs_scroll_offset,
|
||||
&mut self.procs_drag,
|
||||
m,
|
||||
p_area,
|
||||
mm.top_processes.len(),
|
||||
)
|
||||
{
|
||||
self.procs_sort_by = new_sort;
|
||||
use crate::ui::processes::ProcessMouseParams;
|
||||
let total_rows = self.procs_filtered.len();
|
||||
if let Some(new_sort) =
|
||||
processes_handle_mouse_with_selection(ProcessMouseParams {
|
||||
scroll_offset: &mut self.procs_scroll_offset,
|
||||
selected_process_pid: &mut self.selected_process_pid,
|
||||
selected_process_index: &mut self.selected_process_index,
|
||||
drag: &mut self.procs_drag,
|
||||
mouse: m,
|
||||
area: p_area,
|
||||
total_rows,
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
search_box_visible,
|
||||
filtered_indices: &self.procs_filtered,
|
||||
})
|
||||
{
|
||||
self.procs_sort_by = new_sort;
|
||||
self.invalidate_procs_filter();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if process selection changed via mouse and clear details if so
|
||||
if self.selected_process_pid != self.prev_selected_process_pid {
|
||||
self.clear_process_details();
|
||||
self.prev_selected_process_pid = self.selected_process_pid;
|
||||
}
|
||||
}
|
||||
Event::Resize(_, _) => {}
|
||||
@@ -712,26 +1023,145 @@ impl App {
|
||||
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
let mut updated = false;
|
||||
if let Ok(AgentResponse::Processes(procs)) =
|
||||
ws.request(AgentRequest::Processes).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
updated = true;
|
||||
}
|
||||
if updated {
|
||||
self.invalidate_procs_filter();
|
||||
// Rebuild the pre-formatted row cache for the next
|
||||
// ~N frames. Done once per poll, not per frame.
|
||||
if let Some(mm) = self.last_metrics.as_ref() {
|
||||
self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache(
|
||||
mm,
|
||||
&mut self.procs_row_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.last_procs_poll = Instant::now();
|
||||
}
|
||||
|
||||
// Only poll disks every 5s
|
||||
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
||||
if let Ok(AgentResponse::Disks(disks)) =
|
||||
if let Ok(AgentResponse::Disks(mut disks)) =
|
||||
ws.request(AgentRequest::Disks).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
dedup_disks(&mut disks);
|
||||
mm.disks = disks;
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
}
|
||||
|
||||
// Poll process details when modal is active and process is selected
|
||||
if let Some(pid) = self.selected_process_pid {
|
||||
// Check if ProcessDetails modal is currently active
|
||||
if let Some(crate::ui::modal::ModalType::ProcessDetails { .. }) =
|
||||
self.modal_manager.current_modal()
|
||||
{
|
||||
// Poll process details every 500ms when modal is active
|
||||
if self.last_process_details_poll.elapsed()
|
||||
>= self.process_details_interval
|
||||
{
|
||||
// Use timeout to prevent blocking the event loop
|
||||
match timeout(
|
||||
Duration::from_millis(2000),
|
||||
ws.request(AgentRequest::ProcessMetrics { pid }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
|
||||
// Update history for sparklines
|
||||
let cpu_usage = details.process.cpu_usage;
|
||||
let evicted_cpu = push_capped(
|
||||
&mut self.process_cpu_history,
|
||||
cpu_usage,
|
||||
600,
|
||||
);
|
||||
self.process_cpu_history_sum = self.process_cpu_history_sum
|
||||
+ cpu_usage
|
||||
- evicted_cpu.unwrap_or(0.0);
|
||||
|
||||
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
|
||||
{
|
||||
read.saturating_sub(last)
|
||||
} else {
|
||||
0 // First sample, no delta available
|
||||
};
|
||||
push_capped(
|
||||
&mut self.process_io_read_history,
|
||||
delta,
|
||||
600,
|
||||
);
|
||||
self.last_io_read_bytes = Some(read);
|
||||
}
|
||||
if let Some(write) = details.process.write_bytes {
|
||||
let delta = if let Some(last) = self.last_io_write_bytes
|
||||
{
|
||||
write.saturating_sub(last)
|
||||
} else {
|
||||
0 // First sample, no delta available
|
||||
};
|
||||
push_capped(
|
||||
&mut self.process_io_write_history,
|
||||
delta,
|
||||
600,
|
||||
);
|
||||
self.last_io_write_bytes = Some(write);
|
||||
}
|
||||
|
||||
self.process_details = Some(details);
|
||||
self.process_details_unsupported = false;
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
// Agent doesn't support this feature or timeout occurred
|
||||
// Mark as unsupported so we can show appropriate message
|
||||
self.process_details_unsupported = true;
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
// Wrong response type
|
||||
self.process_details_unsupported = true;
|
||||
}
|
||||
}
|
||||
self.last_process_details_poll = Instant::now();
|
||||
}
|
||||
|
||||
// Poll journal entries every 5s when modal is active
|
||||
if self.last_journal_poll.elapsed() >= self.journal_interval {
|
||||
// Use timeout to prevent blocking the event loop
|
||||
match timeout(
|
||||
Duration::from_millis(2000),
|
||||
ws.request(AgentRequest::JournalEntries { pid }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(AgentResponse::JournalEntries(journal))) => {
|
||||
self.journal_entries = Some(journal);
|
||||
}
|
||||
Ok(Err(_)) | Err(_) | Ok(Ok(_)) => {
|
||||
// Agent doesn't support this feature, error occurred, or wrong response type
|
||||
// Keep journal_entries as None
|
||||
}
|
||||
}
|
||||
self.last_journal_poll = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Connection error - show modal if not already shown
|
||||
@@ -760,24 +1190,65 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the filtered-process cache stale. Call this whenever
|
||||
/// `procs_sort_by`, `process_search_query`, or the top_processes content
|
||||
/// changes — the cache is rebuilt lazily on the next read.
|
||||
pub fn invalidate_procs_filter(&mut self) {
|
||||
self.procs_filter_dirty = true;
|
||||
}
|
||||
|
||||
/// Lazily refresh and return the cached filtered+sorted process indices.
|
||||
/// Empty slice when there are no metrics yet.
|
||||
pub fn procs_filter(&mut self) -> &[usize] {
|
||||
if self.procs_filter_dirty {
|
||||
self.procs_filtered.clear();
|
||||
if let Some(m) = self.last_metrics.as_ref() {
|
||||
crate::ui::processes::fill_filtered_sorted_indices(
|
||||
m,
|
||||
&self.process_search_query,
|
||||
self.procs_sort_by,
|
||||
&mut self.procs_filtered,
|
||||
);
|
||||
}
|
||||
self.procs_filter_dirty = false;
|
||||
}
|
||||
&self.procs_filtered
|
||||
}
|
||||
|
||||
/// Clear process details when modal is closed or selection changes
|
||||
pub fn clear_process_details(&mut self) {
|
||||
self.process_details = None;
|
||||
self.journal_entries = None;
|
||||
self.process_cpu_history.clear();
|
||||
self.process_cpu_history_sum = 0.0;
|
||||
self.process_mem_history.clear();
|
||||
self.process_io_read_history.clear();
|
||||
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;
|
||||
}
|
||||
|
||||
fn update_with_metrics(&mut self, mut m: Metrics) {
|
||||
if let Some(prev) = &self.last_metrics {
|
||||
// Preserve slower fields when the fast payload omits them
|
||||
if let Some(prev) = self.last_metrics.as_mut() {
|
||||
// Preserve slower fields when the fast payload omits them.
|
||||
// prev is about to be dropped so we can move its Vecs instead of cloning.
|
||||
if m.disks.is_empty() {
|
||||
m.disks = prev.disks.clone();
|
||||
m.disks = std::mem::take(&mut prev.disks);
|
||||
}
|
||||
if m.top_processes.is_empty() {
|
||||
m.top_processes = prev.top_processes.clone();
|
||||
m.top_processes = std::mem::take(&mut prev.top_processes);
|
||||
}
|
||||
// Preserve total processes count across fast updates
|
||||
if m.process_count.is_none() {
|
||||
m.process_count = prev.process_count;
|
||||
}
|
||||
}
|
||||
|
||||
// CPU avg history
|
||||
// CPU avg history with running sum
|
||||
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.cpu_hist, v, 600);
|
||||
let evicted = push_capped(&mut self.cpu_hist, v, 600);
|
||||
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
|
||||
|
||||
// Per-core history (push current samples)
|
||||
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
||||
@@ -820,16 +1291,31 @@ impl App {
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
draw_header(
|
||||
f,
|
||||
rows[0],
|
||||
self.last_metrics.as_ref(),
|
||||
self.is_tls,
|
||||
self.has_token,
|
||||
self.metrics_interval,
|
||||
self.procs_interval,
|
||||
);
|
||||
// Header — refresh cached strings only when their inputs change so the
|
||||
// ratatui diff renderer can suppress repaints on idle frames.
|
||||
{
|
||||
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
||||
let key = (
|
||||
hostname.unwrap_or("").to_string(),
|
||||
self.is_tls,
|
||||
self.has_token,
|
||||
);
|
||||
if self.header_title_key != key {
|
||||
self.header_title = build_header_title(hostname, self.is_tls, self.has_token);
|
||||
self.header_title_key = key;
|
||||
}
|
||||
|
||||
let intervals_key = (
|
||||
self.metrics_interval.as_millis(),
|
||||
self.procs_interval.as_millis(),
|
||||
);
|
||||
if self.header_intervals_key != intervals_key {
|
||||
self.header_intervals_text =
|
||||
build_header_intervals(intervals_key.0, intervals_key.1);
|
||||
self.header_intervals_key = intervals_key;
|
||||
}
|
||||
}
|
||||
draw_header(f, rows[0], &self.header_title, &self.header_intervals_text);
|
||||
|
||||
// Top row: left CPU avg, right Per-core (full top-right)
|
||||
let top_lr = ratatui::layout::Layout::default()
|
||||
@@ -837,12 +1323,18 @@ impl App {
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[1]);
|
||||
|
||||
draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
|
||||
draw_cpu_avg_graph(
|
||||
f,
|
||||
top_lr[0],
|
||||
&mut self.cpu_hist,
|
||||
self.cpu_hist_sum,
|
||||
self.last_metrics.as_ref(),
|
||||
);
|
||||
draw_per_core_bars(
|
||||
f,
|
||||
top_lr[1],
|
||||
self.last_metrics.as_ref(),
|
||||
&self.per_core_hist,
|
||||
&mut self.per_core_hist,
|
||||
self.per_core_scroll,
|
||||
);
|
||||
|
||||
@@ -886,26 +1378,33 @@ impl App {
|
||||
.split(bottom_lr[0]);
|
||||
|
||||
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
|
||||
|
||||
// Net titles only change when the throughput or peak changes.
|
||||
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
|
||||
let rx_key = (rx_now, self.rx_peak);
|
||||
if self.net_dl_key != rx_key {
|
||||
self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak);
|
||||
self.net_dl_key = rx_key;
|
||||
}
|
||||
draw_net_spark(
|
||||
f,
|
||||
left_stack[1],
|
||||
&format!(
|
||||
"Download (KB/s) — now: {} | peak: {}",
|
||||
self.rx_hist.back().copied().unwrap_or(0),
|
||||
self.rx_peak
|
||||
),
|
||||
&self.rx_hist,
|
||||
&self.net_dl_title,
|
||||
&mut self.rx_hist,
|
||||
ratatui::style::Color::Green,
|
||||
);
|
||||
|
||||
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
|
||||
let tx_key = (tx_now, self.tx_peak);
|
||||
if self.net_ul_key != tx_key {
|
||||
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
|
||||
self.net_ul_key = tx_key;
|
||||
}
|
||||
draw_net_spark(
|
||||
f,
|
||||
left_stack[2],
|
||||
&format!(
|
||||
"Upload (KB/s) — now: {} | peak: {}",
|
||||
self.tx_hist.back().copied().unwrap_or(0),
|
||||
self.tx_peak
|
||||
),
|
||||
&self.tx_hist,
|
||||
&self.net_ul_title,
|
||||
&mut self.tx_hist,
|
||||
ratatui::style::Color::Blue,
|
||||
);
|
||||
|
||||
@@ -913,60 +1412,50 @@ impl App {
|
||||
let procs_area = bottom_lr[1];
|
||||
// Cache for input handlers
|
||||
self.last_procs_area = Some(procs_area);
|
||||
// Refresh the filter cache before partial borrows of self.
|
||||
let _ = self.procs_filter();
|
||||
crate::ui::processes::draw_top_processes(
|
||||
f,
|
||||
procs_area,
|
||||
self.last_metrics.as_ref(),
|
||||
self.procs_scroll_offset,
|
||||
self.procs_sort_by,
|
||||
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,
|
||||
filtered_indices: &self.procs_filtered,
|
||||
cached_rows: &self.procs_row_cache,
|
||||
peak_cpu: self.procs_row_peak_cpu,
|
||||
},
|
||||
);
|
||||
|
||||
// Render modals on top of everything else
|
||||
if self.modal_manager.is_active() {
|
||||
self.modal_manager.render(f);
|
||||
use crate::ui::modal::{ProcessHistoryData, ProcessModalData};
|
||||
self.modal_manager.render(
|
||||
f,
|
||||
ProcessModalData {
|
||||
details: self.process_details.as_ref(),
|
||||
journal: self.journal_entries.as_ref(),
|
||||
history: ProcessHistoryData {
|
||||
cpu: &self.process_cpu_history,
|
||||
cpu_sum: self.process_cpu_history_sum,
|
||||
mem: &self.process_mem_history,
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_metrics: None,
|
||||
cpu_hist: VecDeque::with_capacity(600),
|
||||
per_core_hist: PerCoreHistory::new(60),
|
||||
last_net_totals: None,
|
||||
rx_hist: VecDeque::with_capacity(600),
|
||||
tx_hist: VecDeque::with_capacity(600),
|
||||
rx_peak: 0,
|
||||
tx_peak: 0,
|
||||
should_quit: false,
|
||||
per_core_scroll: 0,
|
||||
per_core_drag: None,
|
||||
procs_scroll_offset: 0,
|
||||
procs_drag: None,
|
||||
procs_sort_by: ProcSortBy::CpuDesc,
|
||||
last_procs_area: None,
|
||||
last_procs_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||
last_disks_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(5))
|
||||
.unwrap_or_else(Instant::now),
|
||||
procs_interval: Duration::from_secs(2),
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
ws_url: String::new(),
|
||||
tls_ca: None,
|
||||
verify_hostname: false,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
modal_manager: ModalManager::new(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
last_connection_attempt: Instant::now(),
|
||||
original_disconnect_time: None,
|
||||
connection_retry_count: 0,
|
||||
last_auto_retry: None,
|
||||
replacement_connection: None,
|
||||
}
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
+16
-7
@@ -2,16 +2,25 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||
if dq.len() == cap {
|
||||
dq.pop_front();
|
||||
}
|
||||
/// Push a value into a capped deque. Returns the evicted front element if any.
|
||||
/// Callers maintaining a running sum can use this to update the sum without
|
||||
/// re-iterating the whole deque.
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> {
|
||||
let evicted = if dq.len() == cap {
|
||||
dq.pop_front()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
dq.push_back(v);
|
||||
evicted
|
||||
}
|
||||
|
||||
// Keeps a history deque per core with a fixed capacity
|
||||
// Keeps a history deque per core with a fixed capacity.
|
||||
// Storage is u64 so sparkline rendering can hand the slice directly to
|
||||
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
|
||||
// allocation or widening conversion.
|
||||
pub struct PerCoreHistory {
|
||||
pub deques: Vec<VecDeque<u16>>,
|
||||
pub deques: Vec<VecDeque<u64>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
@@ -35,7 +44,7 @@ impl PerCoreHistory {
|
||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||
self.ensure_cores(samples.len());
|
||||
for (i, v) in samples.iter().enumerate() {
|
||||
let val = v.clamp(0.0, 100.0).round() as u16;
|
||||
let val = v.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.deques[i], val, self.cap);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-4
@@ -382,7 +382,16 @@ fn gather_intervals(
|
||||
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
let child = spawn_demo_agent(port)?;
|
||||
let child = match spawn_demo_agent(port) {
|
||||
Ok(child) => child,
|
||||
// The agent ships as its own binary, so a missing one is a setup problem,
|
||||
// not a crash: tell the user how to fix it instead of dumping an io error.
|
||||
Err(e @ DemoAgentError::NotFound(_)) => {
|
||||
eprintln!("{e}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut app = App::new();
|
||||
// Demo mode connects to localhost, so disable hostname verification
|
||||
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||
@@ -399,9 +408,50 @@ impl Drop for DemoGuard {
|
||||
eprintln!("Stopped demo agent on port {}", self.port);
|
||||
}
|
||||
}
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||
#[derive(Debug)]
|
||||
enum DemoAgentError {
|
||||
/// The socktop_agent executable could not be located.
|
||||
NotFound(std::path::PathBuf),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DemoAgentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(candidate) => write!(
|
||||
f,
|
||||
"Could not start demo mode: '{}' was not found{}.\n\
|
||||
\n\
|
||||
Demo mode runs a local agent, which is shipped as a separate binary\n\
|
||||
and is not installed alongside the socktop TUI. Install it with:\n\
|
||||
\n cargo install socktop_agent\n\n\
|
||||
then run socktop again. See {} for other install options.",
|
||||
candidate.display(),
|
||||
// A bare file name means find_agent_executable() fell back to a PATH lookup.
|
||||
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
|
||||
" on your PATH"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
env!("CARGO_PKG_HOMEPAGE"),
|
||||
),
|
||||
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DemoAgentError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::NotFound(_) => None,
|
||||
Self::Io(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
|
||||
let candidate = find_agent_executable();
|
||||
let mut cmd = std::process::Command::new(candidate);
|
||||
let mut cmd = std::process::Command::new(&candidate);
|
||||
cmd.arg("--port").arg(port.to_string());
|
||||
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
||||
|
||||
@@ -409,7 +459,10 @@ fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>>
|
||||
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
||||
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let child = cmd.spawn().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
|
||||
_ => DemoAgentError::Io(e),
|
||||
})?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
Ok(DemoGuard {
|
||||
port,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+196
-64
@@ -7,7 +7,9 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||
widgets::{
|
||||
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::history::PerCoreHistory;
|
||||
@@ -42,8 +44,8 @@ pub fn per_core_content_area(area: Rect) -> Rect {
|
||||
/// Handles key events for per-core CPU bars.
|
||||
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
|
||||
match key.code {
|
||||
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::PageUp => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
@@ -133,11 +135,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
}
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let top_for_offset = |off: usize| -> usize {
|
||||
if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * off + max_off / 2) / max_off
|
||||
}
|
||||
((track - thumb_len) * off + max_off / 2)
|
||||
.checked_div(max_off)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let thumb_top = top_for_offset(offset);
|
||||
|
||||
@@ -190,11 +190,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
// Inverse mapping top -> offset
|
||||
if track > thumb_len {
|
||||
let denom = track - thumb_len;
|
||||
offset = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
(new_top * max_off + denom / 2) / denom
|
||||
};
|
||||
offset = (new_top * max_off + denom / 2)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0);
|
||||
} else {
|
||||
offset = 0;
|
||||
}
|
||||
@@ -234,26 +232,71 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
|
||||
}
|
||||
|
||||
/// Draws the CPU average sparkline graph.
|
||||
///
|
||||
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
|
||||
/// fold the (up to 600-element) deque on every frame.
|
||||
pub fn draw_cpu_avg_graph(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
hist: &mut std::collections::VecDeque<u64>,
|
||||
hist_sum: u64,
|
||||
m: Option<&Metrics>,
|
||||
) {
|
||||
let avg_cpu = if hist.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
hist_sum as f64 / hist.len() as f64
|
||||
};
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||
// us skip the per-frame Vec allocation .collect() used to do.
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
|
||||
// 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.
|
||||
@@ -261,7 +304,7 @@ pub fn draw_per_core_bars(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
per_core_hist: &PerCoreHistory,
|
||||
per_core_hist: &mut PerCoreHistory,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
f.render_widget(
|
||||
@@ -306,7 +349,7 @@ pub fn draw_per_core_bars(
|
||||
let rect = vchunks[i];
|
||||
let hchunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
||||
.split(rect);
|
||||
|
||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||
@@ -317,12 +360,17 @@ pub fn draw_per_core_bars(
|
||||
.map(|v| v as f32)
|
||||
.unwrap_or(curr);
|
||||
|
||||
// Trend indicator. Various Unicode glyphs we tried for the "flat"
|
||||
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
|
||||
// coverage; combined with the next column being `100.0` they read as
|
||||
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
|
||||
// for the flat case — no character, no fallback, no confusion.
|
||||
let trend = if curr > older + 0.2 {
|
||||
"↑"
|
||||
} else if curr + 0.2 < older {
|
||||
"↓"
|
||||
} else {
|
||||
"╌"
|
||||
" "
|
||||
};
|
||||
|
||||
let fg = match curr {
|
||||
@@ -331,24 +379,24 @@ pub fn draw_per_core_bars(
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
let hist: Vec<u64> = per_core_hist
|
||||
.deques
|
||||
.get(idx)
|
||||
.map(|d| {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
d.iter().skip(start).map(|&v| v as u64).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
||||
// to Sparkline without allocating a fresh Vec each frame.
|
||||
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
let slice = &d.make_contiguous()[start..];
|
||||
let spark = Sparkline::default()
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
}
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.data(&hist)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
|
||||
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
|
||||
// Hard space between the trend mark and the number — even if the
|
||||
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
||||
// them, this space prevents the trend mark from visually joining
|
||||
// `100.0` to look like a negative value.
|
||||
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
||||
let line = Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
@@ -356,38 +404,122 @@ pub fn draw_per_core_bars(
|
||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||
}
|
||||
|
||||
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
||||
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
||||
// content fits in the viewport, matching the previous behaviour.
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
if scroll_area.height >= 3 {
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let thumb_top = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
||||
};
|
||||
|
||||
// Build lines: top arrow, track (with thumb), bottom arrow
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
|
||||
lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW))));
|
||||
for i in 0..track {
|
||||
if i >= thumb_top && i < thumb_top + thumb_len {
|
||||
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
||||
|
||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.thumb_symbol("█")
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_style(Style::default().fg(SB_THUMB))
|
||||
.track_style(Style::default().fg(SB_TRACK))
|
||||
.begin_style(Style::default().fg(SB_ARROW))
|
||||
.end_style(Style::default().fg(SB_ARROW));
|
||||
let mut state = ScrollbarState::new(max_off).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use socktop_connector::Metrics;
|
||||
|
||||
fn fake_metrics(cores: Vec<f32>) -> Metrics {
|
||||
Metrics {
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: cores,
|
||||
mem_total: 1024,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: "t".into(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
process_count: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump(terminal: &Terminal<TestBackend>) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area().height {
|
||||
for x in 0..buf.area().width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
|
||||
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
|
||||
/// coverage. When a core sat at exactly 100% the label rendered as
|
||||
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
|
||||
/// literal space for the flat case AND insert a hard space between every
|
||||
/// trend mark and the number, so no glyph substitution can produce a
|
||||
/// "-100" substring. We assert that across flat AND transitioning cores.
|
||||
#[test]
|
||||
fn percore_label_never_renders_as_negative() {
|
||||
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
|
||||
let mut hist = PerCoreHistory::new(60);
|
||||
hist.ensure_cores(4);
|
||||
// First sample: history is empty, no trend on first frame.
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
// Second sample: identical values → flat trend (the user's complaint).
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---flat 100% render---\n{out}");
|
||||
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
|
||||
|
||||
// Decreasing trend at saturation: hist was high, current drops a bit.
|
||||
let mut hist2 = PerCoreHistory::new(60);
|
||||
hist2.ensure_cores(4);
|
||||
for _ in 0..25 {
|
||||
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
|
||||
}
|
||||
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
|
||||
hist2.push_samples(&m2.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---decreasing render---\n{out}");
|
||||
assert!(
|
||||
!out.contains("-100"),
|
||||
"found '-100' in decreasing-trend render"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("-80"),
|
||||
"found '-80' in decreasing-trend render"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-6
@@ -24,6 +24,9 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplication is performed once on the App side when fresh disk data
|
||||
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
||||
// here would rebuild a HashSet ~10x per refresh for no reason).
|
||||
let per_disk_h = 3u16;
|
||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||
|
||||
@@ -53,23 +56,43 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
ratatui::style::Color::Red
|
||||
};
|
||||
|
||||
// Add indentation for partitions
|
||||
let indent = if d.is_partition { "└─" } else { "" };
|
||||
|
||||
// Add temperature if available
|
||||
let temp_str = d
|
||||
.temperature
|
||||
.map(|t| format!(" {}°C", t.round() as i32))
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = format!(
|
||||
"{} {} {} / {} ({}%)",
|
||||
"{}{}{}{} {} / {} ({}%)",
|
||||
indent,
|
||||
disk_icon(&d.name),
|
||||
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||
temp_str,
|
||||
human(used),
|
||||
human(d.total),
|
||||
pct
|
||||
);
|
||||
|
||||
// Indent the entire card (block) for partitions to align with └─ prefix (4 chars)
|
||||
let card_indent = if d.is_partition { 4 } else { 0 };
|
||||
let card_rect = Rect {
|
||||
x: slot.x + card_indent,
|
||||
y: slot.y,
|
||||
width: slot.width.saturating_sub(card_indent),
|
||||
height: slot.height,
|
||||
};
|
||||
|
||||
let card = Block::default().borders(Borders::ALL).title(title);
|
||||
f.render_widget(card, *slot);
|
||||
f.render_widget(card, card_rect);
|
||||
|
||||
let inner_card = Rect {
|
||||
x: slot.x + 1,
|
||||
y: slot.y + 1,
|
||||
width: slot.width.saturating_sub(2),
|
||||
height: slot.height.saturating_sub(2),
|
||||
x: card_rect.x + 1,
|
||||
y: card_rect.y + 1,
|
||||
width: card_rect.width.saturating_sub(2),
|
||||
height: card_rect.height.saturating_sub(2),
|
||||
};
|
||||
if inner_card.height == 0 {
|
||||
continue;
|
||||
|
||||
+33
-41
@@ -1,52 +1,44 @@
|
||||
//! Top header with hostname and CPU temperature indicator.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn draw_header(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
is_tls: bool,
|
||||
has_token: bool,
|
||||
metrics_interval: Duration,
|
||||
procs_interval: Duration,
|
||||
) {
|
||||
let base = if let Some(mm) = m {
|
||||
let temp = mm
|
||||
.cpu_temp_c
|
||||
.map(|t| {
|
||||
let icon = if t < 50.0 {
|
||||
"😎"
|
||||
} else if t < 85.0 {
|
||||
"⚠️"
|
||||
} else {
|
||||
"🔥"
|
||||
};
|
||||
format!("CPU Temp: {t:.1}°C {icon}")
|
||||
})
|
||||
.unwrap_or_else(|| "CPU Temp: N/A".into());
|
||||
format!("socktop — host: {} | {}", mm.hostname, temp)
|
||||
} else {
|
||||
"socktop — connecting...".into()
|
||||
/// Build the header's left-side title from session state. Callers cache the
|
||||
/// returned String and only rebuild it when one of the inputs changes.
|
||||
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
|
||||
let base = match hostname {
|
||||
Some(h) => format!("socktop — host: {h}"),
|
||||
None => "socktop — connecting...".into(),
|
||||
};
|
||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||
// Token indicator
|
||||
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let mut parts = vec![base, tls_txt.into()];
|
||||
if !tok_txt.is_empty() {
|
||||
parts.push(tok_txt.into());
|
||||
if has_token {
|
||||
parts.push("🔑 token".into());
|
||||
}
|
||||
parts.push("(a: about, h: help, q: quit)".into());
|
||||
parts.join(" | ")
|
||||
}
|
||||
|
||||
/// Build the right-side polling interval text. Callers cache this string.
|
||||
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
|
||||
format!("⏱ {metrics_ms}ms metrics | {procs_ms}ms procs")
|
||||
}
|
||||
|
||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
|
||||
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);
|
||||
}
|
||||
parts.push(intervals);
|
||||
parts.push("(q to quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ pub mod gpu;
|
||||
pub mod header;
|
||||
pub mod mem;
|
||||
pub mod modal;
|
||||
pub mod modal_connection;
|
||||
pub mod modal_format;
|
||||
pub mod modal_process;
|
||||
pub mod modal_types;
|
||||
pub mod net;
|
||||
pub mod processes;
|
||||
pub mod swap;
|
||||
|
||||
+363
-341
@@ -1,66 +1,29 @@
|
||||
//! Modal window system for socktop TUI application
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::theme::{
|
||||
BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT,
|
||||
BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER,
|
||||
ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE,
|
||||
LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG,
|
||||
MODAL_DIM_BG, MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG,
|
||||
MODAL_RETRY_LABEL_FG, MODAL_TITLE_FG,
|
||||
};
|
||||
use super::theme::MODAL_DIM_BG;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModalType {
|
||||
ConnectionError {
|
||||
message: String,
|
||||
disconnected_at: Instant,
|
||||
retry_count: u32,
|
||||
auto_retry_countdown: Option<u64>,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Confirmation {
|
||||
title: String,
|
||||
message: String,
|
||||
confirm_text: String,
|
||||
cancel_text: String,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Info { title: String, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ModalAction {
|
||||
None,
|
||||
RetryConnection,
|
||||
ExitApp,
|
||||
Confirm,
|
||||
Cancel,
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ModalButton {
|
||||
Retry,
|
||||
Exit,
|
||||
Confirm,
|
||||
Cancel,
|
||||
Ok,
|
||||
}
|
||||
// Re-export types from modal_types
|
||||
pub use super::modal_types::{
|
||||
ModalAction, ModalButton, ModalType, ProcessHistoryData, ProcessModalData,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModalManager {
|
||||
stack: Vec<ModalType>,
|
||||
active_button: ModalButton,
|
||||
pub(super) active_button: ModalButton,
|
||||
pub thread_scroll_offset: usize,
|
||||
pub journal_scroll_offset: usize,
|
||||
pub thread_scroll_max: usize,
|
||||
pub journal_scroll_max: usize,
|
||||
pub help_scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl ModalManager {
|
||||
@@ -68,16 +31,39 @@ impl ModalManager {
|
||||
Self {
|
||||
stack: Vec::new(),
|
||||
active_button: ModalButton::Retry,
|
||||
thread_scroll_offset: 0,
|
||||
journal_scroll_offset: 0,
|
||||
thread_scroll_max: 0,
|
||||
journal_scroll_max: 0,
|
||||
help_scroll_offset: 0,
|
||||
}
|
||||
}
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.stack.is_empty()
|
||||
}
|
||||
|
||||
pub fn current_modal(&self) -> Option<&ModalType> {
|
||||
self.stack.last()
|
||||
}
|
||||
|
||||
pub fn push_modal(&mut self, modal: ModalType) {
|
||||
self.stack.push(modal);
|
||||
self.active_button = match self.stack.last() {
|
||||
Some(ModalType::ConnectionError { .. }) => ModalButton::Retry,
|
||||
Some(ModalType::ProcessDetails { .. }) => {
|
||||
// Reset scroll state for new process details
|
||||
self.thread_scroll_offset = 0;
|
||||
self.journal_scroll_offset = 0;
|
||||
self.thread_scroll_max = 0;
|
||||
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,
|
||||
@@ -88,6 +74,9 @@ impl ModalManager {
|
||||
if let Some(next) = self.stack.last() {
|
||||
self.active_button = match next {
|
||||
ModalType::ConnectionError { .. } => ModalButton::Retry,
|
||||
ModalType::ProcessDetails { .. } => ModalButton::Ok,
|
||||
ModalType::About => ModalButton::Ok,
|
||||
ModalType::Help => ModalButton::Ok,
|
||||
ModalType::Confirmation { .. } => ModalButton::Confirm,
|
||||
ModalType::Info { .. } => ModalButton::Ok,
|
||||
};
|
||||
@@ -135,6 +124,101 @@ impl ModalManager {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
// Close all ProcessDetails modals at once (handles parent navigation chain)
|
||||
while matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.pop_modal();
|
||||
}
|
||||
ModalAction::Dismiss
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Char('J') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.thread_scroll_offset = self
|
||||
.thread_scroll_offset
|
||||
.saturating_add(1)
|
||||
.min(self.thread_scroll_max);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(1);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.thread_scroll_offset = self
|
||||
.thread_scroll_offset
|
||||
.saturating_add(10)
|
||||
.min(self.thread_scroll_max);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.thread_scroll_offset = self.thread_scroll_offset.saturating_sub(10);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('[') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.journal_scroll_offset = self.journal_scroll_offset.saturating_sub(1);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char(']') => {
|
||||
if matches!(self.stack.last(), Some(ModalType::ProcessDetails { .. })) {
|
||||
self.journal_scroll_offset = self
|
||||
.journal_scroll_offset
|
||||
.saturating_add(1)
|
||||
.min(self.journal_scroll_max);
|
||||
ModalAction::Handled
|
||||
} else {
|
||||
ModalAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') | KeyCode::Char('P') => {
|
||||
// Switch to parent process if it exists
|
||||
if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() {
|
||||
// We need to get the parent PID from the process details
|
||||
// For now, return a special action that the app can handle
|
||||
// The app has access to the process details and can extract parent_pid
|
||||
ModalAction::SwitchToParentProcess(*pid)
|
||||
} else {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -144,6 +228,18 @@ impl ModalManager {
|
||||
ModalAction::RetryConnection
|
||||
}
|
||||
(Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp,
|
||||
(Some(ModalType::ProcessDetails { .. }), ModalButton::Ok) => {
|
||||
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) => {
|
||||
@@ -166,10 +262,10 @@ impl ModalManager {
|
||||
self.next_button();
|
||||
}
|
||||
|
||||
pub fn render(&self, f: &mut Frame) {
|
||||
if let Some(m) = self.stack.last() {
|
||||
pub fn render(&mut self, f: &mut Frame, data: ProcessModalData) {
|
||||
if let Some(m) = self.stack.last().cloned() {
|
||||
self.render_background_dim(f);
|
||||
self.render_modal_content(f, m);
|
||||
self.render_modal_content(f, &m, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,9 +280,27 @@ impl ModalManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn render_modal_content(&self, f: &mut Frame, modal: &ModalType) {
|
||||
fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) {
|
||||
let area = f.area();
|
||||
let modal_area = self.centered_rect(70, 50, area);
|
||||
// Different sizes for different modal types
|
||||
let modal_area = match modal {
|
||||
ModalType::ProcessDetails { .. } => {
|
||||
// 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)
|
||||
}
|
||||
};
|
||||
f.render_widget(Clear, modal_area);
|
||||
match modal {
|
||||
ModalType::ConnectionError {
|
||||
@@ -202,6 +316,11 @@ impl ModalManager {
|
||||
*retry_count,
|
||||
*auto_retry_countdown,
|
||||
),
|
||||
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,
|
||||
@@ -212,279 +331,6 @@ impl ModalManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_connection_error(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
message: &str,
|
||||
disconnected_at: Instant,
|
||||
retry_count: u32,
|
||||
auto_retry_countdown: Option<u64>,
|
||||
) {
|
||||
let duration_text = format_duration(disconnected_at.elapsed());
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(area);
|
||||
let block = Block::default()
|
||||
.title(ICON_WARNING_TITLE)
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||
.style(Style::default().bg(MODAL_BG).fg(MODAL_FG));
|
||||
f.render_widget(block, area);
|
||||
|
||||
let content_area = chunks[1];
|
||||
let max_w = content_area.width.saturating_sub(15) as usize;
|
||||
let clean_message = if message.to_lowercase().contains("hostname verification")
|
||||
|| message.contains("socktop_connector")
|
||||
{
|
||||
"Connection failed - hostname verification disabled".to_string()
|
||||
} else if message.contains("Failed to fetch metrics:") {
|
||||
if let Some(p) = message.find(':') {
|
||||
let ess = message[p + 1..].trim();
|
||||
if ess.len() > max_w {
|
||||
format!("{}...", &ess[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
ess.to_string()
|
||||
}
|
||||
} else {
|
||||
"Connection error".to_string()
|
||||
}
|
||||
} else if message.starts_with("Retry failed:") {
|
||||
if let Some(p) = message.find(':') {
|
||||
let ess = message[p + 1..].trim();
|
||||
if ess.len() > max_w {
|
||||
format!("{}...", &ess[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
ess.to_string()
|
||||
}
|
||||
} else {
|
||||
"Retry failed".to_string()
|
||||
}
|
||||
} else if message.len() > max_w {
|
||||
format!("{}...", &message[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
let truncate = |s: &str| {
|
||||
if s.len() > max_w {
|
||||
format!("{}...", &s[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let agent_text = truncate("📡 Cannot connect to socktop agent");
|
||||
let message_text = truncate(&clean_message);
|
||||
let duration_display = truncate(&duration_text);
|
||||
let retry_display = truncate(&retry_count.to_string());
|
||||
let countdown_text = auto_retry_countdown.map(|c| {
|
||||
if c == 0 {
|
||||
"Auto retry now...".to_string()
|
||||
} else {
|
||||
format!("{c}s")
|
||||
}
|
||||
});
|
||||
|
||||
// Determine if we have enough space (height + width) to show large centered icon
|
||||
let icon_max_width = LARGE_ERROR_ICON
|
||||
.iter()
|
||||
.map(|l| l.trim().chars().count())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8)
|
||||
&& content_area.width >= icon_max_width + 6; // small margin for borders/padding
|
||||
let mut icon_lines: Vec<Line> = Vec::new();
|
||||
if large_allowed {
|
||||
for &raw in LARGE_ERROR_ICON.iter() {
|
||||
let trimmed = raw.trim();
|
||||
icon_lines.push(Line::from(
|
||||
trimmed
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch == '!' {
|
||||
Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if ch == '/' || ch == '\\' || ch == '_' {
|
||||
// keep outline in pink
|
||||
Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default()
|
||||
.fg(MODAL_ICON_PINK)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if ch == ' ' {
|
||||
Span::raw(" ")
|
||||
} else {
|
||||
Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
}
|
||||
icon_lines.push(Line::from("")); // blank spacer line below icon
|
||||
}
|
||||
|
||||
let mut info_lines: Vec<Line> = Vec::new();
|
||||
if !large_allowed {
|
||||
info_lines.push(Line::from(vec![Span::styled(
|
||||
ICON_CLUSTER,
|
||||
Style::default().fg(MODAL_ICON_PINK),
|
||||
)]));
|
||||
info_lines.push(Line::from(""));
|
||||
}
|
||||
info_lines.push(Line::from(vec![Span::styled(
|
||||
&agent_text,
|
||||
Style::default().fg(MODAL_AGENT_FG),
|
||||
)]));
|
||||
info_lines.push(Line::from(""));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)),
|
||||
Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)),
|
||||
]));
|
||||
info_lines.push(Line::from(""));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
ICON_OFFLINE_LABEL,
|
||||
Style::default().fg(MODAL_OFFLINE_LABEL_FG),
|
||||
),
|
||||
Span::styled(
|
||||
&duration_display,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)),
|
||||
Span::styled(
|
||||
&retry_display,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
if let Some(cd) = &countdown_text {
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
ICON_COUNTDOWN_LABEL,
|
||||
Style::default().fg(MODAL_COUNTDOWN_LABEL_FG),
|
||||
),
|
||||
Span::styled(
|
||||
cd,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let constrained = Rect {
|
||||
x: content_area.x + 2,
|
||||
y: content_area.y,
|
||||
width: content_area.width.saturating_sub(4),
|
||||
height: content_area.height,
|
||||
};
|
||||
if large_allowed {
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(icon_lines.len() as u16),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(constrained);
|
||||
// Center the icon block; each line already trimmed so per-line centering keeps shape
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(icon_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: false }),
|
||||
split[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(info_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
split[1],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(info_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
constrained,
|
||||
);
|
||||
}
|
||||
|
||||
let button_area = Rect {
|
||||
x: chunks[2].x,
|
||||
y: chunks[2].y,
|
||||
width: chunks[2].width,
|
||||
height: chunks[2].height.saturating_sub(1),
|
||||
};
|
||||
self.render_connection_error_buttons(f, button_area);
|
||||
}
|
||||
|
||||
fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) {
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(30),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(30),
|
||||
])
|
||||
.split(area);
|
||||
let retry_style = if self.active_button == ModalButton::Retry {
|
||||
Style::default()
|
||||
.bg(BTN_RETRY_BG_ACTIVE)
|
||||
.fg(BTN_RETRY_FG_ACTIVE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(BTN_RETRY_FG_INACTIVE)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
let exit_style = if self.active_button == ModalButton::Exit {
|
||||
Style::default()
|
||||
.bg(BTN_EXIT_BG_ACTIVE)
|
||||
.fg(BTN_EXIT_FG_ACTIVE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(BTN_EXIT_FG_INACTIVE)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(Line::from(vec![Span::styled(
|
||||
BTN_RETRY_TEXT,
|
||||
retry_style,
|
||||
)])))
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(Line::from(vec![Span::styled(
|
||||
BTN_EXIT_TEXT,
|
||||
exit_style,
|
||||
)])))
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_confirmation(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
@@ -577,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)
|
||||
@@ -596,17 +632,3 @@ impl ModalManager {
|
||||
.split(vert[1])[1]
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(duration: Duration) -> String {
|
||||
let total = duration.as_secs();
|
||||
let h = total / 3600;
|
||||
let m = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if h > 0 {
|
||||
format!("{h}h {m}m {s}s")
|
||||
} else if m > 0 {
|
||||
format!("{m}m {s}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Connection error modal rendering
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::modal_format::format_duration;
|
||||
use super::theme::{
|
||||
BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT,
|
||||
BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER,
|
||||
ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE,
|
||||
LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG,
|
||||
MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG, MODAL_RETRY_LABEL_FG,
|
||||
MODAL_TITLE_FG,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use super::modal::{ModalButton, ModalManager};
|
||||
|
||||
impl ModalManager {
|
||||
pub(super) fn render_connection_error(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
message: &str,
|
||||
disconnected_at: Instant,
|
||||
retry_count: u32,
|
||||
auto_retry_countdown: Option<u64>,
|
||||
) {
|
||||
let duration_text = format_duration(disconnected_at.elapsed());
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(area);
|
||||
let block = Block::default()
|
||||
.title(
|
||||
Line::from(ICON_WARNING_TITLE).style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||
.style(Style::default().bg(MODAL_BG).fg(MODAL_FG));
|
||||
f.render_widget(block, area);
|
||||
|
||||
let content_area = chunks[1];
|
||||
let max_w = content_area.width.saturating_sub(15) as usize;
|
||||
let clean_message = if message.to_lowercase().contains("hostname verification")
|
||||
|| message.contains("socktop_connector")
|
||||
{
|
||||
"Connection failed - hostname verification disabled".to_string()
|
||||
} else if message.contains("Failed to fetch metrics:") {
|
||||
if let Some(p) = message.find(':') {
|
||||
let ess = message[p + 1..].trim();
|
||||
if ess.len() > max_w {
|
||||
format!("{}...", &ess[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
ess.to_string()
|
||||
}
|
||||
} else {
|
||||
"Connection error".to_string()
|
||||
}
|
||||
} else if message.starts_with("Retry failed:") {
|
||||
if let Some(p) = message.find(':') {
|
||||
let ess = message[p + 1..].trim();
|
||||
if ess.len() > max_w {
|
||||
format!("{}...", &ess[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
ess.to_string()
|
||||
}
|
||||
} else {
|
||||
"Retry failed".to_string()
|
||||
}
|
||||
} else if message.len() > max_w {
|
||||
format!("{}...", &message[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
let truncate = |s: &str| {
|
||||
if s.len() > max_w {
|
||||
format!("{}...", &s[..max_w.saturating_sub(3)])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let agent_text = truncate("📡 Cannot connect to socktop agent");
|
||||
let message_text = truncate(&clean_message);
|
||||
let duration_display = truncate(&duration_text);
|
||||
let retry_display = truncate(&retry_count.to_string());
|
||||
let countdown_text = auto_retry_countdown.map(|c| {
|
||||
if c == 0 {
|
||||
"Auto retry now...".to_string()
|
||||
} else {
|
||||
format!("{c}s")
|
||||
}
|
||||
});
|
||||
|
||||
// Determine if we have enough space (height + width) to show large centered icon
|
||||
let icon_max_width = LARGE_ERROR_ICON
|
||||
.iter()
|
||||
.map(|l| l.trim().chars().count())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8)
|
||||
&& content_area.width >= icon_max_width + 6; // small margin for borders/padding
|
||||
let mut icon_lines: Vec<Line> = Vec::new();
|
||||
if large_allowed {
|
||||
for &raw in LARGE_ERROR_ICON.iter() {
|
||||
let trimmed = raw.trim();
|
||||
icon_lines.push(Line::from(
|
||||
trimmed
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch == '!' {
|
||||
Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if ch == '/' || ch == '\\' || ch == '_' {
|
||||
// keep outline in pink
|
||||
Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default()
|
||||
.fg(MODAL_ICON_PINK)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if ch == ' ' {
|
||||
Span::raw(" ")
|
||||
} else {
|
||||
Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
}
|
||||
icon_lines.push(Line::from("")); // blank spacer line below icon
|
||||
}
|
||||
|
||||
let mut info_lines: Vec<Line> = Vec::new();
|
||||
if !large_allowed {
|
||||
info_lines.push(Line::from(vec![Span::styled(
|
||||
ICON_CLUSTER,
|
||||
Style::default().fg(MODAL_ICON_PINK),
|
||||
)]));
|
||||
info_lines.push(Line::from(""));
|
||||
}
|
||||
info_lines.push(Line::from(vec![Span::styled(
|
||||
&agent_text,
|
||||
Style::default().fg(MODAL_AGENT_FG),
|
||||
)]));
|
||||
info_lines.push(Line::from(""));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)),
|
||||
Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)),
|
||||
]));
|
||||
info_lines.push(Line::from(""));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
ICON_OFFLINE_LABEL,
|
||||
Style::default().fg(MODAL_OFFLINE_LABEL_FG),
|
||||
),
|
||||
Span::styled(
|
||||
&duration_display,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)),
|
||||
Span::styled(
|
||||
&retry_display,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
if let Some(cd) = &countdown_text {
|
||||
info_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
ICON_COUNTDOWN_LABEL,
|
||||
Style::default().fg(MODAL_COUNTDOWN_LABEL_FG),
|
||||
),
|
||||
Span::styled(
|
||||
cd,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let constrained = Rect {
|
||||
x: content_area.x + 2,
|
||||
y: content_area.y,
|
||||
width: content_area.width.saturating_sub(4),
|
||||
height: content_area.height,
|
||||
};
|
||||
if large_allowed {
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(icon_lines.len() as u16),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(constrained);
|
||||
// Center the icon block; each line already trimmed so per-line centering keeps shape
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(icon_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: false }),
|
||||
split[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(info_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
split[1],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(info_lines))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
constrained,
|
||||
);
|
||||
}
|
||||
|
||||
let button_area = Rect {
|
||||
x: chunks[2].x,
|
||||
y: chunks[2].y,
|
||||
width: chunks[2].width,
|
||||
height: chunks[2].height.saturating_sub(1),
|
||||
};
|
||||
self.render_connection_error_buttons(f, button_area);
|
||||
}
|
||||
|
||||
fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) {
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(30),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(30),
|
||||
])
|
||||
.split(area);
|
||||
let retry_style = if self.active_button == ModalButton::Retry {
|
||||
Style::default()
|
||||
.bg(BTN_RETRY_BG_ACTIVE)
|
||||
.fg(BTN_RETRY_FG_ACTIVE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(BTN_RETRY_FG_INACTIVE)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
let exit_style = if self.active_button == ModalButton::Exit {
|
||||
Style::default()
|
||||
.bg(BTN_EXIT_BG_ACTIVE)
|
||||
.fg(BTN_EXIT_FG_ACTIVE)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(BTN_EXIT_FG_INACTIVE)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(Line::from(vec![Span::styled(
|
||||
BTN_RETRY_TEXT,
|
||||
retry_style,
|
||||
)])))
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(Line::from(vec![Span::styled(
|
||||
BTN_EXIT_TEXT,
|
||||
exit_style,
|
||||
)])))
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[3],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Formatting utilities for process details modal
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Format uptime in human-readable form
|
||||
pub fn format_uptime(secs: u64) -> String {
|
||||
let days = secs / 86400;
|
||||
let hours = (secs % 86400) / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
let seconds = secs % 60;
|
||||
|
||||
if days > 0 {
|
||||
format!("{days}d {hours}h {minutes}m")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h {minutes}m {seconds}s")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}m {seconds}s")
|
||||
} else {
|
||||
format!("{seconds}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format duration in human-readable form
|
||||
pub fn format_duration(duration: Duration) -> String {
|
||||
let total = duration.as_secs();
|
||||
let h = total / 3600;
|
||||
let m = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if h > 0 {
|
||||
format!("{h}h {m}m {s}s")
|
||||
} else if m > 0 {
|
||||
format!("{m}m {s}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize CPU usage to 0-100% by dividing by thread count
|
||||
pub fn normalize_cpu_usage(cpu_usage: f32, thread_count: u32) -> f32 {
|
||||
let threads = thread_count.max(1) as f32;
|
||||
(cpu_usage / threads).min(100.0)
|
||||
}
|
||||
|
||||
/// Calculate dynamic Y-axis maximum in 10% increments
|
||||
pub fn calculate_dynamic_y_max(max_value: f64) -> f64 {
|
||||
((max_value / 10.0).ceil() * 10.0).clamp(10.0, 100.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_seconds() {
|
||||
assert_eq!(format_uptime(45), "45s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_minutes() {
|
||||
assert_eq!(format_uptime(125), "2m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_hours() {
|
||||
assert_eq!(format_uptime(3665), "1h 1m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_uptime_days() {
|
||||
assert_eq!(format_uptime(90061), "1d 1h 1m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_single_thread() {
|
||||
assert_eq!(normalize_cpu_usage(50.0, 1), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_multi_thread() {
|
||||
assert_eq!(normalize_cpu_usage(400.0, 4), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_zero_threads() {
|
||||
// Should default to 1 thread to avoid division by zero
|
||||
assert_eq!(normalize_cpu_usage(100.0, 0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cpu_caps_at_100() {
|
||||
assert_eq!(normalize_cpu_usage(150.0, 1), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_y_max_rounds_up() {
|
||||
assert_eq!(calculate_dynamic_y_max(15.0), 20.0);
|
||||
assert_eq!(calculate_dynamic_y_max(25.0), 30.0);
|
||||
assert_eq!(calculate_dynamic_y_max(5.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_y_max_minimum() {
|
||||
assert_eq!(calculate_dynamic_y_max(0.0), 10.0);
|
||||
assert_eq!(calculate_dynamic_y_max(3.0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_y_max_caps_at_100() {
|
||||
assert_eq!(calculate_dynamic_y_max(95.0), 100.0);
|
||||
assert_eq!(calculate_dynamic_y_max(100.0), 100.0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
//! Type definitions for modal system
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// History data for process metrics rendering
|
||||
pub struct ProcessHistoryData<'a> {
|
||||
pub cpu: &'a std::collections::VecDeque<f32>,
|
||||
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
|
||||
pub cpu_sum: f32,
|
||||
pub mem: &'a std::collections::VecDeque<u64>,
|
||||
pub io_read: &'a std::collections::VecDeque<u64>,
|
||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||
}
|
||||
|
||||
/// Process data for modal rendering
|
||||
pub struct ProcessModalData<'a> {
|
||||
pub details: Option<&'a socktop_connector::ProcessMetricsResponse>,
|
||||
pub journal: Option<&'a socktop_connector::JournalResponse>,
|
||||
pub history: ProcessHistoryData<'a>,
|
||||
pub max_mem_bytes: u64,
|
||||
pub unsupported: bool,
|
||||
}
|
||||
|
||||
/// Parameters for rendering scatter plot
|
||||
pub(super) struct ScatterPlotParams<'a> {
|
||||
pub process: &'a socktop_connector::DetailedProcessInfo,
|
||||
pub main_user_ms: f64,
|
||||
pub main_system_ms: f64,
|
||||
pub max_user: f64,
|
||||
pub max_system: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModalType {
|
||||
ConnectionError {
|
||||
message: String,
|
||||
disconnected_at: Instant,
|
||||
retry_count: u32,
|
||||
auto_retry_countdown: Option<u64>,
|
||||
},
|
||||
ProcessDetails {
|
||||
pid: u32,
|
||||
},
|
||||
About,
|
||||
Help,
|
||||
#[allow(dead_code)]
|
||||
Confirmation {
|
||||
title: String,
|
||||
message: String,
|
||||
confirm_text: String,
|
||||
cancel_text: String,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Info {
|
||||
title: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ModalAction {
|
||||
None, // Modal didn't handle the key, pass to main window
|
||||
Handled, // Modal handled the key, don't pass to main window
|
||||
RetryConnection,
|
||||
ExitApp,
|
||||
Confirm,
|
||||
Cancel,
|
||||
Dismiss,
|
||||
SwitchToParentProcess(u32), // Switch to viewing parent process details
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ModalButton {
|
||||
Retry,
|
||||
Exit,
|
||||
Confirm,
|
||||
Cancel,
|
||||
Ok,
|
||||
}
|
||||
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
hist: &VecDeque<u64>,
|
||||
hist: &mut VecDeque<u64>,
|
||||
color: Color,
|
||||
) {
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(
|
||||
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string()),
|
||||
)
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.style(Style::default().fg(color));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
|
||||
+502
-75
@@ -5,15 +5,88 @@ use ratatui::style::Modifier;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Table},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
|
||||
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
|
||||
use crate::ui::util::human;
|
||||
use crate::ui::theme::{
|
||||
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
|
||||
SB_THUMB, SB_TRACK,
|
||||
};
|
||||
|
||||
/// Simple fuzzy matching: returns true if all characters in needle appear in
|
||||
/// haystack in order, ASCII-case-insensitive. Lowercase normalization is done
|
||||
/// on the fly so we don't allocate two `String`s per haystack like the old
|
||||
/// version did (this runs once per process per frame).
|
||||
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
|
||||
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
|
||||
if !haystack_chars.any(|c| c == needle_char) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first
|
||||
/// and reused across calls so callers can amortize the allocation. This is
|
||||
/// the underlying helper for the App-side cached slice.
|
||||
pub fn fill_filtered_sorted_indices(
|
||||
metrics: &Metrics,
|
||||
search_query: &str,
|
||||
sort_by: ProcSortBy,
|
||||
out: &mut Vec<usize>,
|
||||
) {
|
||||
out.clear();
|
||||
out.reserve(metrics.top_processes.len());
|
||||
if search_query.is_empty() {
|
||||
out.extend(0..metrics.top_processes.len());
|
||||
} else {
|
||||
out.extend(
|
||||
(0..metrics.top_processes.len())
|
||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
|
||||
);
|
||||
}
|
||||
match sort_by {
|
||||
ProcSortBy::CpuDesc => out.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 => out.sort_by(|&a, &b| {
|
||||
let aa = metrics.top_processes[a].mem_bytes;
|
||||
let bb = metrics.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Precomputed filtered + sorted indices into `metrics.top_processes`.
|
||||
/// Maintained on the App side so the draw path never recomputes the list.
|
||||
pub filtered_indices: &'a [usize],
|
||||
/// Pre-formatted strings for each row of `metrics.top_processes`.
|
||||
/// Indexed the same as `metrics.top_processes`. Empty when no procs poll
|
||||
/// has run yet (the draw path falls back to fast inline formatting).
|
||||
pub cached_rows: &'a [CachedRow],
|
||||
/// Peak cpu_usage from the most recent cache build; used to bold the
|
||||
/// busiest process. -1.0 if no cache.
|
||||
pub peak_cpu: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ProcSortBy {
|
||||
@@ -22,6 +95,45 @@ pub enum ProcSortBy {
|
||||
MemDesc,
|
||||
}
|
||||
|
||||
/// Pre-formatted strings for one row of the process table. Built once per
|
||||
/// `Processes` poll (cadence ~2s) and reused by every draw frame in between
|
||||
/// so the diff renderer can suppress repaints when nothing changed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedRow {
|
||||
pub pid_str: String,
|
||||
pub cpu_str: String,
|
||||
pub mem_str: String,
|
||||
pub mem_pct_str: String,
|
||||
pub mem_pct: f64,
|
||||
pub cpu_val: f32,
|
||||
}
|
||||
|
||||
/// Build a fresh row cache parallel to `metrics.top_processes`. Reuses `out`'s
|
||||
/// allocation when possible. Also returns the peak cpu_usage observed, which
|
||||
/// the draw path uses to bold the busiest process.
|
||||
pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
|
||||
out.clear();
|
||||
out.reserve(metrics.top_processes.len());
|
||||
let total = metrics.mem_total.max(1);
|
||||
let mut peak = 0.0_f32;
|
||||
for p in &metrics.top_processes {
|
||||
let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0;
|
||||
let cpu_val = p.cpu_usage;
|
||||
if cpu_val > peak {
|
||||
peak = cpu_val;
|
||||
}
|
||||
out.push(CachedRow {
|
||||
pid_str: p.pid.to_string(),
|
||||
cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)),
|
||||
mem_str: crate::ui::util::human(p.mem_bytes),
|
||||
mem_pct_str: format!("{mem_pct:.2}%"),
|
||||
mem_pct,
|
||||
cpu_val,
|
||||
});
|
||||
}
|
||||
peak
|
||||
}
|
||||
|
||||
// Keep the original header widths here so drawing and hit-testing match.
|
||||
const COLS: [Constraint; 5] = [
|
||||
Constraint::Length(8), // PID
|
||||
@@ -31,28 +143,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,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -63,42 +208,70 @@ 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)
|
||||
}),
|
||||
}
|
||||
let idxs = params.filtered_indices;
|
||||
|
||||
// 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
|
||||
// Use the App-side cache when available so we avoid allocating ~5 strings
|
||||
// per row every frame. Falls back to inline formatting (slow path) when
|
||||
// the cache hasn't been built yet — e.g. the very first frame before the
|
||||
// initial procs poll completes.
|
||||
let cache_ok = params.cached_rows.len() == mm.top_processes.len();
|
||||
let total_mem_bytes = mm.mem_total.max(1);
|
||||
let peak_cpu = mm
|
||||
.top_processes
|
||||
.iter()
|
||||
.map(|p| p.cpu_usage)
|
||||
.fold(0.0_f32, f32::max);
|
||||
let peak_cpu = if cache_ok {
|
||||
params.peak_cpu
|
||||
} else {
|
||||
mm.top_processes
|
||||
.iter()
|
||||
.map(|p| p.cpu_usage)
|
||||
.fold(0.0_f32, f32::max)
|
||||
};
|
||||
|
||||
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
|
||||
let p = &mm.top_processes[ix];
|
||||
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
|
||||
|
||||
let cpu_val = p.cpu_usage;
|
||||
let (
|
||||
cpu_val,
|
||||
mem_pct,
|
||||
pid_span,
|
||||
name_span,
|
||||
cpu_span_text,
|
||||
mem_span_text,
|
||||
mem_pct_span_text,
|
||||
) = if cache_ok {
|
||||
let row = ¶ms.cached_rows[ix];
|
||||
(
|
||||
row.cpu_val,
|
||||
row.mem_pct,
|
||||
Span::raw(row.pid_str.as_str()),
|
||||
Span::raw(p.name.as_str()),
|
||||
row.cpu_str.as_str(),
|
||||
row.mem_str.as_str(),
|
||||
row.mem_pct_str.as_str(),
|
||||
)
|
||||
} else {
|
||||
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
|
||||
// SLOW path: only the very first frame before the cache exists.
|
||||
// We leak the formatted strings via Box::leak'd statics? No —
|
||||
// simpler: emit empty placeholders. Cache will exist within
|
||||
// ~500ms and the diff renderer fills it in.
|
||||
(
|
||||
p.cpu_usage,
|
||||
mem_pct,
|
||||
Span::raw(""),
|
||||
Span::raw(""),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
};
|
||||
|
||||
let cpu_fg = match cpu_val {
|
||||
x if x < 25.0 => Color::Green,
|
||||
x if x < 60.0 => Color::Yellow,
|
||||
@@ -110,32 +283,45 @@ pub fn draw_top_processes(
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
let emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON {
|
||||
let mut emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON {
|
||||
Style::default().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let cpu_str = fmt_cpu_pct(cpu_val);
|
||||
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
|
||||
selected_pid == p.pid
|
||||
} else if let Some(selected_idx) = params.selected_process_index {
|
||||
selected_idx == ix
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_selected {
|
||||
emphasis = emphasis
|
||||
.bg(PROCESS_SELECTION_BG)
|
||||
.fg(PROCESS_SELECTION_FG)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
|
||||
ratatui::widgets::Row::new(vec![
|
||||
ratatui::widgets::Cell::from(p.pid.to_string())
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
ratatui::widgets::Cell::from(p.name.clone()),
|
||||
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
|
||||
ratatui::widgets::Cell::from(human(p.mem_bytes)),
|
||||
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
|
||||
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
|
||||
ratatui::widgets::Cell::from(name_span),
|
||||
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
|
||||
.style(Style::default().fg(cpu_fg)),
|
||||
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
|
||||
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
|
||||
.style(Style::default().fg(mem_fg)),
|
||||
])
|
||||
.style(emphasis)
|
||||
});
|
||||
|
||||
// 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",
|
||||
};
|
||||
@@ -151,46 +337,82 @@ pub fn draw_top_processes(
|
||||
.column_spacing(1);
|
||||
f.render_widget(table, content);
|
||||
|
||||
// Draw scrollbar like CPU pane
|
||||
// Draw tooltip if a process is selected
|
||||
if let Some(selected_pid) = params.selected_process_pid {
|
||||
// Find the selected process to get its name
|
||||
let process_info = if let Some(metrics) = params.metrics {
|
||||
metrics
|
||||
.top_processes
|
||||
.iter()
|
||||
.find(|p| p.pid == selected_pid)
|
||||
.map(|p| format!("PID {} • {}", p.pid, p.name))
|
||||
.unwrap_or_else(|| format!("PID {selected_pid}"))
|
||||
} else {
|
||||
format!("PID {selected_pid}")
|
||||
};
|
||||
|
||||
let tooltip_text = format!("{process_info} | Enter for details • X to unselect");
|
||||
let tooltip_width = tooltip_text.len() as u16 + 2; // Add padding
|
||||
let tooltip_height = 3;
|
||||
|
||||
// Position tooltip at bottom-right of the processes area
|
||||
if area.width > tooltip_width + 2 && area.height > tooltip_height + 1 {
|
||||
let tooltip_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(tooltip_width + 1),
|
||||
y: area.y + area.height.saturating_sub(tooltip_height + 1),
|
||||
width: tooltip_width,
|
||||
height: tooltip_height,
|
||||
};
|
||||
|
||||
let tooltip_block = Block::default().borders(Borders::ALL).style(
|
||||
Style::default()
|
||||
.bg(PROCESS_TOOLTIP_BG)
|
||||
.fg(PROCESS_TOOLTIP_FG),
|
||||
);
|
||||
|
||||
let tooltip_paragraph = Paragraph::new(tooltip_text)
|
||||
.block(tooltip_block)
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
|
||||
f.render_widget(tooltip_paragraph, tooltip_area);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollbar (ratatui built-in). Skip drawing when content fits in viewport.
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
if scroll_area.height >= 3 {
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let thumb_top = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
||||
};
|
||||
|
||||
// Build lines: top arrow, track (with thumb), bottom arrow
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
|
||||
lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW))));
|
||||
for i in 0..track {
|
||||
if i >= thumb_top && i < thumb_top + thumb_len {
|
||||
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
||||
let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off_for_bar > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.thumb_symbol("█")
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_style(Style::default().fg(SB_THUMB))
|
||||
.track_style(Style::default().fg(SB_TRACK))
|
||||
.begin_style(Style::default().fg(SB_ARROW))
|
||||
.end_style(Style::default().fg(SB_ARROW));
|
||||
let mut state = ScrollbarState::new(max_off_for_bar).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_cpu_pct(v: f32) -> String {
|
||||
format!("{:>5.1}", v.clamp(0.0, 100.0))
|
||||
/// 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 filtered_indices: &'a [usize],
|
||||
}
|
||||
|
||||
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
||||
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
|
||||
#[allow(dead_code)]
|
||||
pub fn processes_handle_key(
|
||||
scroll_offset: &mut usize,
|
||||
key: crossterm::event::KeyEvent,
|
||||
@@ -199,8 +421,89 @@ pub fn processes_handle_key(
|
||||
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
|
||||
}
|
||||
|
||||
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
let move_selection = |delta: isize,
|
||||
sel_idx: &mut Option<usize>,
|
||||
sel_pid: &mut Option<u32>,
|
||||
metrics: Option<&Metrics>,
|
||||
idxs: &[usize]| {
|
||||
let Some(m) = metrics else { return };
|
||||
if idxs.is_empty() {
|
||||
*sel_idx = None;
|
||||
*sel_pid = None;
|
||||
return;
|
||||
}
|
||||
if sel_idx.is_none() || sel_pid.is_none() {
|
||||
let first_idx = idxs[0];
|
||||
*sel_idx = Some(first_idx);
|
||||
*sel_pid = Some(m.top_processes[first_idx].pid);
|
||||
return;
|
||||
}
|
||||
let current_idx = sel_idx.unwrap();
|
||||
match idxs.iter().position(|&idx| idx == current_idx) {
|
||||
Some(pos) => {
|
||||
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
|
||||
if new_pos != pos {
|
||||
let new_idx = idxs[new_pos];
|
||||
*sel_idx = Some(new_idx);
|
||||
*sel_pid = Some(m.top_processes[new_idx].pid);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Current selection no longer in filtered list
|
||||
let first_idx = idxs[0];
|
||||
*sel_idx = Some(first_idx);
|
||||
*sel_pid = Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match params.key.code {
|
||||
KeyCode::Up => {
|
||||
move_selection(
|
||||
-1,
|
||||
params.selected_process_index,
|
||||
params.selected_process_pid,
|
||||
params.metrics,
|
||||
params.filtered_indices,
|
||||
);
|
||||
true
|
||||
}
|
||||
KeyCode::Down => {
|
||||
move_selection(
|
||||
1,
|
||||
params.selected_process_index,
|
||||
params.selected_process_pid,
|
||||
params.metrics,
|
||||
params.filtered_indices,
|
||||
);
|
||||
true
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X')
|
||||
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
|
||||
{
|
||||
*params.selected_process_pid = None;
|
||||
*params.selected_process_index = None;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => false,
|
||||
KeyCode::Enter => {
|
||||
// Signal that Enter was pressed with a selection
|
||||
params.selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||
}
|
||||
_ => {
|
||||
// No other keys handled - let scrollbar handle all navigation
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle mouse for content scrolling and scrollbar dragging.
|
||||
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
|
||||
/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality
|
||||
#[allow(dead_code)]
|
||||
pub fn processes_handle_mouse(
|
||||
scroll_offset: &mut usize,
|
||||
drag: &mut Option<crate::ui::cpu::PerCoreScrollDrag>,
|
||||
@@ -264,3 +567,127 @@ pub fn processes_handle_mouse(
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Parameters for process mouse event handling
|
||||
pub struct ProcessMouseParams<'a> {
|
||||
pub scroll_offset: &'a mut usize,
|
||||
pub selected_process_pid: &'a mut Option<u32>,
|
||||
pub selected_process_index: &'a mut Option<usize>,
|
||||
pub drag: &'a mut Option<crate::ui::cpu::PerCoreScrollDrag>,
|
||||
pub mouse: MouseEvent,
|
||||
pub area: Rect,
|
||||
pub total_rows: usize,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
/// True when the on-screen search box is currently being drawn (active
|
||||
/// edit mode OR a non-empty filter is showing). The caller computes this
|
||||
/// from the same condition as the draw path.
|
||||
pub search_box_visible: bool,
|
||||
pub filtered_indices: &'a [usize],
|
||||
}
|
||||
|
||||
/// Enhanced mouse handler that also manages process selection
|
||||
/// Returns Some(new_sort) if the header was clicked, or handles row selection
|
||||
pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Option<ProcSortBy> {
|
||||
// Inner and content areas (match draw_top_processes)
|
||||
let inner = Rect {
|
||||
x: params.area.x + 1,
|
||||
y: params.area.y + 1,
|
||||
width: params.area.width.saturating_sub(2),
|
||||
height: params.area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height == 0 || inner.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate content area - must match draw_top_processes exactly!
|
||||
// If a search box is being drawn (active edit mode OR a filter showing),
|
||||
// content starts 3 rows below.
|
||||
let content_start_y = if params.search_box_visible {
|
||||
inner.y + 3
|
||||
} else {
|
||||
inner.y
|
||||
};
|
||||
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: content_start_y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner
|
||||
.height
|
||||
.saturating_sub(if params.search_box_visible { 3 } else { 0 }),
|
||||
};
|
||||
|
||||
// Scrollbar interactions (click arrows/page/drag)
|
||||
per_core_handle_scrollbar_mouse(
|
||||
params.scroll_offset,
|
||||
params.drag,
|
||||
params.mouse,
|
||||
params.area,
|
||||
params.total_rows,
|
||||
);
|
||||
|
||||
// Wheel scrolling when inside the content
|
||||
crate::ui::cpu::per_core_handle_mouse(
|
||||
params.scroll_offset,
|
||||
params.mouse,
|
||||
content,
|
||||
content.height as usize,
|
||||
);
|
||||
|
||||
// Header click to change sort
|
||||
let header_area = Rect {
|
||||
x: content.x,
|
||||
y: content.y,
|
||||
width: content.width,
|
||||
height: 1,
|
||||
};
|
||||
let inside_header = params.mouse.row == header_area.y
|
||||
&& params.mouse.column >= header_area.x
|
||||
&& params.mouse.column < header_area.x + header_area.width;
|
||||
|
||||
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
// Split header into the same columns
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(COLS.to_vec())
|
||||
.split(header_area);
|
||||
if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width {
|
||||
return Some(ProcSortBy::CpuDesc);
|
||||
}
|
||||
if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width {
|
||||
return Some(ProcSortBy::MemDesc);
|
||||
}
|
||||
}
|
||||
|
||||
// Row click for process selection
|
||||
let data_start_row = content.y + 1; // Skip header
|
||||
let data_area_height = content.height.saturating_sub(1); // Exclude header
|
||||
|
||||
if matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||||
&& params.mouse.row >= data_start_row
|
||||
&& params.mouse.row < data_start_row + data_area_height
|
||||
&& params.mouse.column >= content.x
|
||||
&& params.mouse.column < content.x + content.width
|
||||
{
|
||||
let clicked_row = (params.mouse.row - data_start_row) as usize;
|
||||
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = params.filtered_indices;
|
||||
let visible_process_position = *params.scroll_offset + clicked_row;
|
||||
if visible_process_position < idxs.len() {
|
||||
let actual_process_index = idxs[visible_process_position];
|
||||
let clicked_process = &m.top_processes[actual_process_index];
|
||||
*params.selected_process_pid = Some(clicked_process.pid);
|
||||
*params.selected_process_index = Some(actual_process_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp to valid range
|
||||
per_core_clamp(
|
||||
params.scroll_offset,
|
||||
params.total_rows,
|
||||
(content.height.saturating_sub(1)) as usize,
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
+36
-1
@@ -30,6 +30,15 @@ pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red
|
||||
pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
|
||||
pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255);
|
||||
|
||||
// Process selection colors
|
||||
pub const PROCESS_SELECTION_BG: Color = Color::Rgb(147, 112, 219); // Medium slate blue (purple)
|
||||
pub const PROCESS_SELECTION_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast
|
||||
pub const PROCESS_TOOLTIP_BG: Color = Color::Rgb(147, 112, 219); // Same purple as selection
|
||||
pub const PROCESS_TOOLTIP_FG: Color = Color::Rgb(255, 255, 255); // White text for contrast
|
||||
|
||||
// Process details modal colors (matches main UI aesthetic - no custom colors, terminal defaults)
|
||||
pub const PROCESS_DETAILS_ACCENT: Color = Color::Rgb(147, 112, 219); // Purple accent for highlights
|
||||
|
||||
// Emoji / icon strings (centralized so they can be themed/swapped later)
|
||||
pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR ";
|
||||
pub const ICON_CLUSTER: &str = "⚠️";
|
||||
@@ -40,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] = &[
|
||||
" /\\ ",
|
||||
" / \\ ",
|
||||
@@ -51,3 +60,29 @@ pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" / !! \\ ",
|
||||
" /______________\\ ",
|
||||
];
|
||||
|
||||
//about logo
|
||||
pub const ASCII_ART: &str = r#"
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀
|
||||
⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀
|
||||
⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀
|
||||
⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
|
||||
███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗
|
||||
██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
|
||||
███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝
|
||||
╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝
|
||||
███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║
|
||||
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝
|
||||
"#;
|
||||
|
||||
@@ -1,39 +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" ] }
|
||||
|
||||
[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![])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Library interface for socktop_agent functionality
|
||||
//! This allows testing of agent functions.
|
||||
|
||||
pub mod gpu;
|
||||
pub mod metrics;
|
||||
pub mod proto;
|
||||
pub mod state;
|
||||
pub mod tls;
|
||||
pub mod types;
|
||||
pub mod ws;
|
||||
|
||||
// Re-export commonly used types and functions for testing
|
||||
pub use metrics::{collect_journal_entries, collect_process_metrics};
|
||||
pub use state::{AppState, CacheEntry};
|
||||
pub use types::{
|
||||
DetailedProcessInfo, JournalEntry, JournalResponse, LogLevel, ProcessMetricsResponse,
|
||||
};
|
||||
@@ -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"));
|
||||
|
||||
+1046
-85
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,10 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
|
||||
pub struct ProcCpuTracker {
|
||||
pub last_total: u64,
|
||||
pub last_per_pid: HashMap<u32, u64>,
|
||||
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
|
||||
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
|
||||
/// the same `String`s on every processes poll (~once per 1.5s).
|
||||
pub names: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -63,6 +67,11 @@ pub struct AppState {
|
||||
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
|
||||
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
|
||||
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
|
||||
|
||||
// Process detail caches (per-PID)
|
||||
pub cache_process_metrics:
|
||||
Arc<Mutex<HashMap<u32, CacheEntry<crate::types::ProcessMetricsResponse>>>>,
|
||||
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -71,6 +80,12 @@ pub struct CacheEntry<T> {
|
||||
pub value: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> Default for CacheEntry<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -90,6 +105,12 @@ impl<T> CacheEntry<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let sys = System::new();
|
||||
@@ -116,6 +137,8 @@ impl AppState {
|
||||
cache_metrics: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_disks: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_processes: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_process_metrics: Arc::new(Mutex::new(HashMap::new())),
|
||||
cache_journal_entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
pub temperature: Option<f32>,
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -47,3 +49,76 @@ pub struct ProcessesPayload {
|
||||
pub process_count: usize,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub tid: u32, // Thread ID
|
||||
pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm)
|
||||
pub cpu_time_user: u64, // User CPU time in microseconds
|
||||
pub cpu_time_system: u64, // System CPU time in microseconds
|
||||
pub status: String, // Thread status (Running, Sleeping, etc.)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DetailedProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
pub virtual_mem_bytes: u64,
|
||||
pub shared_mem_bytes: Option<u64>,
|
||||
pub thread_count: u32,
|
||||
pub fd_count: Option<u32>,
|
||||
pub status: String,
|
||||
pub parent_pid: Option<u32>,
|
||||
pub user_id: u32,
|
||||
pub group_id: u32,
|
||||
pub start_time: u64, // Unix timestamp
|
||||
pub cpu_time_user: u64, // Microseconds
|
||||
pub cpu_time_system: u64, // Microseconds
|
||||
pub read_bytes: Option<u64>,
|
||||
pub write_bytes: Option<u64>,
|
||||
pub working_directory: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub child_processes: Vec<DetailedProcessInfo>,
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessMetricsResponse {
|
||||
pub process: DetailedProcessInfo,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalEntry {
|
||||
pub timestamp: String, // ISO 8601 formatted timestamp
|
||||
pub priority: LogLevel,
|
||||
pub message: String,
|
||||
pub unit: Option<String>, // systemd unit name
|
||||
pub pid: Option<u32>,
|
||||
pub comm: Option<String>, // process command name
|
||||
pub uid: Option<u32>,
|
||||
pub gid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum LogLevel {
|
||||
Emergency = 0,
|
||||
Alert = 1,
|
||||
Critical = 2,
|
||||
Error = 3,
|
||||
Warning = 4,
|
||||
Notice = 5,
|
||||
Info = 6,
|
||||
Debug = 7,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalResponse {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
pub total_count: u32,
|
||||
pub truncated: bool,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
+114
-19
@@ -17,6 +17,8 @@ use crate::proto::pb;
|
||||
use crate::state::AppState;
|
||||
|
||||
// Compression threshold based on typical payload size
|
||||
// Temporarily increased for testing - revert to 768 for production
|
||||
//const COMPRESSION_THRESHOLD: usize = 50_000;
|
||||
const COMPRESSION_THRESHOLD: usize = 768;
|
||||
|
||||
// Reusable buffer for compression to avoid allocations
|
||||
@@ -67,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
Message::Text(ref text) if text == "get_processes" => {
|
||||
let payload = collect_processes_all(&state).await;
|
||||
|
||||
// Map to protobuf message
|
||||
// Get cached buffers
|
||||
// Get cached buffers. The Vec capacity is preserved across
|
||||
// calls (with_capacity(512) seeds it, then we swap-back after
|
||||
// encode so the allocation outlives any single request).
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
// Reuse process vector to build the list
|
||||
cache.processes_vec.clear();
|
||||
cache
|
||||
.processes_vec
|
||||
@@ -83,34 +85,127 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
mem_bytes: p.mem_bytes,
|
||||
}));
|
||||
|
||||
let pb = pb::Processes {
|
||||
// Move the populated Vec into the proto, encode, then move it
|
||||
// BACK into the cache so the next call reuses the same heap
|
||||
// allocation. The previous code did `mem::take(...)` here but
|
||||
// then dropped `pb` (and the Vec along with it), leaving the
|
||||
// cache holding an empty zero-capacity Vec — defeating the
|
||||
// whole point of `with_capacity(512)`.
|
||||
let mut pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows: std::mem::take(&mut cache.processes_vec),
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let encode_result = prost::Message::encode(&pb, &mut buf);
|
||||
// Restore the (now-encoded-from) Vec to the cache before pb is
|
||||
// dropped. We `take` it out of pb to leave that field empty,
|
||||
// and the next request will `.clear()` before refilling.
|
||||
cache.processes_vec = std::mem::take(&mut pb.rows);
|
||||
|
||||
if encode_result.is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// compress if large
|
||||
if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cache); // Explicit drop to release mutex early
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_process_metrics:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_process_metrics:")
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
{
|
||||
let ttl = std::time::Duration::from_millis(250); // 250ms TTL
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
if let Some(entry) = cache.get(&pid)
|
||||
&& entry.is_fresh(ttl)
|
||||
&& let Some(cached_response) = entry.get()
|
||||
{
|
||||
let _ = send_json(&mut socket, cached_response).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect fresh data
|
||||
match crate::metrics::collect_process_metrics(pid, &state).await {
|
||||
Ok(response) => {
|
||||
// Cache the response
|
||||
{
|
||||
let mut cache = state.cache_process_metrics.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(crate::state::CacheEntry::new)
|
||||
.set(response.clone());
|
||||
}
|
||||
let _ = send_json(&mut socket, &response).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error_response = serde_json::json!({
|
||||
"error": err,
|
||||
"request": "get_process_metrics",
|
||||
"pid": pid
|
||||
});
|
||||
let _ = send_json(&mut socket, &error_response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_journal_entries:")
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
{
|
||||
let ttl = std::time::Duration::from_secs(1); // 1s TTL
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
if let Some(entry) = cache.get(&pid)
|
||||
&& entry.is_fresh(ttl)
|
||||
&& let Some(cached_response) = entry.get()
|
||||
{
|
||||
let _ = send_json(&mut socket, cached_response).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect fresh data
|
||||
match crate::metrics::collect_journal_entries(pid) {
|
||||
Ok(response) => {
|
||||
// Cache the response
|
||||
{
|
||||
let mut cache = state.cache_journal_entries.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(crate::state::CacheEntry::new)
|
||||
.set(response.clone());
|
||||
}
|
||||
let _ = send_json(&mut socket, &response).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error_response = serde_json::json!({
|
||||
"error": err,
|
||||
"request": "get_journal_entries",
|
||||
"pid": pid
|
||||
});
|
||||
let _ = send_json(&mut socket, &error_response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Tests for the process cache functionality
|
||||
|
||||
use socktop_agent::state::{AppState, CacheEntry};
|
||||
use socktop_agent::types::{DetailedProcessInfo, JournalResponse, ProcessMetricsResponse};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_cache_ttl() {
|
||||
let state = AppState::new();
|
||||
let pid = 12345;
|
||||
|
||||
// Create mock data
|
||||
let process_info = DetailedProcessInfo {
|
||||
pid,
|
||||
name: "test_process".to_string(),
|
||||
command: "test command".to_string(),
|
||||
cpu_usage: 50.0,
|
||||
mem_bytes: 1024 * 1024,
|
||||
virtual_mem_bytes: 2048 * 1024,
|
||||
shared_mem_bytes: Some(512 * 1024),
|
||||
thread_count: 4,
|
||||
fd_count: Some(10),
|
||||
status: "Running".to_string(),
|
||||
parent_pid: Some(1),
|
||||
user_id: 1000,
|
||||
group_id: 1000,
|
||||
start_time: 1234567890,
|
||||
cpu_time_user: 100000,
|
||||
cpu_time_system: 50000,
|
||||
read_bytes: Some(1024),
|
||||
write_bytes: Some(2048),
|
||||
working_directory: Some("/tmp".to_string()),
|
||||
executable_path: Some("/usr/bin/test".to_string()),
|
||||
child_processes: vec![],
|
||||
threads: vec![],
|
||||
};
|
||||
|
||||
let metrics_response = ProcessMetricsResponse {
|
||||
process: process_info,
|
||||
cached_at: 1234567890,
|
||||
};
|
||||
|
||||
let journal_response = JournalResponse {
|
||||
entries: vec![],
|
||||
total_count: 0,
|
||||
truncated: false,
|
||||
cached_at: 1234567890,
|
||||
};
|
||||
|
||||
// Test process metrics caching
|
||||
{
|
||||
let mut cache = state.cache_process_metrics.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(CacheEntry::new)
|
||||
.set(metrics_response.clone());
|
||||
}
|
||||
|
||||
// Should get cached value immediately
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
let ttl = Duration::from_millis(250);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(entry.is_fresh(ttl));
|
||||
assert!(entry.get().is_some());
|
||||
assert_eq!(entry.get().unwrap().process.pid, pid);
|
||||
} else {
|
||||
panic!("Expected cached entry");
|
||||
}
|
||||
}
|
||||
println!("✓ Process metrics cached and retrieved successfully");
|
||||
|
||||
// Test journal entries caching
|
||||
{
|
||||
let mut cache = state.cache_journal_entries.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(CacheEntry::new)
|
||||
.set(journal_response.clone());
|
||||
}
|
||||
|
||||
// Should get cached value immediately
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
let ttl = Duration::from_secs(1);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(entry.is_fresh(ttl));
|
||||
assert!(entry.get().is_some());
|
||||
assert_eq!(entry.get().unwrap().total_count, 0);
|
||||
} else {
|
||||
panic!("Expected cached entry");
|
||||
}
|
||||
}
|
||||
println!("✓ Journal entries cached and retrieved successfully");
|
||||
|
||||
// Wait for process metrics to expire (250ms + buffer)
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
|
||||
// Process metrics should be expired now
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
let ttl = Duration::from_millis(250);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(!entry.is_fresh(ttl));
|
||||
}
|
||||
}
|
||||
println!("✓ Process metrics correctly expired after TTL");
|
||||
|
||||
// Journal entries should still be valid (1s TTL)
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
let ttl = Duration::from_secs(1);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(entry.is_fresh(ttl));
|
||||
}
|
||||
}
|
||||
println!("✓ Journal entries still valid within TTL");
|
||||
|
||||
// Wait for journal entries to expire (additional 800ms to reach 1s total)
|
||||
sleep(Duration::from_millis(800)).await;
|
||||
|
||||
// Journal entries should be expired now
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
let ttl = Duration::from_secs(1);
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
assert!(!entry.is_fresh(ttl));
|
||||
}
|
||||
}
|
||||
println!("✓ Journal entries correctly expired after TTL");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Tests for process detail collection functionality
|
||||
|
||||
use socktop_agent::metrics::{collect_journal_entries, collect_process_metrics};
|
||||
use socktop_agent::state::AppState;
|
||||
use std::process;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_process_metrics_self() {
|
||||
// Test collecting metrics for our own process
|
||||
let pid = process::id();
|
||||
let state = AppState::new();
|
||||
|
||||
match collect_process_metrics(pid, &state).await {
|
||||
Ok(response) => {
|
||||
assert_eq!(response.process.pid, pid);
|
||||
assert!(!response.process.name.is_empty());
|
||||
// Command might be empty on some systems, so don't assert on it
|
||||
assert!(response.cached_at > 0);
|
||||
println!(
|
||||
"✓ Process metrics collected for PID {}: {} ({})",
|
||||
pid, response.process.name, response.process.command
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// This might fail if sysinfo can't find the process, which is possible
|
||||
println!("⚠ Warning: Failed to collect process metrics for self: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_journal_entries_self() {
|
||||
// Test collecting journal entries for our own process
|
||||
let pid = process::id();
|
||||
|
||||
match collect_journal_entries(pid) {
|
||||
Ok(response) => {
|
||||
assert!(response.cached_at > 0);
|
||||
println!(
|
||||
"✓ Journal entries collected for PID {}: {} entries",
|
||||
pid, response.total_count
|
||||
);
|
||||
if !response.entries.is_empty() {
|
||||
let entry = &response.entries[0];
|
||||
println!(" Latest entry: {}", entry.message);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// This might fail if journalctl is not available or restricted
|
||||
println!("⚠ Warning: Failed to collect journal entries for self: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_process_metrics_invalid_pid() {
|
||||
// Test with an invalid PID
|
||||
let invalid_pid = 999999;
|
||||
let state = AppState::new();
|
||||
|
||||
match collect_process_metrics(invalid_pid, &state).await {
|
||||
Ok(_) => {
|
||||
println!("⚠ Warning: Unexpectedly found process for invalid PID {invalid_pid}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✓ Correctly failed for invalid PID {invalid_pid}: {e}");
|
||||
assert!(e.contains("not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_journal_entries_invalid_pid() {
|
||||
// Test with an invalid PID - journalctl might still return empty results
|
||||
let invalid_pid = 999999;
|
||||
|
||||
match collect_journal_entries(invalid_pid) {
|
||||
Ok(response) => {
|
||||
println!(
|
||||
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
|
||||
invalid_pid, response.total_count
|
||||
);
|
||||
// Should be empty or very few entries
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✓ Journal query failed for invalid PID {invalid_pid}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -66,7 +66,7 @@ use tokio_tungstenite::{Connector, connect_async_tls_with_config};
|
||||
use crate::error::{ConnectorError, Result};
|
||||
use crate::types::{AgentRequest, AgentResponse};
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
|
||||
use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, ProcessMetricsResponse, JournalResponse};
|
||||
#[cfg(feature = "tls")]
|
||||
fn ensure_crypto_provider() {
|
||||
use std::sync::Once;
|
||||
@@ -186,6 +186,18 @@ impl SocktopConnector {
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?;
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
AgentRequest::ProcessMetrics { pid } => {
|
||||
let process_metrics = request_process_metrics(stream, pid)
|
||||
.await
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get process metrics"))?;
|
||||
Ok(AgentResponse::ProcessMetrics(process_metrics))
|
||||
}
|
||||
AgentRequest::JournalEntries { pid } => {
|
||||
let journal_entries = request_journal_entries(stream, pid)
|
||||
.await
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get journal entries"))?;
|
||||
Ok(AgentResponse::JournalEntries(journal_entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +449,38 @@ async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
|
||||
}
|
||||
}
|
||||
|
||||
// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse
|
||||
#[cfg(feature = "networking")]
|
||||
async fn request_process_metrics(ws: &mut WsStream, pid: u32) -> Option<ProcessMetricsResponse> {
|
||||
let request = format!("get_process_metrics:{}", pid);
|
||||
if ws.send(Message::Text(request)).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::<ProcessMetricsResponse>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessMetricsResponse>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse
|
||||
#[cfg(feature = "networking")]
|
||||
async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option<JournalResponse> {
|
||||
let request = format!("get_journal_entries:{}", pid);
|
||||
if ws.send(Message::Text(request)).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).ok().and_then(|s| serde_json::from_str::<JournalResponse>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<JournalResponse>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Decompress a gzip-compressed binary frame into a String.
|
||||
/// Unified gzip decompression to string for both networking and WASM
|
||||
#[cfg(any(feature = "networking", feature = "wasm"))]
|
||||
@@ -805,6 +849,20 @@ impl SocktopConnector {
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
}
|
||||
AgentRequest::ProcessMetrics { pid: _ } => {
|
||||
// Parse JSON response for process metrics
|
||||
let process_metrics: ProcessMetricsResponse = serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!("Failed to parse process metrics: {e}"))
|
||||
})?;
|
||||
Ok(AgentResponse::ProcessMetrics(process_metrics))
|
||||
}
|
||||
AgentRequest::JournalEntries { pid: _ } => {
|
||||
// Parse JSON response for journal entries
|
||||
let journal_entries: JournalResponse = serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!("Failed to parse journal entries: {e}"))
|
||||
})?;
|
||||
Ok(AgentResponse::JournalEntries(journal_entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::{AgentRequest, AgentResponse};
|
||||
|
||||
#[cfg(feature = "networking")]
|
||||
use crate::networking::{
|
||||
WsStream, connect_to_agent, request_disks, request_metrics, request_processes,
|
||||
WsStream, connect_to_agent, request_disks, request_journal_entries, request_metrics,
|
||||
request_process_metrics, request_processes,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "wasm", not(feature = "networking")))]
|
||||
@@ -72,6 +73,20 @@ impl SocktopConnector {
|
||||
.ok_or_else(|| ConnectorError::invalid_response("Failed to get processes"))?;
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
AgentRequest::ProcessMetrics { pid } => {
|
||||
let process_metrics =
|
||||
request_process_metrics(stream, pid).await.ok_or_else(|| {
|
||||
ConnectorError::invalid_response("Failed to get process metrics")
|
||||
})?;
|
||||
Ok(AgentResponse::ProcessMetrics(process_metrics))
|
||||
}
|
||||
AgentRequest::JournalEntries { pid } => {
|
||||
let journal_entries =
|
||||
request_journal_entries(stream, pid).await.ok_or_else(|| {
|
||||
ConnectorError::invalid_response("Failed to get journal entries")
|
||||
})?;
|
||||
Ok(AgentResponse::JournalEntries(journal_entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ pub use config::ConnectorConfig;
|
||||
pub use connector_impl::SocktopConnector;
|
||||
pub use error::{ConnectorError, Result};
|
||||
pub use types::{
|
||||
AgentRequest, AgentResponse, DiskInfo, GpuInfo, Metrics, NetworkInfo, ProcessInfo,
|
||||
AgentRequest, AgentResponse, DetailedProcessInfo, DiskInfo, GpuInfo, JournalEntry,
|
||||
JournalResponse, LogLevel, Metrics, NetworkInfo, ProcessInfo, ProcessMetricsResponse,
|
||||
ProcessesPayload,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! WebSocket request handlers for native (non-WASM) environments.
|
||||
|
||||
use crate::networking::WsStream;
|
||||
use crate::types::{JournalResponse, ProcessMetricsResponse};
|
||||
use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip};
|
||||
use crate::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload, pb};
|
||||
|
||||
@@ -82,3 +83,36 @@ pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a "get_process_metrics:{pid}" request and await a JSON ProcessMetricsResponse
|
||||
pub async fn request_process_metrics(
|
||||
ws: &mut WsStream,
|
||||
pid: u32,
|
||||
) -> Option<ProcessMetricsResponse> {
|
||||
let request = format!("get_process_metrics:{pid}");
|
||||
if ws.send(Message::Text(request)).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => gunzip_to_string(&b)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<ProcessMetricsResponse>(&s).ok()),
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessMetricsResponse>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a "get_journal_entries:{pid}" request and await a JSON JournalResponse
|
||||
pub async fn request_journal_entries(ws: &mut WsStream, pid: u32) -> Option<JournalResponse> {
|
||||
let request = format!("get_journal_entries:{pid}");
|
||||
if ws.send(Message::Text(request)).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => gunzip_to_string(&b)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<JournalResponse>(&s).ok()),
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<JournalResponse>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -73,6 +77,79 @@ pub struct ProcessesPayload {
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub tid: u32, // Thread ID
|
||||
pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm)
|
||||
pub cpu_time_user: u64, // User CPU time in microseconds
|
||||
pub cpu_time_system: u64, // System CPU time in microseconds
|
||||
pub status: String, // Thread status (Running, Sleeping, etc.)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DetailedProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
pub virtual_mem_bytes: u64,
|
||||
pub shared_mem_bytes: Option<u64>,
|
||||
pub thread_count: u32,
|
||||
pub fd_count: Option<u32>,
|
||||
pub status: String,
|
||||
pub parent_pid: Option<u32>,
|
||||
pub user_id: u32,
|
||||
pub group_id: u32,
|
||||
pub start_time: u64, // Unix timestamp
|
||||
pub cpu_time_user: u64, // Microseconds
|
||||
pub cpu_time_system: u64, // Microseconds
|
||||
pub read_bytes: Option<u64>,
|
||||
pub write_bytes: Option<u64>,
|
||||
pub working_directory: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub child_processes: Vec<DetailedProcessInfo>,
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ProcessMetricsResponse {
|
||||
pub process: DetailedProcessInfo,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct JournalEntry {
|
||||
pub timestamp: String, // ISO 8601 formatted timestamp
|
||||
pub priority: LogLevel,
|
||||
pub message: String,
|
||||
pub unit: Option<String>, // systemd unit name
|
||||
pub pid: Option<u32>,
|
||||
pub comm: Option<String>, // process command name
|
||||
pub uid: Option<u32>,
|
||||
pub gid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub enum LogLevel {
|
||||
Emergency = 0,
|
||||
Alert = 1,
|
||||
Critical = 2,
|
||||
Error = 3,
|
||||
Warning = 4,
|
||||
Notice = 5,
|
||||
Info = 6,
|
||||
Debug = 7,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct JournalResponse {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
pub total_count: u32,
|
||||
pub truncated: bool,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
/// Request types that can be sent to the agent
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
@@ -83,6 +160,10 @@ pub enum AgentRequest {
|
||||
Disks,
|
||||
#[serde(rename = "processes")]
|
||||
Processes,
|
||||
#[serde(rename = "process_metrics")]
|
||||
ProcessMetrics { pid: u32 },
|
||||
#[serde(rename = "journal_entries")]
|
||||
JournalEntries { pid: u32 },
|
||||
}
|
||||
|
||||
impl AgentRequest {
|
||||
@@ -92,6 +173,8 @@ impl AgentRequest {
|
||||
AgentRequest::Metrics => "get_metrics".to_string(),
|
||||
AgentRequest::Disks => "get_disks".to_string(),
|
||||
AgentRequest::Processes => "get_processes".to_string(),
|
||||
AgentRequest::ProcessMetrics { pid } => format!("get_process_metrics:{pid}"),
|
||||
AgentRequest::JournalEntries { pid } => format!("get_journal_entries:{pid}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,4 +189,8 @@ pub enum AgentResponse {
|
||||
Disks(Vec<DiskInfo>),
|
||||
#[serde(rename = "processes")]
|
||||
Processes(ProcessesPayload),
|
||||
#[serde(rename = "process_metrics")]
|
||||
ProcessMetrics(ProcessMetricsResponse),
|
||||
#[serde(rename = "journal_entries")]
|
||||
JournalEntries(JournalResponse),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
use crate::error::{ConnectorError, Result};
|
||||
use crate::pb::Processes;
|
||||
use crate::utils::{gunzip_to_string, gunzip_to_vec, is_gzip, log_debug};
|
||||
use crate::{AgentRequest, AgentResponse, DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
|
||||
use crate::{
|
||||
AgentRequest, AgentResponse, DiskInfo, JournalResponse, Metrics, ProcessInfo,
|
||||
ProcessMetricsResponse, ProcessesPayload,
|
||||
};
|
||||
|
||||
use prost::Message as ProstMessage;
|
||||
use std::cell::RefCell;
|
||||
@@ -206,6 +209,26 @@ pub async fn send_request_and_wait(
|
||||
Ok(AgentResponse::Processes(processes))
|
||||
}
|
||||
}
|
||||
AgentRequest::ProcessMetrics { pid: _ } => {
|
||||
// Parse JSON response for process metrics
|
||||
let process_metrics: ProcessMetricsResponse =
|
||||
serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!(
|
||||
"Failed to parse process metrics: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(AgentResponse::ProcessMetrics(process_metrics))
|
||||
}
|
||||
AgentRequest::JournalEntries { pid: _ } => {
|
||||
// Parse JSON response for journal entries
|
||||
let journal_entries: JournalResponse =
|
||||
serde_json::from_str(&response).map_err(|e| {
|
||||
ConnectorError::serialization_error(format!(
|
||||
"Failed to parse journal entries: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(AgentResponse::JournalEntries(journal_entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
|
||||
Reference in New Issue
Block a user