Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce59dd9dfe | |||
| 8d48fa4c3b | |||
| 51e702368e | |||
| 85f9a44e46 | |||
| b2468a5936 | |||
| 8de5943f34 | |||
| e624751f56 | |||
| 8bd1af7a27 | |||
| 5c32d15156 | |||
| 471d547b5d | |||
| d3aff590bc | |||
| 47910725a8 | |||
| a8e3f4ef26 | |||
| fab1e5a104 | |||
| d0455611d5 | |||
| 4c45b85c98 | |||
| d9fdc31e8f | |||
| dc9aa4c026 | |||
| c2e91bd20c | |||
| 25229d6b03 | |||
| 290e2a8fb2 | |||
| 30d263c71e | |||
| 9b177f3206 | |||
| 8a6ae3fcd7 | |||
| 5b8ec7efc1 | |||
| 155c420a1a | |||
| d3fa55e572 | |||
| faf2861b29 | |||
| 59432ab1d3 | |||
| d1c8a64418 | |||
| 8def4b2d06 | |||
| a42ca71a9f | |||
| 9f675fa804 | |||
| 3ac03c07ba | |||
| e53d0ab98d | |||
| 2ca51adc61 | |||
| 67ecf36883 | |||
| 9a35306340 | |||
| a4bb6f170a | |||
| 384953d5d5 | |||
| f9114426cc | |||
| 8ee2a03a2c | |||
| 0275b1871d | |||
| 9491dc50a8 | |||
| e7eb3e6557 | |||
| a596acfb72 | |||
| b727e54589 | |||
| 2af08c455a | |||
| d049846564 | |||
| 97308f9d15 | |||
| 4cef273e57 | |||
| 660474a6ce | |||
| 93dd14967d | |||
| 923a3872fe | |||
| 5f10e34341 | |||
| b80d322650 | |||
| fff386f9d5 | |||
| 93f4e1feea | |||
| 97255b42fb | |||
| 554a2c349f | |||
| 10501168c5 | |||
| d346c61c28 | |||
| 7652095109 | |||
| 6b58ac67f6 | |||
| 3ad1d52fe2 | |||
| 2e8cc24e81 | |||
| 36e73fd9ed | |||
| 3d14e4a370 | |||
| c6b8c9c905 | |||
| f980b6ace9 | |||
| 6a27280f8d | |||
| 38b0cdcf0e | |||
| 268627ed63 | |||
| 55a663cf7a | |||
| 8b76ccb742 | |||
| d0f6cb0e70 | |||
| 56ebe6bbab | |||
| dc90de7ff1 | |||
| 319f47eb73 | |||
| fd2889ccca | |||
| 0859f50897 | |||
| 5c002f0b2b | |||
| 5a824c2098 | |||
| ffb381e40e | |||
| 0a70d7fd39 | |||
| 8d81ee1f7e | |||
| 1e248306a6 | |||
| 7cd6a6e0a1 | |||
| 8f58feffbe | |||
| 5790ef753b | |||
| 9a49fd6b24 | |||
| b35e431200 | |||
| 4b52382326 | |||
| 11506699e3 | |||
| 4c6c707dd0 | |||
| d69a4104fc | |||
| c3f81eef25 | |||
| 05276f9eea | |||
| 0105b29bfc | |||
| 0cbba6b290 | |||
| 250f7bf93a | |||
| 4efeb3b60f | |||
| d20061614c | |||
| 289c9f7ebe | |||
| bdfa74be54 | |||
| 6efdc35b19 | |||
| 20278d67f1 | |||
| a4f69a5f7d | |||
| 9b1643afac | |||
| a9086eac84 | |||
| 4bd6744df4 | |||
| 274a485f8d | |||
| 747aef0005 | |||
| a0e17c6e22 | |||
| 19973c24d8 | |||
| 9a4c8b703e | |||
| 968a25eaf1 | |||
| 13fb22c7ee | |||
| 6c867774f7 | |||
| 7c3e4a6e39 | |||
| 466a32a90a | |||
| cb4882e983 | |||
| 87378e49ac | |||
| 100434fc3c | |||
| deef4702f4 | |||
| 1c2415bc1b | |||
| fac09b381f |
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[pre-commit] Running cargo fmt --all" >&2
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[pre-commit] cargo not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cargo fmt --all
|
||||
|
||||
# Stage any Rust files that were reformatted
|
||||
changed=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
|
||||
if [ -n "$changed" ]; then
|
||||
echo "$changed" | xargs git add
|
||||
echo "[pre-commit] Added formatted files" >&2
|
||||
fi
|
||||
|
||||
# Fail if further diffs remain (shouldn't happen normally)
|
||||
unfmt=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
|
||||
if [ -n "$unfmt" ]; then
|
||||
echo "[pre-commit] Some Rust files still differ after formatting:" >&2
|
||||
echo "$unfmt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,129 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdrm-dev libdrm-amdgpu1
|
||||
- name: Cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
- name: Build (release)
|
||||
run: cargo build --release --workspace
|
||||
|
||||
- name: "Linux: start agent and run WS probe"
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUST_LOG=info SOCKTOP_ENABLE_SSL=0 SOCKTOP_AGENT_GPU=0 SOCKTOP_AGENT_TEMP=0 ./target/release/socktop_agent -p 3000 > agent.log 2>&1 &
|
||||
AGENT_PID=$!
|
||||
for i in {1..60}; do
|
||||
if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if ! curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
|
||||
echo "--- agent.log (tail) ---"
|
||||
tail -n 200 agent.log || true
|
||||
(command -v ss >/dev/null && ss -ltnp || netstat -ltnp) || true
|
||||
kill $AGENT_PID || true
|
||||
exit 1
|
||||
fi
|
||||
SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop --test ws_probe -- --nocapture
|
||||
kill $AGENT_PID || true
|
||||
|
||||
- name: "Windows: start agent and run WS probe"
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SOCKTOP_ENABLE_SSL = "0"
|
||||
$env:SOCKTOP_AGENT_GPU = "0"
|
||||
$env:SOCKTOP_AGENT_TEMP = "0"
|
||||
$out = Join-Path $PWD "agent.out.txt"
|
||||
$err = Join-Path $PWD "agent.err.txt"
|
||||
$p = Start-Process -FilePath "${PWD}\target\release\socktop_agent.exe" -ArgumentList "-p 3000" -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -NoNewWindow
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 60; $i++) {
|
||||
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$pinfo.FileName = "curl.exe"
|
||||
$pinfo.Arguments = "-fsS http://127.0.0.1:3000/healthz"
|
||||
$pinfo.RedirectStandardOutput = $true
|
||||
$pinfo.RedirectStandardError = $true
|
||||
$pinfo.UseShellExecute = $false
|
||||
$proc = [System.Diagnostics.Process]::Start($pinfo)
|
||||
$proc.WaitForExit()
|
||||
if ($proc.ExitCode -eq 0) { $ready = $true; break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $ready) {
|
||||
Write-Warning "TCP connect to (127.0.0.1 : 3000) failed"
|
||||
if (Test-Path $out) { Write-Host "--- agent.out (full) ---"; Get-Content $out }
|
||||
if (Test-Path $err) { Write-Host "--- agent.err (full) ---"; Get-Content $err }
|
||||
Write-Host "--- netstat ---"
|
||||
netstat -ano | Select-String ":3000" | ForEach-Object { $_.Line }
|
||||
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
throw "agent did not become ready"
|
||||
}
|
||||
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
|
||||
try {
|
||||
cargo test -p socktop --test ws_probe -- --nocapture
|
||||
} finally {
|
||||
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
- name: Smoke test (client --help)
|
||||
run: cargo run -p socktop -- --help
|
||||
|
||||
- name: Package artifacts (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p dist
|
||||
cp target/release/socktop dist/
|
||||
cp target/release/socktop_agent dist/
|
||||
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
|
||||
|
||||
- name: Package artifacts (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
Copy-Item target\release\socktop.exe dist\
|
||||
Copy-Item target\release\socktop_agent.exe dist\
|
||||
Compress-Archive -Path dist\* -DestinationPath socktop-${{ matrix.os }}.zip -Force
|
||||
|
||||
- name: Upload build artifacts (ephemeral)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: socktop-${{ matrix.os }}
|
||||
path: |
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
- name: Upload to rolling GitHub Release (main only)
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: latest
|
||||
name: Latest build
|
||||
prerelease: true
|
||||
draft: false
|
||||
files: |
|
||||
*.tar.gz
|
||||
*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
.vscode/
|
||||
@@ -1,423 +0,0 @@
|
||||
# APT Repository Setup Summary
|
||||
|
||||
## 🎉 What You Now Have
|
||||
|
||||
You now have a complete system for creating and hosting your own APT repository for socktop packages, **without needing a sponsor or official Debian/Ubuntu approval**.
|
||||
|
||||
## 📁 Files Created
|
||||
|
||||
### Scripts (in `scripts/`)
|
||||
- **`init-apt-repo.sh`** - Initializes the APT repository directory structure
|
||||
- **`add-package-to-repo.sh`** - Adds .deb packages to the repository and generates metadata
|
||||
- **`sign-apt-repo.sh`** - Signs the repository with your GPG key
|
||||
- **`setup-apt-repo.sh`** - All-in-one interactive wizard to set everything up
|
||||
|
||||
### Documentation
|
||||
- **`QUICK_START_APT_REPO.md`** - Quick start guide (< 10 minutes)
|
||||
- **`docs/APT_REPOSITORY.md`** - Comprehensive 600+ line guide covering everything
|
||||
- **`APT_REPO_SUMMARY.md`** - This file
|
||||
|
||||
### GitHub Actions
|
||||
- **`.github/workflows/publish-apt-repo.yml`** - Automated building, signing, and publishing
|
||||
|
||||
## 🚀 Quick Start (Choose One)
|
||||
|
||||
### Option 1: Interactive Setup (Recommended for First Time)
|
||||
|
||||
Run the setup wizard:
|
||||
|
||||
```bash
|
||||
./scripts/setup-apt-repo.sh
|
||||
```
|
||||
|
||||
This walks you through:
|
||||
1. ✅ Checking prerequisites
|
||||
2. 🔑 Setting up GPG key
|
||||
3. 📦 Finding/building packages
|
||||
4. 📝 Creating repository structure
|
||||
5. ✍️ Signing the repository
|
||||
6. 📋 Next steps to publish to gh-pages
|
||||
|
||||
### Option 2: Manual Step-by-Step
|
||||
|
||||
```bash
|
||||
# 1. Initialize
|
||||
./scripts/init-apt-repo.sh
|
||||
|
||||
# 2. Build packages
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
|
||||
# 3. Add packages
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
|
||||
|
||||
# 4. Sign (replace YOUR-KEY-ID)
|
||||
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
|
||||
|
||||
# 5. Update URLs
|
||||
sed -i 's/YOUR-USERNAME/your-github-username/g' apt-repo/*.{md,html}
|
||||
|
||||
# 6. Publish to gh-pages (see below)
|
||||
```
|
||||
|
||||
### Option 3: Fully Automated (After Initial Setup)
|
||||
|
||||
Once gh-pages branch exists, just tag releases:
|
||||
|
||||
```bash
|
||||
git tag v1.50.0
|
||||
git push --tags
|
||||
|
||||
# GitHub Actions will:
|
||||
# - Build packages for AMD64 and ARM64
|
||||
# - Update APT repository
|
||||
# - Sign with your GPG key
|
||||
# - Push to gh-pages branch automatically
|
||||
```
|
||||
|
||||
## 📤 Publishing to GitHub Pages (gh-pages branch)
|
||||
|
||||
**Why gh-pages branch?**
|
||||
- ✅ Keeps main branch clean (source code only)
|
||||
- ✅ Separate branch for published content
|
||||
- ✅ GitHub Actions can auto-update it
|
||||
- ✅ You can customize the landing page
|
||||
|
||||
**Initial Setup:**
|
||||
```bash
|
||||
# Create gh-pages branch
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf .
|
||||
|
||||
# Copy apt-repo CONTENTS to root (not the folder!)
|
||||
cp -r apt-repo/* .
|
||||
rm -rf apt-repo
|
||||
|
||||
# Commit and push
|
||||
git add .
|
||||
git commit -m "Initialize APT repository"
|
||||
git push -u origin gh-pages
|
||||
|
||||
# Return to main
|
||||
git checkout main
|
||||
```
|
||||
|
||||
**Enable in GitHub:**
|
||||
1. Settings → Pages
|
||||
2. Source: **gh-pages** → **/ (root)**
|
||||
3. Save
|
||||
|
||||
Your repo will be at: `https://your-username.github.io/socktop/`
|
||||
|
||||
**Note:** GitHub Pages only allows `/` (root) or `/docs`. Since we use gh-pages branch, contents go in the root of that branch.
|
||||
|
||||
See `SETUP_GITHUB_PAGES.md` for detailed step-by-step instructions.
|
||||
|
||||
### Alternative: Self-Hosted Server
|
||||
|
||||
Copy `apt-repo/` contents to your web server:
|
||||
```bash
|
||||
rsync -avz apt-repo/ user@example.com:/var/www/apt/
|
||||
```
|
||||
|
||||
Configure Apache/Nginx to serve the directory. See `docs/APT_REPOSITORY.md` for details.
|
||||
|
||||
## 🤖 GitHub Actions Automation
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Add these in GitHub Settings → Secrets → Actions:
|
||||
|
||||
1. **GPG_PRIVATE_KEY**
|
||||
```bash
|
||||
gpg --armor --export-secret-key YOUR-KEY-ID
|
||||
# Copy entire output including BEGIN/END lines
|
||||
```
|
||||
|
||||
2. **GPG_KEY_ID**
|
||||
```bash
|
||||
gpg --list-secret-keys --keyid-format LONG
|
||||
# Use the ID after "rsa4096/"
|
||||
```
|
||||
|
||||
3. **GPG_PASSPHRASE**
|
||||
```bash
|
||||
# Your GPG passphrase (leave empty if no passphrase)
|
||||
```
|
||||
|
||||
### Triggers
|
||||
|
||||
The workflow runs on:
|
||||
- **Version tags**: `git tag v1.50.0 && git push --tags`
|
||||
- **Manual dispatch**: Actions tab → "Publish APT Repository" → Run workflow
|
||||
|
||||
### What It Does
|
||||
|
||||
1. ✅ Builds packages for AMD64 and ARM64
|
||||
2. ✅ Initializes or updates APT repository
|
||||
3. ✅ Generates Packages files and metadata
|
||||
4. ✅ Signs with your GPG key
|
||||
5. ✅ Commits and pushes to gh-pages branch
|
||||
6. ✅ Creates GitHub Release with artifacts
|
||||
7. ✅ Generates summary with installation instructions
|
||||
|
||||
## 👥 User Installation
|
||||
|
||||
Once published, users install with:
|
||||
|
||||
```bash
|
||||
# Add repository
|
||||
curl -fsSL https://your-username.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://your-username.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
|
||||
# The agent service is automatically installed and configured
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Release New Version (Automated)
|
||||
|
||||
```bash
|
||||
# Update version in Cargo.toml, commit changes
|
||||
git add . && git commit -m "Bump version to 1.51.0"
|
||||
git tag v1.51.0
|
||||
git push origin main --tags
|
||||
|
||||
# GitHub Actions automatically:
|
||||
# - Builds packages for AMD64 and ARM64
|
||||
# - Updates apt-repo
|
||||
# - Signs with GPG
|
||||
# - Pushes to gh-pages branch
|
||||
```
|
||||
|
||||
### Manual Update (if needed)
|
||||
|
||||
```bash
|
||||
# On main branch
|
||||
cargo deb --package socktop
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
|
||||
./scripts/sign-apt-repo.sh
|
||||
|
||||
# Switch to gh-pages and update
|
||||
git checkout gh-pages
|
||||
cp -r apt-repo/* .
|
||||
git add . && git commit -m "Release v1.51.0" && git push
|
||||
git checkout main
|
||||
```
|
||||
|
||||
### Remove Old Versions
|
||||
|
||||
```bash
|
||||
# On gh-pages branch
|
||||
git checkout gh-pages
|
||||
rm pool/main/socktop_1.50.0_*.deb
|
||||
# Regenerate metadata (re-add remaining packages)
|
||||
git add . && git commit -m "Remove old versions" && git push
|
||||
git checkout main
|
||||
```
|
||||
|
||||
## 🎯 Key Benefits
|
||||
|
||||
✅ **No sponsor needed** - Host your own repository
|
||||
✅ **Full control** - You decide when to release
|
||||
✅ **Free hosting** - GitHub Pages at no cost
|
||||
✅ **Automated** - GitHub Actions does the work
|
||||
✅ **Professional** - Just like official repos
|
||||
✅ **Multi-arch** - AMD64, ARM64 support built-in
|
||||
✅ **Secure** - GPG signed packages
|
||||
✅ **Easy updates** - Users get updates via `apt upgrade`
|
||||
|
||||
## 📊 Repository Structure
|
||||
|
||||
```
|
||||
apt-repo/
|
||||
├── dists/
|
||||
│ └── stable/
|
||||
│ ├── Release # Main metadata (checksums)
|
||||
│ ├── Release.gpg # Detached signature
|
||||
│ ├── InRelease # Clearsigned release
|
||||
│ └── main/
|
||||
│ ├── binary-amd64/
|
||||
│ │ ├── Packages # Package list
|
||||
│ │ ├── Packages.gz # Compressed
|
||||
│ │ └── Release # Component metadata
|
||||
│ ├── binary-arm64/
|
||||
│ └── binary-armhf/
|
||||
├── pool/
|
||||
│ └── main/
|
||||
│ ├── socktop_1.50.0_amd64.deb
|
||||
│ ├── socktop-agent_1.50.1_amd64.deb
|
||||
│ ├── socktop_1.50.0_arm64.deb
|
||||
│ └── socktop-agent_1.50.1_arm64.deb
|
||||
├── KEY.gpg # Public GPG key
|
||||
├── README.md # Repository info
|
||||
├── index.html # Web interface
|
||||
└── packages.html # Package listing
|
||||
```
|
||||
|
||||
## 🔑 GPG Key Management
|
||||
|
||||
### Create New Key
|
||||
|
||||
```bash
|
||||
gpg --full-generate-key
|
||||
# Choose RSA 4096, no expiration (or 2 years)
|
||||
```
|
||||
|
||||
### Export Keys
|
||||
|
||||
```bash
|
||||
# Public key (for users)
|
||||
gpg --armor --export YOUR-KEY-ID > KEY.gpg
|
||||
|
||||
# Private key (for GitHub Secrets)
|
||||
gpg --armor --export-secret-key YOUR-KEY-ID
|
||||
```
|
||||
|
||||
### Backup Keys
|
||||
|
||||
```bash
|
||||
# Backup to safe location
|
||||
gpg --export-secret-keys YOUR-KEY-ID > gpg-private-backup.key
|
||||
gpg --export YOUR-KEY-ID > gpg-public-backup.key
|
||||
```
|
||||
|
||||
### Key Rotation
|
||||
|
||||
If your key expires or is compromised:
|
||||
```bash
|
||||
./scripts/sign-apt-repo.sh apt-repo stable NEW-KEY-ID
|
||||
gpg --armor --export NEW-KEY-ID > apt-repo/KEY.gpg
|
||||
# Users need to re-import the key
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Repository not signed"
|
||||
```bash
|
||||
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
|
||||
ls apt-repo/dists/stable/Release* # Should show 3 files
|
||||
```
|
||||
|
||||
### "Package not found"
|
||||
```bash
|
||||
cd apt-repo
|
||||
dpkg-scanpackages --arch amd64 pool/main /dev/null > dists/stable/main/binary-amd64/Packages
|
||||
gzip -9 -k -f dists/stable/main/binary-amd64/Packages
|
||||
cd ..
|
||||
./scripts/sign-apt-repo.sh
|
||||
```
|
||||
|
||||
### "404 Not Found" on GitHub Pages
|
||||
- Wait 2-3 minutes after pushing
|
||||
- Check Settings → Pages is enabled
|
||||
- Verify source branch/directory
|
||||
|
||||
### GitHub Actions not signing
|
||||
- Check all 3 secrets are set correctly
|
||||
- GPG_PRIVATE_KEY must include BEGIN/END lines
|
||||
- Test signing locally first
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
| File | Purpose | Length |
|
||||
|------|---------|--------|
|
||||
| `QUICK_START_APT_REPO.md` | Get started in < 10 minutes | Quick |
|
||||
| `SETUP_GITHUB_PAGES.md` | Detailed gh-pages setup guide | Step-by-step |
|
||||
| `docs/APT_REPOSITORY.md` | Complete guide with all options | Comprehensive |
|
||||
| `docs/DEBIAN_PACKAGING.md` | How .deb packages are built | Technical |
|
||||
| `DEBIAN_PACKAGING_SUMMARY.md` | Overview of packaging work | Summary |
|
||||
| `APT_REPO_SUMMARY.md` | This file | Overview |
|
||||
|
||||
## 🎓 Learning Path
|
||||
|
||||
1. **Start here**: `QUICK_START_APT_REPO.md` (10 min)
|
||||
2. **Set up**: Run `./scripts/setup-apt-repo.sh` (15 min)
|
||||
3. **Publish**: Follow `SETUP_GITHUB_PAGES.md` (5 min)
|
||||
4. **Automate**: Set up GitHub Actions secrets (10 min)
|
||||
5. **Advanced**: Read `docs/APT_REPOSITORY.md` as needed
|
||||
|
||||
## 🚦 Next Steps
|
||||
|
||||
Choose your path:
|
||||
|
||||
### Just Getting Started?
|
||||
1. ✅ Read `QUICK_START_APT_REPO.md`
|
||||
2. ✅ Run `./scripts/setup-apt-repo.sh`
|
||||
3. ✅ Follow `SETUP_GITHUB_PAGES.md` to publish
|
||||
4. ✅ Test installation on a VM
|
||||
|
||||
### Want Automation?
|
||||
1. ✅ Generate/export GPG key
|
||||
2. ✅ Add GitHub Secrets
|
||||
3. ✅ Tag a release: `git tag v1.50.0 && git push --tags`
|
||||
4. ✅ Watch GitHub Actions magic happen
|
||||
|
||||
### Want to Understand Everything?
|
||||
1. ✅ Read `docs/APT_REPOSITORY.md` (comprehensive)
|
||||
2. ✅ Study the scripts in `scripts/`
|
||||
3. ✅ Examine `.github/workflows/publish-apt-repo.yml`
|
||||
4. ✅ Learn about Debian repository format
|
||||
|
||||
### Ready for Production?
|
||||
1. ✅ Set up monitoring/analytics
|
||||
2. ✅ Create PPA for Ubuntu (Launchpad)
|
||||
3. ✅ Apply to Debian mentors for official inclusion
|
||||
4. ✅ Set up repository mirrors
|
||||
5. ✅ Document best practices for users
|
||||
|
||||
## 🌟 Success Criteria
|
||||
|
||||
You'll know you're successful when:
|
||||
|
||||
- [ ] Users can `apt install socktop`
|
||||
- [ ] Updates work with `apt upgrade`
|
||||
- [ ] Multiple architectures supported
|
||||
- [ ] Repository is GPG signed
|
||||
- [ ] GitHub Actions publishes automatically
|
||||
- [ ] Installation instructions in README
|
||||
- [ ] Zero sponsor or approval needed
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Test first**: Always test on a fresh VM before publishing
|
||||
2. **Keep versions**: Don't delete old .deb files immediately
|
||||
3. **Backup GPG key**: Store it safely offline
|
||||
4. **Monitor downloads**: Use GitHub Insights or server logs
|
||||
5. **Document everything**: Help users troubleshoot
|
||||
6. **Version consistently**: Use semantic versioning
|
||||
7. **Sign always**: Never publish unsigned repositories
|
||||
|
||||
## 🔗 Resources
|
||||
|
||||
- [Debian Repository Format](https://wiki.debian.org/DebianRepository/Format)
|
||||
- [GitHub Pages Docs](https://docs.github.com/en/pages)
|
||||
- [cargo-deb](https://github.com/kornelski/cargo-deb)
|
||||
- [Ubuntu PPA Guide](https://help.launchpad.net/Packaging/PPA)
|
||||
- [Debian Mentors](https://mentors.debian.net/)
|
||||
|
||||
## 🎊 Congratulations!
|
||||
|
||||
You now have everything you need to:
|
||||
- ✅ Create your own APT repository
|
||||
- ✅ Host it for free on GitHub Pages
|
||||
- ✅ Automate the entire process
|
||||
- ✅ Distribute packages professionally
|
||||
- ✅ Provide easy installation for users
|
||||
|
||||
**No sponsor required. No approval needed. You're in control!** 🚀
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Check the docs or open an issue.
|
||||
|
||||
**Ready to publish?** Run `./scripts/setup-apt-repo.sh` and follow the wizard!
|
||||
@@ -0,0 +1,43 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"socktop",
|
||||
"socktop_agent"
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
# async + streams
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
anyhow = "1.0"
|
||||
|
||||
# websocket
|
||||
tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] }
|
||||
url = "2.5"
|
||||
|
||||
# JSON + error handling
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# system stats (align across crates)
|
||||
sysinfo = "0.37"
|
||||
|
||||
# CLI UI
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.27"
|
||||
|
||||
|
||||
# web server (remote-agent)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
# protobuf
|
||||
prost = "0.13"
|
||||
dirs-next = "2"
|
||||
|
||||
[profile.release]
|
||||
# Favor smaller, simpler binaries with good runtime perf
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
opt-level = 3
|
||||
strip = "symbols"
|
||||
@@ -1,109 +0,0 @@
|
||||
# 🚀 Get Your APT Repository Live in 5 Minutes
|
||||
|
||||
## You're Here Because...
|
||||
|
||||
You want to publish socktop packages via APT, but GitHub Pages won't let you select `apt-repo/` folder. Here's why and how to fix it:
|
||||
|
||||
**The Issue:** GitHub Pages only serves from `/` (root) or `/docs`, not custom folders like `/apt-repo`.
|
||||
|
||||
**The Solution:** Use a `gh-pages` branch where `apt-repo` contents go in the root.
|
||||
|
||||
## Quick Setup (5 Steps)
|
||||
|
||||
### 1. Create apt-repo locally (if you haven't)
|
||||
|
||||
```bash
|
||||
./scripts/setup-apt-repo.sh
|
||||
```
|
||||
|
||||
This creates `apt-repo/` with your packages and signs them.
|
||||
|
||||
### 2. Create gh-pages branch
|
||||
|
||||
```bash
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf .
|
||||
```
|
||||
|
||||
### 3. Copy apt-repo to root
|
||||
|
||||
```bash
|
||||
cp -r apt-repo/* .
|
||||
rm -rf apt-repo
|
||||
ls
|
||||
# You should see: dists/ pool/ KEY.gpg index.html README.md
|
||||
```
|
||||
|
||||
### 4. Push to GitHub
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Initialize APT repository"
|
||||
git push -u origin gh-pages
|
||||
git checkout main
|
||||
```
|
||||
|
||||
### 5. Enable GitHub Pages
|
||||
|
||||
1. Go to: **Settings → Pages**
|
||||
2. Source: **gh-pages** → **/ (root)**
|
||||
3. Click **Save**
|
||||
|
||||
**Done!** ✅ Your repo will be live at `https://your-username.github.io/socktop/` in 1-2 minutes.
|
||||
|
||||
## Test It
|
||||
|
||||
```bash
|
||||
curl -I https://your-username.github.io/socktop/KEY.gpg
|
||||
# Should return: HTTP/2 200
|
||||
```
|
||||
|
||||
## Install It (On Any Debian/Ubuntu System)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://your-username.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://your-username.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
```
|
||||
|
||||
## What's Next?
|
||||
|
||||
### Now (Optional):
|
||||
- Customize `index.html` on gh-pages for a nice landing page
|
||||
- Add installation instructions to your main README
|
||||
|
||||
### Later:
|
||||
- Set up GitHub Actions automation (see `QUICK_START_APT_REPO.md`)
|
||||
- Add more architectures (ARM64, ARMv7)
|
||||
|
||||
## Understanding the Setup
|
||||
|
||||
```
|
||||
main branch: gh-pages branch:
|
||||
├── src/ ├── dists/
|
||||
├── Cargo.toml ├── pool/
|
||||
├── scripts/ ├── KEY.gpg
|
||||
└── apt-repo/ (local) └── index.html ← GitHub Pages serves this
|
||||
|
||||
Work here ↑ Published here ↑
|
||||
```
|
||||
|
||||
- **main**: Your development work
|
||||
- **gh-pages**: What users see/download
|
||||
- **apt-repo/**: Local folder (ignored in git, see `.gitignore`)
|
||||
|
||||
## Need More Help?
|
||||
|
||||
- **Quick start**: `QUICK_START_APT_REPO.md`
|
||||
- **Detailed setup**: `SETUP_GITHUB_PAGES.md`
|
||||
- **Why gh-pages?**: `WHY_GHPAGES_BRANCH.md`
|
||||
- **Full guide**: `docs/APT_REPOSITORY.md`
|
||||
|
||||
---
|
||||
|
||||
**You got this!** 🎉
|
||||
@@ -1,42 +0,0 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGNBGkih7QBDADgX6sYMx2Lp6qcZxeCCizcy4TFsxcRJfp5mfbMplVES0hQToIP
|
||||
EMC11JqPwQdLliXKjUr8Z2kgM2oqvH+dkdgzUGrw6kTK8YHc+qs37iJAOVS9D72X
|
||||
tTld282NrtFwzb74nS2GKPkpWI7aSKBpHtWFPX/1ONsc56qGqFd3wwikEvCz8MeJ
|
||||
HwCD1JZ9F+2DyyXWsTJNgDwPloJSUbtyVuk2gd6PeTg7AQdx92Pk/mggmYbHtP8N
|
||||
wy072ku1g8K/hplmwIOGpSx1JWvAQkDU/Bb/jSqrYg2wSHO7IQnYE8I3x/zglYBl
|
||||
FYNh47TVQr0zPVSYR1MQkHU5YLBTDc5UgDvtcsYUiTtq4D/m8HWmKja0/UKGxvDJ
|
||||
P5sUPcp4dk77RdoCtUe5HImYGS8lo5N3+t0lz8sd9rYmRiIO4f7FJaJqJeHbUJyn
|
||||
iw/GCQh5D5/D571dICrEq/QhL+k5KhJljPGoVMGPFXJIc7q+CxvGp2oOo5fOlbOn
|
||||
3kSrM93AJPwT8FMAEQEAAbRFSmFzb24gV2l0dHkgKHNvY2t0b3AgYXB0IHNpZ25p
|
||||
bmcga2V5KSA8amFzb25wd2l0dHkrc29ja3RvcEBwcm90b24ubWU+iQHOBBMBCgA4
|
||||
FiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkih7QCGwMFCwkIBwIGFQoJCAsCBBYC
|
||||
AwECHgECF4AACgkQESwaeYRl+/KV+gwAzfZVZEhO7MQV2EmNeKVK1GycFSm2oUAl
|
||||
ZbwNIEHu6+tOzqXJb8o65BtGlbLSGavsMpgRCK2SL83DdLOkutG1ahQiJr+5GaXC
|
||||
zbQgX+VWqGPZtQ+I6/rVoYZPMTCrqpAmFgvVpqv0xod7w8/wny8/XmhQ37KY2/0l
|
||||
B38oNTvdA7C8jzSrI6kr3XqurvQRW7z+MnC+nCp9Ob9bYtY0kpd4U3NrVdb8m32U
|
||||
d5LVFwD1OGvzLOSqyJ33IKjSJc4KLvW+aEsHXe+fHO9UEzH8Nbo5MmVvX3QIHiyq
|
||||
jD4zN16AGsGYqCK4irtQCiD3wBOdsG/RVkgIcdlmAH3EGEp7Ux8+7v1PXYI+UrSs
|
||||
XE7f1xFTJ2r5TMex6W3he073Em4qhQsrnMF5syTZsM6N+5UqXVOM1RuDVVXr7929
|
||||
hC3G8pK/A2W5Lwpxl2yzock2CxhvUn7M/xm4VbcPlWTCUd/QzU8VtsgaGHcuhi5e
|
||||
xHY1AU07STLB9RinjBVf2bmk4oDQcmB6uQGNBGkih7QBDACrjE+xSWP92n931/5t
|
||||
+tXcujwFlIpSZdbSQFr0B0YyjPRUP4FSzEGu8vuM5ChUfWKhmN1dDr5C4qFo9NgQ
|
||||
6oCN2HubajSGyXNwnOMlMb5ck79Ubmy9yDV9/ZLqpJJiozGap2/EnNoDhaANlmUg
|
||||
rfqUHpIB8XC2IZ0Itt05tp/u78dJiB+R6ReZn/bVUafNV4jIqYZfLRzI3FTJ4xvK
|
||||
FGs/ER+JajAdJQ8LPfazmDQSGw0huguxhopZwKQ/qWZMn1OHq/ZaPvCqbQt3irLw
|
||||
dLPDC4pEaYGRyADYeyuarG0DVyUQ9XRc/NufKDvOAn33LpBPBpcvNQAsVhWTCYl7
|
||||
ogQ+suVYVN8Tu7v4bUSHKwzXKvLN/ojJX/Fh7eTW4TPsgLHNHAEDUkSQozIe9vO6
|
||||
o+vydDqRxuXJgdkR7lqP6PQDYrhRYZGJf57eKf6VtTKYFaMbiMWPU+vcHeB0/iDe
|
||||
Pv81qro2LD2PG5WCzDpNETBceCTjykb9r0VHx4/JsiojKmsAEQEAAYkBtgQYAQoA
|
||||
IBYhBB51VqgFObg5S8KCDREsGnmEZfvyBQJpIoe0AhsMAAoJEBEsGnmEZfvyNp8M
|
||||
AIH+6+hGB3qADdnhNgb+3fN0511eK9Uk82lxgGARLcD8GN1UP0HlvEqkxCHy3PUe
|
||||
tHcsuYVz7i8pmpEGdFx9zv7MelenUsJniUQ++OZKx6iUG/MYqz//NxY+5lyRmcu2
|
||||
aYvUxhkgf9zgxXTkTyV2VV32mX//cHcwc+c/089QAPzCMaSrHdNK+ED9+k8uquJ1
|
||||
lSL9Bm15z/EV42v9Q/4KTM5OBLHpNw0Rvn9C0iuZVwHXBrrA/HSGXpA54AqNUMpZ
|
||||
kRPgLQcy5yVE2y1aXLXt2XdTn6YPzrAjNoazYYuCWHYIZU7dGkIswpsDirDLKHdD
|
||||
onb3VShmSpemYjsuFiqhfi6qwCkeHsz/CpQAp70SZ+z9oB8H80PJVKPbPIP3zEf3
|
||||
i7bcsqHA7stF+8sJclXgxBUBeDJ3O2jN/scBOcvNA6xoRp7+oJbnjDRuxBmh+fVg
|
||||
TIuw2++vTF2Ml0EMv7ePTpr7b1DofuJRNYGkuAIMVXHjLTqMiTJUce3OUy003zMg
|
||||
Dg==
|
||||
=AaPQ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -1,221 +0,0 @@
|
||||
# Quick Start: Setting Up Your socktop APT Repository
|
||||
|
||||
This guide will get your APT repository up and running in **under 10 minutes**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ ] Debian packages built (or use GitHub Actions to build them)
|
||||
- [ ] GPG key for signing
|
||||
- [ ] GitHub repository with Pages enabled
|
||||
|
||||
## Step 1: Create GPG Key (if needed)
|
||||
|
||||
```bash
|
||||
# Generate a new key
|
||||
gpg --full-generate-key
|
||||
|
||||
# Select:
|
||||
# - RSA and RSA (default)
|
||||
# - 4096 bits
|
||||
# - Key does not expire (or 2 years)
|
||||
# - Your name and email
|
||||
|
||||
# Get your key ID
|
||||
gpg --list-secret-keys --keyid-format LONG
|
||||
# Look for the part after "rsa4096/" - that's your KEY-ID
|
||||
```
|
||||
|
||||
## Step 2: Initialize Repository Locally
|
||||
|
||||
```bash
|
||||
cd socktop
|
||||
|
||||
# Create the repository structure
|
||||
./scripts/init-apt-repo.sh
|
||||
|
||||
# Build packages (or download from GitHub Actions)
|
||||
cargo install cargo-deb
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
|
||||
# Add packages to repository
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
|
||||
|
||||
# Sign the repository (replace YOUR-KEY-ID with actual key ID)
|
||||
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
|
||||
|
||||
# Update URLs with your GitHub username
|
||||
sed -i 's/YOUR-USERNAME/your-github-username/g' apt-repo/README.md apt-repo/index.html
|
||||
```
|
||||
|
||||
## Step 3: Publish to GitHub Pages (gh-pages branch)
|
||||
|
||||
```bash
|
||||
# Create gh-pages branch
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf .
|
||||
|
||||
# Copy apt-repo CONTENTS to root (not the folder itself)
|
||||
cp -r apt-repo/* .
|
||||
rm -rf apt-repo
|
||||
|
||||
# Commit and push
|
||||
git add .
|
||||
git commit -m "Initialize APT repository"
|
||||
git push -u origin gh-pages
|
||||
|
||||
# Go back to main branch
|
||||
git checkout main
|
||||
```
|
||||
|
||||
Then in GitHub:
|
||||
1. Go to **Settings → Pages**
|
||||
2. Source: **Deploy from a branch**
|
||||
3. Branch: **gh-pages** → **/ (root)** → **Save**
|
||||
|
||||
Wait 1-2 minutes, then visit: `https://your-username.github.io/socktop/`
|
||||
|
||||
## Step 4: Automate with GitHub Actions
|
||||
|
||||
Add these secrets to your repository (Settings → Secrets → Actions):
|
||||
|
||||
```bash
|
||||
# Export your private key
|
||||
gpg --armor --export-secret-key YOUR-KEY-ID
|
||||
|
||||
# Copy the ENTIRE output and save as secret: GPG_PRIVATE_KEY
|
||||
```
|
||||
|
||||
Add these three secrets:
|
||||
- **GPG_PRIVATE_KEY**: Your exported private key
|
||||
- **GPG_KEY_ID**: Your key ID (e.g., `ABC123DEF456`)
|
||||
- **GPG_PASSPHRASE**: Your key passphrase (leave empty if no passphrase)
|
||||
|
||||
The workflow in `.github/workflows/publish-apt-repo.yml` will now:
|
||||
- Build packages for AMD64 and ARM64
|
||||
- Update the APT repository
|
||||
- Sign with your GPG key
|
||||
- Push to gh-pages automatically
|
||||
|
||||
Trigger it by:
|
||||
- Creating a version tag: `git tag v1.50.0 && git push --tags`
|
||||
- Manual dispatch from GitHub Actions tab
|
||||
|
||||
## Step 5: Test It
|
||||
|
||||
On any Debian/Ubuntu system:
|
||||
|
||||
```bash
|
||||
# Add your repository
|
||||
curl -fsSL https://your-username.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://your-username.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent
|
||||
|
||||
# Verify
|
||||
socktop --version
|
||||
socktop_agent --version
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Add a New Version
|
||||
|
||||
```bash
|
||||
# Build new packages
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
|
||||
# Add to repository
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
|
||||
|
||||
# Re-sign
|
||||
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
|
||||
|
||||
# Publish
|
||||
cd docs/apt # or wherever your apt-repo is
|
||||
git add .
|
||||
git commit -m "Release v1.51.0"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Or Just Tag and Let GitHub Actions Do It
|
||||
|
||||
```bash
|
||||
# Update version in Cargo.toml
|
||||
# Commit changes
|
||||
git add .
|
||||
git commit -m "Bump version to 1.51.0"
|
||||
|
||||
# Tag and push
|
||||
git tag v1.51.0
|
||||
git push origin main --tags
|
||||
|
||||
# GitHub Actions will:
|
||||
# - Build packages for AMD64 and ARM64
|
||||
# - Update gh-pages branch automatically
|
||||
# - Sign and publish!
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Repository not signed" error
|
||||
|
||||
Make sure you signed it:
|
||||
```bash
|
||||
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
|
||||
ls apt-repo/dists/stable/Release*
|
||||
# Should show: Release, Release.gpg, InRelease, KEY.gpg
|
||||
```
|
||||
|
||||
### "404 Not Found" on GitHub Pages
|
||||
|
||||
1. Check Settings → Pages is enabled
|
||||
2. Wait 2-3 minutes for GitHub to deploy
|
||||
3. Verify the URL structure matches your settings
|
||||
|
||||
### GitHub Actions not signing
|
||||
|
||||
Check that all three secrets are set correctly:
|
||||
- Settings → Secrets and variables → Actions
|
||||
- Make sure GPG_PRIVATE_KEY includes the BEGIN/END lines
|
||||
- Test locally first
|
||||
|
||||
## What's Next?
|
||||
|
||||
✅ You now have a working APT repository!
|
||||
|
||||
**Share it:**
|
||||
- Add installation instructions to your main README
|
||||
- Tweet/blog about it
|
||||
- Submit to awesome-rust lists
|
||||
|
||||
**Improve it:**
|
||||
- Customize your GitHub Pages site (it's just HTML!)
|
||||
- Add more architectures (ARMv7)
|
||||
- Create multiple distributions (stable, testing)
|
||||
- Set up download statistics
|
||||
- Apply to Ubuntu PPA (Launchpad)
|
||||
- Eventually submit to official Debian repos
|
||||
|
||||
## Full Documentation
|
||||
|
||||
For detailed information, see:
|
||||
- `docs/APT_REPOSITORY.md` - Complete APT repository guide
|
||||
- `docs/DEBIAN_PACKAGING.md` - Debian packaging details
|
||||
- `DEBIAN_PACKAGING_SUMMARY.md` - Quick summary
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue on GitHub or check the full documentation.
|
||||
|
||||
---
|
||||
|
||||
**Happy packaging! 📦**
|
||||
@@ -1,38 +1,545 @@
|
||||
# socktop APT Repository
|
||||
# socktop
|
||||
|
||||
This repository contains Debian packages for socktop and socktop-agent.
|
||||
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
|
||||
|
||||
## Adding this repository
|
||||
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||
Add the repository to your system:
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Remote monitoring via WebSocket (JSON over WS)
|
||||
- Optional WSS (TLS): agent auto‑generates a self‑signed cert on first run; client pins the cert via --tls-ca/-t
|
||||
- TUI built with ratatui
|
||||
- CPU
|
||||
- Overall sparkline + per-core mini bars
|
||||
- Accurate per-process CPU% (Linux /proc deltas), normalized to 0–100%
|
||||
- Memory/Swap gauges with human units
|
||||
- Disks: per-device usage
|
||||
- Network: per-interface throughput with sparklines and peak markers
|
||||
- Temperatures: CPU (optional)
|
||||
- Top processes (top 50)
|
||||
- PID, name, CPU%, memory, and memory%
|
||||
- Click-to-sort by CPU% or Mem (descending)
|
||||
- Scrollbar and mouse/keyboard scrolling
|
||||
- Total process count shown in the header
|
||||
- Only top-level processes listed (threads hidden) — matches btop/top
|
||||
- Optional GPU metrics (can be disabled)
|
||||
- Optional auth token for the agent
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites: Install Rust (rustup)
|
||||
|
||||
Rust is fast, safe, and cross‑platform. Installing it will make your machine better. Consider yourself privileged.
|
||||
|
||||
Linux/macOS:
|
||||
```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
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
# load cargo for this shell
|
||||
source "$HOME/.cargo/env"
|
||||
# ensure stable is up to date
|
||||
rustup update stable
|
||||
rustc --version
|
||||
cargo --version
|
||||
# after install you may need to reload your shell, e.g.:
|
||||
exec bash # or: exec zsh / exec fish
|
||||
```
|
||||
|
||||
## Manual Installation
|
||||
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.
|
||||
|
||||
You can also download and install packages manually from the `pool/main/` directory.
|
||||
### Raspberry Pi / Ubuntu / PopOS (required)
|
||||
|
||||
Install GPU support with apt command below
|
||||
|
||||
```bash
|
||||
wget https://jasonwitty.github.io/socktop/pool/main/socktop_VERSION_ARCH.deb
|
||||
sudo dpkg -i socktop_VERSION_ARCH.deb
|
||||
sudo apt-get update
|
||||
sudo apt-get install libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
## Supported Architectures
|
||||
---
|
||||
|
||||
- amd64 (x86_64)
|
||||
- arm64 (aarch64)
|
||||
- armhf (32-bit ARM)
|
||||
## Architecture
|
||||
|
||||
## Building from Source
|
||||
Two components:
|
||||
|
||||
See the main repository at https://github.com/jasonwitty/socktop
|
||||
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
|
||||
|
||||
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
- Build both binaries:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jasonwitty/socktop.git
|
||||
cd socktop
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
- Start the agent on the target machine (default port 3000):
|
||||
|
||||
```bash
|
||||
./target/release/socktop_agent --port 3000
|
||||
```
|
||||
|
||||
- Connect with the TUI from your local machine:
|
||||
|
||||
```bash
|
||||
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
||||
```
|
||||
|
||||
### Quick demo (no agent setup)
|
||||
|
||||
Spin up a temporary local agent on port 3231 and connect automatically:
|
||||
|
||||
```bash
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
Or just run `socktop` with no arguments and pick the built‑in `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
|
||||
|
||||
- Runs locally (`ws://127.0.0.1:3231/ws`)
|
||||
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
|
||||
|
||||
---
|
||||
|
||||
## Install (from crates.io)
|
||||
|
||||
You don’t need to clone this repo to use socktop. Install the published binaries with cargo:
|
||||
|
||||
```bash
|
||||
# TUI (client)
|
||||
cargo install socktop
|
||||
# Agent (server)
|
||||
cargo install socktop_agent
|
||||
```
|
||||
|
||||
This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
|
||||
|
||||
Notes:
|
||||
- After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
|
||||
- Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldn’t. Be brave.
|
||||
|
||||
System-wide agent (Linux)
|
||||
|
||||
```bash
|
||||
# If you installed with cargo, binaries are in ~/.cargo/bin
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
|
||||
# Install and enable the systemd service (example unit in docs/)
|
||||
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
# Enable SSL
|
||||
|
||||
# Stop service
|
||||
sudo systemctl stop socktop-agent
|
||||
|
||||
# Edit service to append SSL option and port
|
||||
sudo micro /etc/systemd/system/socktop-agent.service
|
||||
|
||||
--
|
||||
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
|
||||
--
|
||||
|
||||
# Reload
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Restart
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# check logs for certificate location
|
||||
sudo journalctl -u socktop-agent -f
|
||||
|
||||
--
|
||||
Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
|
||||
--
|
||||
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
Agent (server):
|
||||
|
||||
```bash
|
||||
socktop_agent --port 3000
|
||||
# or env: SOCKTOP_PORT=3000 socktop_agent
|
||||
# optional auth: SOCKTOP_TOKEN=changeme socktop_agent
|
||||
# enable TLS (self‑signed cert, default port 8443; you can also use -p):
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Client (TUI):
|
||||
|
||||
```bash
|
||||
socktop ws://HOST:3000/ws
|
||||
# with token:
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
# TLS with pinned server certificate (recommended over the internet):
|
||||
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# (By default hostname/SAN verification is skipped for ease on home networks. To enforce it add --verify-hostname)
|
||||
socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# shorthand:
|
||||
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
||||
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
||||
```
|
||||
|
||||
Intervals (client-driven):
|
||||
- Fast metrics: ~500 ms
|
||||
- Processes: ~2 s (top 50)
|
||||
- Disks: ~5 s
|
||||
|
||||
The agent stays idle unless queried. When queried, it collects just what’s needed.
|
||||
|
||||
---
|
||||
|
||||
## Connection Profiles (Named)
|
||||
|
||||
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||
|
||||
Config file location:
|
||||
|
||||
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
|
||||
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
|
||||
|
||||
### Creating a profile
|
||||
|
||||
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
|
||||
|
||||
```bash
|
||||
socktop --profile prod ws://prod-host:3000/ws
|
||||
# With TLS pinning:
|
||||
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
|
||||
|
||||
You can also set custom intervals (milliseconds):
|
||||
|
||||
```bash
|
||||
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
|
||||
```
|
||||
```
|
||||
|
||||
If a profile already exists you will be prompted before overwriting:
|
||||
|
||||
```
|
||||
$ socktop --profile prod ws://new-host:3000/ws
|
||||
Overwrite existing profile 'prod'? [y/N]: y
|
||||
```
|
||||
|
||||
To overwrite without an interactive prompt pass `--save`:
|
||||
|
||||
```bash
|
||||
socktop --profile prod --save ws://new-host:3000/ws
|
||||
```
|
||||
|
||||
### Using a saved profile
|
||||
|
||||
Just pass the profile name (no URL needed):
|
||||
|
||||
```bash
|
||||
socktop --profile prod
|
||||
socktop -P prod-tls # short flag
|
||||
```
|
||||
|
||||
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
|
||||
|
||||
### Interactive selection (no args)
|
||||
|
||||
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
|
||||
|
||||
```
|
||||
$ socktop
|
||||
Select profile:
|
||||
1. prod
|
||||
2. prod-tls
|
||||
Enter number (or blank to abort): 2
|
||||
```
|
||||
|
||||
Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
|
||||
|
||||
### JSON format
|
||||
|
||||
An example `profiles.json` (pretty‑printed):
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"prod": { "url": "ws://prod-host:3000/ws" },
|
||||
"prod-tls": {
|
||||
"url": "wss://prod-host:8443/ws",
|
||||
"tls_ca": "/home/user/certs/prod-cert.pem",
|
||||
"metrics_interval_ms": 500,
|
||||
"processes_interval_ms": 2000
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
|
||||
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
|
||||
- Profiles are client-side convenience only; they do not affect the agent.
|
||||
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
Update the agent (systemd):
|
||||
|
||||
```bash
|
||||
# on the server running the agent
|
||||
cargo install socktop_agent --force
|
||||
sudo systemctl stop socktop-agent
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
# if you changed the unit file:
|
||||
# sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
# sudo systemctl daemon-reload
|
||||
sudo systemctl start socktop-agent
|
||||
sudo systemctl status socktop-agent --no-pager
|
||||
# logs:
|
||||
# journalctl -u socktop-agent -f
|
||||
```
|
||||
|
||||
Update the TUI (client):
|
||||
```bash
|
||||
cargo install socktop --force
|
||||
socktop ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
|
||||
|
||||
---
|
||||
|
||||
## Configuration (agent)
|
||||
|
||||
- Port:
|
||||
- Flag: --port 8080 or -p 8080
|
||||
- Positional: socktop_agent 8080
|
||||
- Env: SOCKTOP_PORT=8080
|
||||
- TLS (self‑signed):
|
||||
- Enable: --enableSSL
|
||||
- Default TLS port: 8443 (override with --port/-p)
|
||||
- Certificate/Key location (created on first TLS run):
|
||||
- Linux (XDG): $XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem} (defaults to ~/.config)
|
||||
- The agent prints these paths on creation.
|
||||
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
|
||||
- Additional SANs: set `SOCKTOP_AGENT_EXTRA_SANS` (comma‑separated) before first TLS start to include extra IPs/DNS names in the cert. Example:
|
||||
```bash
|
||||
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL
|
||||
```
|
||||
This prevents client errors like `NotValidForName` when connecting via an IP not present in the default cert SAN list.
|
||||
- Expiry / rotation: the generated cert is valid for ~397 days from creation. If the agent fails to start with an "ExpiredCertificate" error (or your client reports expiry), simply delete the existing cert and key:
|
||||
```bash
|
||||
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
|
||||
# (adjust path if XDG_CONFIG_HOME is set or different user)
|
||||
systemctl restart socktop-agent # if running under systemd
|
||||
```
|
||||
On next TLS start the agent will generate a fresh pair. Only distribute the new cert.pem to clients (never the key).
|
||||
- Auth token (optional): SOCKTOP_TOKEN=changeme
|
||||
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
|
||||
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
|
||||
|
||||
---
|
||||
|
||||
## Keyboard & Mouse
|
||||
|
||||
- Quit: q or Esc
|
||||
- Processes pane:
|
||||
- Click “CPU %” to sort by CPU descending
|
||||
- Click “Mem” to sort by memory descending
|
||||
- Mouse wheel: scroll
|
||||
- Drag scrollbar: scroll
|
||||
- Arrow/PageUp/PageDown/Home/End: scroll
|
||||
|
||||
---
|
||||
|
||||
## Example agent JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"cpu_total": 12.4,
|
||||
"cpu_per_core": [11.2, 15.7],
|
||||
"mem_total": 33554432,
|
||||
"mem_used": 18321408,
|
||||
"swap_total": 0,
|
||||
"swap_used": 0,
|
||||
"process_count": 127,
|
||||
"hostname": "myserver",
|
||||
"cpu_temp_c": 42.5,
|
||||
"disks": [{"name":"nvme0n1p2","total":512000000000,"available":320000000000}],
|
||||
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
|
||||
"top_processes": [
|
||||
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
|
||||
],
|
||||
"gpus": null
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- process_count is merged into the main metrics on the client when processes are polled.
|
||||
- top_processes are the current top 50 (sorting in the TUI is client-side).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Set a token on the agent and pass it as a query param from the client:
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
|
||||
```
|
||||
|
||||
Client:
|
||||
|
||||
```bash
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
```
|
||||
|
||||
### TLS / WSS
|
||||
|
||||
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
|
||||
|
||||
Server (generates self‑signed cert and key on first run):
|
||||
|
||||
```bash
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Client (trust/pin the server cert; copy cert.pem from the agent):
|
||||
|
||||
```bash
|
||||
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Do not copy the private key off the server; only the cert.pem is needed by clients.
|
||||
- When --tls-ca/-t is supplied, the client auto‑upgrades ws:// to wss:// to avoid protocol mismatch.
|
||||
- Hostname (SAN) verification is DISABLED by default (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking.
|
||||
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
|
||||
|
||||
---
|
||||
|
||||
## Using tmux to monitor multiple hosts
|
||||
|
||||
You can use tmux to show multiple socktop instances in a single terminal.
|
||||
|
||||

|
||||
monitoring 4 Raspberry Pis using Tmux
|
||||
|
||||
Prerequisites:
|
||||
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
|
||||
|
||||
Key bindings (defaults):
|
||||
- Split left/right: Ctrl-b %
|
||||
- Split top/bottom: Ctrl-b "
|
||||
- Move between panes: Ctrl-b + Arrow keys
|
||||
- Show pane numbers: Ctrl-b q
|
||||
- Close a pane: Ctrl-b x
|
||||
- Detach from session: Ctrl-b d
|
||||
|
||||
Two panes (left/right)
|
||||
- This creates a session named "socktop", splits it horizontally, and starts two socktops.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-layout even-horizontal \; \
|
||||
attach
|
||||
```
|
||||
|
||||
Four panes (top-left, top-right, bottom-left, bottom-right)
|
||||
- This creates a 2x2 grid with one socktop per pane.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
|
||||
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
|
||||
select-layout tiled \; \
|
||||
attach
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Replace HOST1..HOST4 (and ports) with your targets.
|
||||
- Reattach later: `tmux attach -t socktop`
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Linux: fully supported (agent and client).
|
||||
- Raspberry Pi:
|
||||
- 64-bit: aarch64-unknown-linux-gnu
|
||||
- 32-bit: armv7-unknown-linux-gnueabihf
|
||||
- Windows:
|
||||
- TUI + agent can build with stable Rust; bring your own MSVC. You’re on Windows; you know the drill.
|
||||
- CPU temperature may be unavailable.
|
||||
- binary exe for both available in build artifacts under actions.
|
||||
- macOS:
|
||||
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy --all-targets --all-features
|
||||
cargo run -p socktop -- ws://127.0.0.1:3000/ws
|
||||
# TLS (dev): first run will create certs under ~/.config/socktop_agent/tls/
|
||||
cargo run -p socktop_agent -- --enableSSL --port 8443
|
||||
```
|
||||
|
||||
### Auto-format on commit
|
||||
|
||||
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
|
||||
Enable it (one-time):
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-commit
|
||||
```
|
||||
|
||||
Every commit will then format Rust sources and restage them automatically.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Agent authentication (token)
|
||||
- [x] Hide per-thread entries; only show processes
|
||||
- [x] Sort top processes in the TUI
|
||||
- [ ] Configurable refresh intervals (client)
|
||||
- [ ] Export metrics to file
|
||||
- [x] TLS / WSS support (self‑signed server cert + client pinning)
|
||||
- [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see LICENSE.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- ratatui for the TUI
|
||||
- sysinfo for system metrics
|
||||
- tokio-tungstenite for WebSockets
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
# Setting Up GitHub Pages for socktop APT Repository
|
||||
|
||||
This guide walks you through the initial setup of your APT repository on GitHub Pages using the `gh-pages` branch.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ ] You've run `./scripts/setup-apt-repo.sh` or manually created `apt-repo/`
|
||||
- [ ] `apt-repo/` contains signed packages and metadata
|
||||
- [ ] You have a GitHub repository for socktop
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Verify Your Local Repository
|
||||
|
||||
First, make sure everything is ready:
|
||||
|
||||
```bash
|
||||
# Check that apt-repo exists and has content
|
||||
ls -la apt-repo/
|
||||
|
||||
# You should see:
|
||||
# - dists/stable/Release, Release.gpg, InRelease
|
||||
# - pool/main/*.deb
|
||||
# - KEY.gpg
|
||||
# - index.html, README.md
|
||||
```
|
||||
|
||||
### 2. Create and Switch to gh-pages Branch
|
||||
|
||||
```bash
|
||||
# Create a new orphan branch (no history from main)
|
||||
git checkout --orphan gh-pages
|
||||
|
||||
# Remove all files from staging
|
||||
git rm -rf .
|
||||
```
|
||||
|
||||
**Important:** This creates a completely separate branch. Don't worry - your main branch is safe!
|
||||
|
||||
### 3. Copy APT Repository to Root
|
||||
|
||||
```bash
|
||||
# Copy CONTENTS of apt-repo to root of gh-pages
|
||||
cp -r apt-repo/* .
|
||||
|
||||
# Remove the apt-repo directory itself
|
||||
rm -rf apt-repo
|
||||
|
||||
# Verify the structure
|
||||
ls -la
|
||||
|
||||
# You should see in the current directory:
|
||||
# - dists/
|
||||
# - pool/
|
||||
# - KEY.gpg
|
||||
# - index.html
|
||||
# - README.md
|
||||
```
|
||||
|
||||
**Why root?** GitHub Pages can only serve from:
|
||||
- `/` (root) - what we're doing
|
||||
- `/docs` directory
|
||||
- NOT from custom directories like `/apt-repo`
|
||||
|
||||
### 4. Commit and Push
|
||||
|
||||
```bash
|
||||
# Add all files
|
||||
git add .
|
||||
|
||||
# Commit
|
||||
git commit -m "Initialize APT repository for GitHub Pages"
|
||||
|
||||
# Push to gh-pages branch
|
||||
git push -u origin gh-pages
|
||||
```
|
||||
|
||||
### 5. Return to Main Branch
|
||||
|
||||
```bash
|
||||
# Switch back to your main development branch
|
||||
git checkout main
|
||||
|
||||
# Verify you're back on main
|
||||
git branch
|
||||
# Should show: * main
|
||||
```
|
||||
|
||||
### 6. Enable GitHub Pages
|
||||
|
||||
1. Go to your repository on GitHub
|
||||
2. Click **Settings** (top right)
|
||||
3. Click **Pages** (left sidebar)
|
||||
4. Under "Build and deployment":
|
||||
- Source: **Deploy from a branch**
|
||||
- Branch: **gh-pages**
|
||||
- Folder: **/ (root)**
|
||||
- Click **Save**
|
||||
|
||||
### 7. Wait for Deployment
|
||||
|
||||
GitHub will deploy your site. This usually takes 1-2 minutes.
|
||||
|
||||
You can watch the progress:
|
||||
- Go to **Actions** tab
|
||||
- Look for "pages build and deployment" workflow
|
||||
|
||||
### 8. Verify Your Repository is Live
|
||||
|
||||
Once deployed, your repository will be at:
|
||||
|
||||
```
|
||||
https://YOUR-USERNAME.github.io/socktop/
|
||||
```
|
||||
|
||||
Test it:
|
||||
|
||||
```bash
|
||||
# Check the public key is accessible
|
||||
curl -I https://YOUR-USERNAME.github.io/socktop/KEY.gpg
|
||||
|
||||
# Should return: HTTP/2 200
|
||||
|
||||
# Check the Release file
|
||||
curl -I https://YOUR-USERNAME.github.io/socktop/dists/stable/Release
|
||||
|
||||
# Should return: HTTP/2 200
|
||||
```
|
||||
|
||||
### 9. Test Installation (Optional but Recommended)
|
||||
|
||||
On a Debian/Ubuntu VM or system:
|
||||
|
||||
```bash
|
||||
# Add GPG key
|
||||
curl -fsSL https://YOUR-USERNAME.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
|
||||
# Add repository
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://YOUR-USERNAME.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
|
||||
# Update package lists
|
||||
sudo apt update
|
||||
|
||||
# You should see:
|
||||
# Get:1 https://YOUR-USERNAME.github.io/socktop stable InRelease [xxx B]
|
||||
|
||||
# Install packages
|
||||
sudo apt install socktop socktop-agent
|
||||
|
||||
# Verify
|
||||
socktop --version
|
||||
```
|
||||
|
||||
## Understanding the Two Branches
|
||||
|
||||
After setup, you'll have two branches:
|
||||
|
||||
### `main` branch (development)
|
||||
```
|
||||
main/
|
||||
├── src/
|
||||
├── Cargo.toml
|
||||
├── scripts/
|
||||
├── docs/
|
||||
├── apt-repo/ ← Local build artifact (not published)
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Purpose:** Source code, development, building packages
|
||||
|
||||
### `gh-pages` branch (published)
|
||||
```
|
||||
gh-pages/
|
||||
├── dists/
|
||||
├── pool/
|
||||
├── KEY.gpg
|
||||
├── index.html ← Customize this for a nice landing page!
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Purpose:** Published APT repository served by GitHub Pages
|
||||
|
||||
## Workflow Going Forward
|
||||
|
||||
### Manual Updates
|
||||
|
||||
When you release a new version:
|
||||
|
||||
```bash
|
||||
# 1. On main branch, build new packages
|
||||
git checkout main
|
||||
cargo deb --package socktop
|
||||
cargo deb --package socktop_agent
|
||||
|
||||
# 2. Update local apt-repo
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
|
||||
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
|
||||
./scripts/sign-apt-repo.sh apt-repo stable YOUR-GPG-KEY-ID
|
||||
|
||||
# 3. Switch to gh-pages and update
|
||||
git checkout gh-pages
|
||||
cp -r apt-repo/* .
|
||||
git add .
|
||||
git commit -m "Release v1.51.0"
|
||||
git push origin gh-pages
|
||||
|
||||
# 4. Return to main
|
||||
git checkout main
|
||||
```
|
||||
|
||||
### Automated Updates (Recommended)
|
||||
|
||||
Set up GitHub Actions to do this automatically:
|
||||
|
||||
1. Add GitHub Secrets (Settings → Secrets → Actions):
|
||||
- `GPG_PRIVATE_KEY` - Your exported private key
|
||||
- `GPG_KEY_ID` - Your GPG key ID
|
||||
- `GPG_PASSPHRASE` - Your GPG passphrase (if any)
|
||||
|
||||
2. Tag and push:
|
||||
```bash
|
||||
git tag v1.51.0
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
3. GitHub Actions will automatically:
|
||||
- Build packages for AMD64 and ARM64
|
||||
- Update apt-repo
|
||||
- Sign with your GPG key
|
||||
- Push to gh-pages
|
||||
- Create GitHub Release
|
||||
|
||||
See `.github/workflows/publish-apt-repo.yml` for details.
|
||||
|
||||
## Customizing Your GitHub Pages Site
|
||||
|
||||
The `gh-pages` branch contains `index.html` which users see when they visit:
|
||||
`https://YOUR-USERNAME.github.io/socktop/`
|
||||
|
||||
You can customize this! On the `gh-pages` branch:
|
||||
|
||||
```bash
|
||||
git checkout gh-pages
|
||||
|
||||
# Edit index.html
|
||||
nano index.html
|
||||
|
||||
# Add features, badges, screenshots, etc.
|
||||
|
||||
git add index.html
|
||||
git commit -m "Improve landing page"
|
||||
git push origin gh-pages
|
||||
|
||||
git checkout main
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "404 Not Found" on GitHub Pages
|
||||
|
||||
**Check:**
|
||||
- Settings → Pages shows "Your site is live at..."
|
||||
- Wait 2-3 minutes after pushing
|
||||
- Verify branch is `gh-pages` and folder is `/`
|
||||
- Check Actions tab for deployment errors
|
||||
|
||||
### "Repository not found" when installing
|
||||
|
||||
**Check:**
|
||||
- URL is correct: `https://USERNAME.github.io/REPO/` (no trailing /apt-repo)
|
||||
- Files exist at the URLs:
|
||||
```bash
|
||||
curl -I https://USERNAME.github.io/REPO/dists/stable/InRelease
|
||||
curl -I https://USERNAME.github.io/REPO/KEY.gpg
|
||||
```
|
||||
|
||||
### "GPG error" when installing
|
||||
|
||||
**Check:**
|
||||
- Repository is signed: `ls gh-pages/dists/stable/Release.gpg`
|
||||
- Users imported the key: `curl https://USERNAME.github.io/REPO/KEY.gpg | gpg --import`
|
||||
|
||||
### Changes not appearing
|
||||
|
||||
**Check:**
|
||||
- You committed and pushed to `gh-pages` (not `main`)
|
||||
- Wait 1-2 minutes for GitHub to redeploy
|
||||
- Clear browser cache if viewing index.html
|
||||
- For apt: `sudo apt clean && sudo apt update`
|
||||
|
||||
## Success Checklist
|
||||
|
||||
After completing this guide, you should have:
|
||||
|
||||
- [ ] `gh-pages` branch created and pushed
|
||||
- [ ] GitHub Pages enabled and deployed
|
||||
- [ ] Site accessible at `https://USERNAME.github.io/socktop/`
|
||||
- [ ] `KEY.gpg` downloadable
|
||||
- [ ] `dists/stable/InRelease` accessible
|
||||
- [ ] Packages in `pool/main/*.deb` downloadable
|
||||
- [ ] Successfully tested installation on a test system
|
||||
- [ ] Understand the workflow for future updates
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Update your main README.md** with installation instructions
|
||||
2. **Set up GitHub Actions** for automated releases
|
||||
3. **Customize index.html** on gh-pages for a nice landing page
|
||||
4. **Test on multiple architectures** (AMD64, ARM64)
|
||||
5. **Share your repository** with users
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Switch branches:**
|
||||
```bash
|
||||
git checkout main # Development
|
||||
git checkout gh-pages # Published site
|
||||
```
|
||||
|
||||
**Update published site manually:**
|
||||
```bash
|
||||
git checkout main
|
||||
# ... build packages, update apt-repo ...
|
||||
git checkout gh-pages
|
||||
cp -r apt-repo/* .
|
||||
git add . && git commit -m "Update" && git push
|
||||
git checkout main
|
||||
```
|
||||
|
||||
**Your repository URL:**
|
||||
```
|
||||
https://YOUR-USERNAME.github.io/socktop/
|
||||
```
|
||||
|
||||
**User installation command:**
|
||||
```bash
|
||||
curl -fsSL https://YOUR-USERNAME.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://YOUR-USERNAME.github.io/socktop stable main" | \
|
||||
sudo tee /etc/apt/sources.list.d/socktop.list
|
||||
sudo apt update && sudo apt install socktop socktop-agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Need help?** See:
|
||||
- `QUICK_START_APT_REPO.md` - Overall quick start
|
||||
- `docs/APT_REPOSITORY.md` - Comprehensive guide
|
||||
- `docs/APT_WORKFLOW.md` - Visual workflow diagrams
|
||||
@@ -1,119 +0,0 @@
|
||||
# Why We Use the gh-pages Branch
|
||||
|
||||
## The Problem
|
||||
|
||||
GitHub Pages has a limitation - it can only serve static sites from:
|
||||
|
||||
1. **`/` (root)** of a branch
|
||||
2. **`/docs`** directory of a branch
|
||||
3. **NOT** from custom directories like `/apt-repo`
|
||||
|
||||
## Why Not `/docs`?
|
||||
|
||||
When you tried to enable GitHub Pages with `apt-repo/` checked into main, you couldn't select it because:
|
||||
|
||||
```
|
||||
main/
|
||||
├── src/
|
||||
├── Cargo.toml
|
||||
├── apt-repo/ ← GitHub Pages can't serve from here!
|
||||
└── ...
|
||||
```
|
||||
|
||||
You could move it to `/docs`:
|
||||
```
|
||||
main/
|
||||
├── src/
|
||||
├── Cargo.toml
|
||||
├── docs/ ← GitHub Pages CAN serve from here
|
||||
│ ├── dists/
|
||||
│ ├── pool/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
But this has downsides:
|
||||
- ❌ Mixed source code and published content
|
||||
- ❌ Large .deb files bloat the main branch
|
||||
- ❌ Can't easily customize the site without affecting source
|
||||
- ❌ Messy git history with binary files
|
||||
|
||||
## Why gh-pages Branch (Our Solution)
|
||||
|
||||
Using a separate `gh-pages` branch is cleaner:
|
||||
|
||||
```
|
||||
main branch (source code):
|
||||
├── src/
|
||||
├── Cargo.toml
|
||||
├── scripts/
|
||||
└── docs/ ← Documentation source
|
||||
|
||||
gh-pages branch (published):
|
||||
├── dists/
|
||||
├── pool/
|
||||
├── KEY.gpg
|
||||
├── index.html ← Customizable landing page
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
✅ **Clean separation**: Source code stays in `main`, published content in `gh-pages`
|
||||
✅ **No binary bloat**: .deb files don't clutter your main branch history
|
||||
✅ **Easy automation**: GitHub Actions can push to gh-pages without affecting main
|
||||
✅ **Customizable**: You can make a beautiful landing page on gh-pages
|
||||
✅ **Standard practice**: Most GitHub Pages projects use gh-pages branch
|
||||
✅ **Root URL**: Your repo is at `https://username.github.io/socktop/` (not `/apt-repo`)
|
||||
|
||||
### Workflow
|
||||
|
||||
```
|
||||
Developer (main branch)
|
||||
↓
|
||||
Build packages
|
||||
↓
|
||||
Update apt-repo/ (local)
|
||||
↓
|
||||
Push to gh-pages branch
|
||||
↓
|
||||
GitHub Pages serves
|
||||
↓
|
||||
Users: apt install socktop
|
||||
```
|
||||
|
||||
## The Setup
|
||||
|
||||
**One-time setup:**
|
||||
```bash
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf .
|
||||
cp -r apt-repo/* .
|
||||
rm -rf apt-repo
|
||||
git add . && git commit -m "Initialize APT repository"
|
||||
git push -u origin gh-pages
|
||||
git checkout main
|
||||
```
|
||||
|
||||
**Going forward:**
|
||||
- Work on `main` branch for development
|
||||
- `gh-pages` branch gets updated by GitHub Actions (or manually)
|
||||
- Never need to switch branches manually after automation is set up!
|
||||
|
||||
## Comparison
|
||||
|
||||
| Approach | Location | URL | Pros | Cons |
|
||||
|----------|----------|-----|------|------|
|
||||
| **gh-pages branch** ✅ | gh-pages:/ | `username.github.io/socktop/` | Clean, automated, customizable | Two branches |
|
||||
| `/docs` on main | main:/docs | `username.github.io/socktop/` | One branch | Mixed content, binary bloat |
|
||||
| `/apt-repo` on main | main:/apt-repo | ❌ Not possible | - | GitHub Pages won't allow it |
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `gh-pages` branch approach is:
|
||||
- The **cleanest** solution
|
||||
- The **most flexible** for customization
|
||||
- The **easiest to automate**
|
||||
- **Industry standard** for GitHub Pages
|
||||
|
||||
That's why we chose it! 🚀
|
||||
@@ -1,43 +0,0 @@
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf riscv64
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: Mon, 24 Nov 2025 20:01:32 +0000
|
||||
MD5Sum:
|
||||
0b1110782420dd7941940d8f99adda34 1627 main/binary-amd64/Packages
|
||||
7c1447cf37137d7a72534cea3cb3872f 836 main/binary-amd64/Packages.gz
|
||||
1e8f176ddd62df4bed3d11aa9673d5f1 1627 main/binary-arm64/Packages
|
||||
165f010f2f2e85775e602bbfc32b9a67 832 main/binary-arm64/Packages.gz
|
||||
0c17d836ddf76547285336b1a9948daf 1610 main/binary-armhf/Packages
|
||||
6ee29ba21442c21c3a1ad89aaa1bcdf6 820 main/binary-armhf/Packages.gz
|
||||
1fc7e24923a509ea722c2d0d5189700a 1623 main/binary-riscv64/Packages
|
||||
6fc7db17b307382c4042cd7fab03010a 820 main/binary-riscv64/Packages.gz
|
||||
SHA256:
|
||||
eb77b0c29f6d909a0e26596ff5c897ad32c45fa0c95d14f435e68264e2f02024 1627 main/binary-amd64/Packages
|
||||
e045e3303e653094ad828aa11adb0fd7194f2fe422b99cd0ee0a1c2100d86544 836 main/binary-amd64/Packages.gz
|
||||
7054a62c0a4cdda4be5a264809c358a83f8323b388d43880d73557588c20fe15 1627 main/binary-arm64/Packages
|
||||
d87f2cc66015a7d6f7b6755330af0003fa444d27229eb6127df00ab847b9cd53 832 main/binary-arm64/Packages.gz
|
||||
d17d55bd46dc1955b09615b3bb7ce009f34e277b1b11e131b340afc77157e554 1610 main/binary-armhf/Packages
|
||||
53e37cd254c6f96272f7b9f83e2ad86e2013be1a3e4061a35592c795102aa087 820 main/binary-armhf/Packages.gz
|
||||
a43bb5de1b4b43fbd02e141db8c0134558b4750f9c2835f468cb731701103ba6 1623 main/binary-riscv64/Packages
|
||||
1d0be4332d0794b3a25ea81774690030276fb791f47eaf07758041183079807d 820 main/binary-riscv64/Packages.gz
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAEBCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkkuZ4ACgkQESwaeYRl
|
||||
+/LaawwAv9CIkkuqc7bvgyLNNVf2GEi0UV3I/DBg0YX47gKI2u4wp760sf53BYkk
|
||||
wmYZkPcQFFAICVEY7j/WqvnRBkNb67sF7eS4p14IW9UB4GAMs5U0k6737VIwp47G
|
||||
AUbuqZKv3kRk/x7XeZgmqnipXtSqlfVct2dx1+53yGqnwdSywpU9Ns64Iod4/lLQ
|
||||
ipXkKOmb+SdGM90uv4lQ+BdleRykqb8cC761LKJEYZEByUal95woBW8cp9EycJXu
|
||||
yqqAX21Bgx8YC7aF0Z61e9BYlUGXnxFwz4pn0hwvLli8X5dJEdUxWnuto+b/5HAk
|
||||
ql7gicDHfxOKOUv2qQFsShsybJm4axWMBqMudDohZTtylfp0rgAQX8Tr6U1u303a
|
||||
1kmU8v0Ph/XOfZT1gGnHfEn6RlOmDfaSRqcF2USyZTzrPgo9g8EKCiBuZm+Som9a
|
||||
VjQmvdRHiJ0K84bfNpjFW4aJo0xE2EQR7xS/RXCA0SQ9YhFrxBxYr5TxWM0N0C6j
|
||||
LCsp4O4A
|
||||
=Jzma
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -1,26 +0,0 @@
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Suite: stable
|
||||
Codename: stable
|
||||
Architectures: amd64 arm64 armhf riscv64
|
||||
Components: main
|
||||
Description: socktop APT repository
|
||||
Date: Mon, 24 Nov 2025 20:01:32 +0000
|
||||
MD5Sum:
|
||||
0b1110782420dd7941940d8f99adda34 1627 main/binary-amd64/Packages
|
||||
7c1447cf37137d7a72534cea3cb3872f 836 main/binary-amd64/Packages.gz
|
||||
1e8f176ddd62df4bed3d11aa9673d5f1 1627 main/binary-arm64/Packages
|
||||
165f010f2f2e85775e602bbfc32b9a67 832 main/binary-arm64/Packages.gz
|
||||
0c17d836ddf76547285336b1a9948daf 1610 main/binary-armhf/Packages
|
||||
6ee29ba21442c21c3a1ad89aaa1bcdf6 820 main/binary-armhf/Packages.gz
|
||||
1fc7e24923a509ea722c2d0d5189700a 1623 main/binary-riscv64/Packages
|
||||
6fc7db17b307382c4042cd7fab03010a 820 main/binary-riscv64/Packages.gz
|
||||
SHA256:
|
||||
eb77b0c29f6d909a0e26596ff5c897ad32c45fa0c95d14f435e68264e2f02024 1627 main/binary-amd64/Packages
|
||||
e045e3303e653094ad828aa11adb0fd7194f2fe422b99cd0ee0a1c2100d86544 836 main/binary-amd64/Packages.gz
|
||||
7054a62c0a4cdda4be5a264809c358a83f8323b388d43880d73557588c20fe15 1627 main/binary-arm64/Packages
|
||||
d87f2cc66015a7d6f7b6755330af0003fa444d27229eb6127df00ab847b9cd53 832 main/binary-arm64/Packages.gz
|
||||
d17d55bd46dc1955b09615b3bb7ce009f34e277b1b11e131b340afc77157e554 1610 main/binary-armhf/Packages
|
||||
53e37cd254c6f96272f7b9f83e2ad86e2013be1a3e4061a35592c795102aa087 820 main/binary-armhf/Packages.gz
|
||||
a43bb5de1b4b43fbd02e141db8c0134558b4750f9c2835f468cb731701103ba6 1623 main/binary-riscv64/Packages
|
||||
1d0be4332d0794b3a25ea81774690030276fb791f47eaf07758041183079807d 820 main/binary-riscv64/Packages.gz
|
||||
@@ -1,14 +0,0 @@
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQGzBAABCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkkuZwACgkQESwaeYRl
|
||||
+/LkCQv+M3ceDIfGIJYN5PoDJjE5ON2RuOg+GQscz44qcOwrXxj2E76LpAhkjbrC
|
||||
RFQOHPp2harTDLAQ5b1PcxCy7DygTYgyFVXxn3bqf5NwzXFDHGzVMhFHLBqDr7e7
|
||||
rkG6k2H6OMUV4SLhx6XQMp74fMP3e4qvKiZRP0LPn2ZiQcnh5CLKRDWwPhC8GWHM
|
||||
Fh053Drg8bkWY/qG4070FVfQU/Os5w65pS9knDPe+1AFC9Rl7glNYtcMVPO6psvX
|
||||
2UgvSCZ5McFZJt+eQceWDFIK6Zl0gJ5YEFVsIPug93x3EEdXXYL5UfosaYeSS1L8
|
||||
g1ATdNA3otvPYvcwOVo/USjwwQ9OODb3tQLlp8NynOJ+v9oTju22RXLaa4iwt7d/
|
||||
qk+bfmxfZTjEb0dz92SKGtIHwTSzDUAxb7kvpQLtQ3utGuH/46ozxJQ0FUoOLATr
|
||||
pp2n7aCmgWupuRybB6U9tQNICydOGbY5lBrmzQC3/dR2IdayCuRhKqiGe5z10KCa
|
||||
gu7LGncL
|
||||
=1a8X
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -1,40 +0,0 @@
|
||||
Package: socktop
|
||||
Version: 1.50.0-1
|
||||
Architecture: amd64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 3447
|
||||
Depends: libc6 (>= 2.39)
|
||||
Filename: pool/main/socktop_1.50.0-1_amd64.deb
|
||||
Size: 1277472
|
||||
MD5sum: 8af32694d8ea66feb97bc9896dd1034f
|
||||
SHA1: acf596897449d1ec3a3eaee7c7fe25e716d91a8c
|
||||
SHA256: 59ade1d2cc919fa672c2b8b50a05905defc1a220776949b78743d054eaa07994
|
||||
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: 6726
|
||||
Depends: libc6 (>= 2.39), libdrm-amdgpu1 (>= 2.4.80)
|
||||
Filename: pool/main/socktop-agent_1.50.2-1_amd64.deb
|
||||
Size: 1869464
|
||||
MD5sum: b708dbc7330e8c8f2d2d6812f978f3c9
|
||||
SHA1: 2fce3376934586a630031f9a6dc4ed196c37200e
|
||||
SHA256: 4a1d7e9794048b6dc0a74a428d1536903c9c3e3ed8b52d53539f1ecb4ebc3967
|
||||
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.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
Archive: stable
|
||||
Component: main
|
||||
Origin: socktop
|
||||
Label: socktop
|
||||
Architecture: amd64
|
||||
@@ -1,40 +0,0 @@
|
||||
Package: socktop
|
||||
Version: 1.50.0-1
|
||||
Architecture: arm64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 2908
|
||||
Depends: libc6 (>= 2.39)
|
||||
Filename: pool/main/socktop_1.50.0-1_arm64.deb
|
||||
Size: 1139764
|
||||
MD5sum: 05fa53ec3555238b6454ffe44acba4a5
|
||||
SHA1: c6f062f0d191e45d34e2d349864dfa552433bd0c
|
||||
SHA256: 976de361b1f867e5c3fe1b8bba34862f6e92564980367a1b5a9ff024c7825273
|
||||
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: arm64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 5220
|
||||
Depends: libc6 (>= 2.39), libdrm-amdgpu1 (>= 2.4.80)
|
||||
Filename: pool/main/socktop-agent_1.50.2-1_arm64.deb
|
||||
Size: 1645464
|
||||
MD5sum: 517f46814ffc34ca3075ce0fc6020f1f
|
||||
SHA1: a120d9b763b453c0e860fea8e3f121e4cabda329
|
||||
SHA256: 72d562c50f4de437c5e8f46fb79cc0216b96f5bdc2b96958a51ae69aa156eead
|
||||
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.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
Package: socktop
|
||||
Version: 1.50.0-1
|
||||
Architecture: armhf
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 2706
|
||||
Depends: libc6:armhf (>= 2.39)
|
||||
Filename: pool/main/socktop_1.50.0-1_armhf.deb
|
||||
Size: 986508
|
||||
MD5sum: 5872abb834d52fefd05d3be848c0c466
|
||||
SHA1: c2cd0f4bc1578541836f44c8595a90b07815448f
|
||||
SHA256: c246bd1fbad3598129dd3101296791aa75d0d817d2a9b33ea18e21b95712bdeb
|
||||
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: armhf
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 3919
|
||||
Depends: libc6:armhf (>= 2.39)
|
||||
Filename: pool/main/socktop-agent_1.50.2-1_armhf.deb
|
||||
Size: 1494848
|
||||
MD5sum: 90aae9922ffe58b9685c10e38cdb91f0
|
||||
SHA1: f8aab5171952a9a5b1251a00e907ae6805286edd
|
||||
SHA256: 6c1f813a899416243a8c6d11dd424832a8a7184e3da200ba72e3afe23cb5fcd6
|
||||
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.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
Package: socktop
|
||||
Version: 1.50.0-1
|
||||
Architecture: riscv64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 2664
|
||||
Depends: libc6:riscv64 (>= 2.39)
|
||||
Filename: pool/main/socktop_1.50.0-1_riscv64.deb
|
||||
Size: 1133776
|
||||
MD5sum: 8d35cb4a2e61e4817bedd36362f3d21e
|
||||
SHA1: 242c3818e20e4fa8ab2fd6bd9a897aab0246c01d
|
||||
SHA256: c8138d798820fc0bcdcf12a945f35b6a45635cbec109548ce65cb4ab3db7690a
|
||||
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: riscv64
|
||||
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
|
||||
Installed-Size: 4068
|
||||
Depends: libc6:riscv64 (>= 2.39)
|
||||
Filename: pool/main/socktop-agent_1.50.2-1_riscv64.deb
|
||||
Size: 1698504
|
||||
MD5sum: 76249497cbd2bb14e86b0a680a628d22
|
||||
SHA1: 1b427d86313d2d906886f33f5f274b6515958245
|
||||
SHA256: 52f17798f0a208a5067a266fad84e535c30137620273a218caac465fb969f48e
|
||||
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.
|
||||
|
||||
|
After Width: | Height: | Size: 775 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Socktop agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/socktop_agent --port 3000
|
||||
Environment=RUST_LOG=info
|
||||
# Optional auth:
|
||||
# Environment=SOCKTOP_TOKEN=changeme
|
||||
Restart=on-failure
|
||||
User=socktop
|
||||
Group=socktop
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
@@ -1,364 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>socktop APT Repository</title>
|
||||
<style>
|
||||
/* Catppuccin Frappe Color Palette */
|
||||
:root {
|
||||
--ctp-base: #303446;
|
||||
--ctp-mantle: #292c3c;
|
||||
--ctp-crust: #232634;
|
||||
--ctp-text: #c6d0f5;
|
||||
--ctp-subtext1: #b5bfe2;
|
||||
--ctp-subtext0: #a5adce;
|
||||
--ctp-overlay2: #949cbb;
|
||||
--ctp-overlay1: #838ba7;
|
||||
--ctp-overlay0: #737994;
|
||||
--ctp-surface2: #626880;
|
||||
--ctp-surface1: #51576d;
|
||||
--ctp-surface0: #414559;
|
||||
--ctp-lavender: #babbf1;
|
||||
--ctp-blue: #8caaee;
|
||||
--ctp-sapphire: #85c1dc;
|
||||
--ctp-sky: #99d1db;
|
||||
--ctp-teal: #81c8be;
|
||||
--ctp-green: #a6d189;
|
||||
--ctp-yellow: #e5c890;
|
||||
--ctp-peach: #ef9f76;
|
||||
--ctp-maroon: #ea999c;
|
||||
--ctp-red: #e78284;
|
||||
--ctp-mauve: #ca9ee6;
|
||||
--ctp-pink: #f4b8e4;
|
||||
--ctp-flamingo: #eebebe;
|
||||
--ctp-rosewater: #f2d5cf;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background-color: var(--ctp-mantle);
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--ctp-blue);
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 3px solid var(--ctp-surface0);
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
h2 {
|
||||
color: var(--ctp-mauve);
|
||||
font-size: 1.8em;
|
||||
margin-top: 35px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
h3 {
|
||||
color: var(--ctp-sapphire);
|
||||
font-size: 1.3em;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--ctp-surface0);
|
||||
color: var(--ctp-green);
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
pre {
|
||||
background-color: var(--ctp-crust);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
position: relative;
|
||||
padding-top: 18px; /* leave space for top-right button */
|
||||
}
|
||||
pre code {
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
display: block;
|
||||
white-space: pre;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Copy button styles */
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
transform 0.08s ease;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: var(--ctp-surface2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.copy-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.copy-btn.copied {
|
||||
background: var(--ctp-green);
|
||||
color: var(--ctp-crust);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background-color: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
margin: 5px 5px 5px 0;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
}
|
||||
.badge.arch {
|
||||
background-color: var(--ctp-surface0);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.note {
|
||||
background-color: var(--ctp-surface0);
|
||||
border-left: 4px solid var(--ctp-yellow);
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.note strong {
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 50px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid var(--ctp-surface0);
|
||||
text-align: center;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.command-comment {
|
||||
color: var(--ctp-overlay1);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.highlight-blue {
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
.highlight-green {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
.highlight-yellow {
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
.highlight-mauve {
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
.highlight-peach {
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 25px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>socktop APT Repository</h1>
|
||||
<p class="subtitle">
|
||||
System monitor with remote agent support for Linux systems
|
||||
</p>
|
||||
|
||||
<h2>📦 Quick Installation</h2>
|
||||
<p>Add this repository to your Debian/Ubuntu system:</p>
|
||||
|
||||
<h3>Step 1: Add GPG Key</h3>
|
||||
<pre><code># Add the repository's GPG signing key
|
||||
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \
|
||||
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg</code></pre>
|
||||
|
||||
<h3>Step 2: Add Repository</h3>
|
||||
<pre><code># Add the APT repository to your sources
|
||||
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</code></pre>
|
||||
|
||||
<h3>Step 3: Install</h3>
|
||||
<pre><code># Update package lists and install
|
||||
sudo apt update
|
||||
sudo apt install socktop socktop-agent</code></pre>
|
||||
|
||||
<h2>📋 What's Included</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong class="highlight-blue">socktop</strong> - Terminal
|
||||
UI client for monitoring systems
|
||||
</li>
|
||||
<li>
|
||||
<strong class="highlight-mauve">socktop-agent</strong> -
|
||||
Background agent that reports system metrics
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> The agent package automatically installs
|
||||
and configures a systemd service. Enable it with:
|
||||
<code>sudo systemctl enable --now socktop-agent</code>
|
||||
</div>
|
||||
|
||||
<h2>🏗️ Supported Architectures</h2>
|
||||
<div>
|
||||
<span class="badge arch">amd64</span>
|
||||
<span class="badge arch">arm64</span>
|
||||
<span class="badge arch">armhf</span>
|
||||
<span class="badge arch">riscv64</span>
|
||||
</div>
|
||||
|
||||
<h2>🔧 Usage</h2>
|
||||
<p>After installation:</p>
|
||||
<pre><code># Start the TUI client
|
||||
socktop
|
||||
|
||||
# Connect to a remote agent
|
||||
socktop ws://hostname:3000
|
||||
|
||||
# Start the agent (if not using systemd)
|
||||
socktop_agent</code></pre>
|
||||
|
||||
<h2>🔗 Links</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/jasonwitty/socktop"
|
||||
>Source Code on GitHub</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/jasonwitty/socktop/issues"
|
||||
>Report Issues</a
|
||||
>
|
||||
</li>
|
||||
<li><a href="README.md">Repository Documentation</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="footer">
|
||||
<p>Hosted on GitHub Pages | Packages signed with GPG</p>
|
||||
<p>
|
||||
Theme:
|
||||
<a href="https://github.com/catppuccin/catppuccin"
|
||||
>Catppuccin Frappe</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Attach copy buttons to all <pre> blocks and enable copy-to-clipboard.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const pres = document.querySelectorAll("pre");
|
||||
pres.forEach((pre) => {
|
||||
// Create button
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "copy-btn";
|
||||
btn.setAttribute("aria-label", "Copy code to clipboard");
|
||||
// Use emoji for simplicity; you can replace with SVG if desired
|
||||
btn.innerText = "📋";
|
||||
|
||||
// Append to pre
|
||||
pre.appendChild(btn);
|
||||
|
||||
// Click handler
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const code = pre.querySelector("code");
|
||||
const text = code ? code.innerText : pre.innerText;
|
||||
try {
|
||||
if (!navigator.clipboard) {
|
||||
// Fallback method
|
||||
const textarea =
|
||||
document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
// feedback
|
||||
btn.classList.add("copied");
|
||||
const prior = btn.innerText;
|
||||
btn.innerText = "✓ Copied";
|
||||
setTimeout(() => {
|
||||
btn.classList.remove("copied");
|
||||
btn.innerText = "📋";
|
||||
}, 1800);
|
||||
} catch (err) {
|
||||
btn.innerText = "✖";
|
||||
setTimeout(() => (btn.innerText = "📋"), 1500);
|
||||
console.error("Copy failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
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,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["clippy", "rustfmt"]
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "socktop"
|
||||
version = "0.1.3"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
description = "Remote system monitor over WebSocket, TUI like top"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
dirs-next = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2.1"
|
||||
prost = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
protoc-bin-vendored = "3"
|
||||
@@ -0,0 +1,14 @@
|
||||
fn main() {
|
||||
// Vendored protoc for reproducible builds (works on crates.io build machines)
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||
std::env::set_var("PROTOC", &protoc);
|
||||
|
||||
// Tell Cargo when to re-run
|
||||
println!("cargo:rerun-if-changed=proto/processes.proto");
|
||||
|
||||
let mut cfg = prost_build::Config::new();
|
||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||
// Use in-crate relative path so `cargo package` includes the file
|
||||
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // paths relative to CARGO_MANIFEST_DIR
|
||||
.expect("compile protos");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! App state and main loop: input handling, fetching metrics, updating history, and drawing.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Rect},
|
||||
//style::Color, // + add Color
|
||||
Terminal,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::history::{push_capped, PerCoreHistory};
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::cpu::{
|
||||
draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp, per_core_content_area,
|
||||
per_core_handle_key, per_core_handle_mouse, per_core_handle_scrollbar_mouse, PerCoreScrollDrag,
|
||||
};
|
||||
use crate::ui::processes::{processes_handle_key, processes_handle_mouse, ProcSortBy};
|
||||
use crate::ui::{
|
||||
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
|
||||
swap::draw_swap,
|
||||
};
|
||||
use crate::ws::{connect, request_disks, request_metrics, request_processes};
|
||||
|
||||
pub struct App {
|
||||
// Latest metrics + histories
|
||||
last_metrics: Option<Metrics>,
|
||||
|
||||
// CPU avg history (0..100)
|
||||
cpu_hist: VecDeque<u64>,
|
||||
|
||||
// Per-core history (0..100)
|
||||
per_core_hist: PerCoreHistory,
|
||||
|
||||
// Network totals snapshot + histories of KB/s
|
||||
last_net_totals: Option<(u64, u64, Instant)>,
|
||||
rx_hist: VecDeque<u64>,
|
||||
tx_hist: VecDeque<u64>,
|
||||
rx_peak: u64,
|
||||
tx_peak: u64,
|
||||
|
||||
// Quit flag
|
||||
should_quit: bool,
|
||||
|
||||
pub per_core_scroll: usize,
|
||||
pub per_core_drag: Option<PerCoreScrollDrag>, // new: drag state
|
||||
pub procs_scroll_offset: usize,
|
||||
pub procs_drag: Option<PerCoreScrollDrag>,
|
||||
pub procs_sort_by: ProcSortBy,
|
||||
last_procs_area: Option<ratatui::layout::Rect>,
|
||||
|
||||
last_procs_poll: Instant,
|
||||
last_disks_poll: Instant,
|
||||
procs_interval: Duration,
|
||||
disks_interval: Duration,
|
||||
metrics_interval: Duration,
|
||||
|
||||
// For reconnects
|
||||
ws_url: String,
|
||||
// Security / status flags
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> 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(),
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
|
||||
if let Some(m) = metrics_ms {
|
||||
self.metrics_interval = Duration::from_millis(m.max(100));
|
||||
}
|
||||
if let Some(p) = procs_ms {
|
||||
self.procs_interval = Duration::from_millis(p.max(200));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_status(mut self, is_tls: bool, has_token: bool) -> Self {
|
||||
self.is_tls = is_tls;
|
||||
self.has_token = has_token;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&mut self,
|
||||
url: &str,
|
||||
tls_ca: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Connect to agent
|
||||
//let mut ws = connect(url, tls_ca).await?;
|
||||
self.ws_url = url.to_string();
|
||||
let mut ws = connect(url, tls_ca).await?;
|
||||
|
||||
// Terminal setup
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
// Main loop
|
||||
let res = self.event_loop(&mut terminal, &mut ws).await;
|
||||
|
||||
// Teardown
|
||||
disable_raw_mode()?;
|
||||
let backend = terminal.backend_mut();
|
||||
execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
async fn event_loop<B: ratatui::backend::Backend>(
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
ws: &mut crate::ws::WsStream,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
// Input (non-blocking)
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
match event::read()? {
|
||||
Event::Key(k) => {
|
||||
if matches!(
|
||||
k.code,
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
|
||||
) {
|
||||
self.should_quit = true;
|
||||
}
|
||||
// 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);
|
||||
let rows = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(10),
|
||||
])
|
||||
.split(area);
|
||||
let top = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.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);
|
||||
|
||||
let total_rows = self
|
||||
.last_metrics
|
||||
.as_ref()
|
||||
.map(|mm| mm.cpu_per_core.len())
|
||||
.unwrap_or(0);
|
||||
per_core_clamp(
|
||||
&mut self.per_core_scroll,
|
||||
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) => {
|
||||
// Layout to get areas
|
||||
let sz = terminal.size()?;
|
||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||
let rows = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(10),
|
||||
])
|
||||
.split(area);
|
||||
let top = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[1]);
|
||||
|
||||
// Content wheel scrolling
|
||||
let content = per_core_content_area(top[1]);
|
||||
per_core_handle_mouse(
|
||||
&mut self.per_core_scroll,
|
||||
m,
|
||||
content,
|
||||
content.height as usize,
|
||||
);
|
||||
|
||||
// Scrollbar clicks/drag
|
||||
let total_rows = self
|
||||
.last_metrics
|
||||
.as_ref()
|
||||
.map(|mm| mm.cpu_per_core.len())
|
||||
.unwrap_or(0);
|
||||
per_core_handle_scrollbar_mouse(
|
||||
&mut self.per_core_scroll,
|
||||
&mut self.per_core_drag,
|
||||
m,
|
||||
top[1],
|
||||
total_rows,
|
||||
);
|
||||
|
||||
// Clamp to bounds
|
||||
per_core_clamp(
|
||||
&mut self.per_core_scroll,
|
||||
total_rows,
|
||||
content.height as usize,
|
||||
);
|
||||
|
||||
// Processes table: sort by column on header click
|
||||
if let (Some(mm), Some(p_area)) =
|
||||
(self.last_metrics.as_ref(), self.last_procs_area)
|
||||
{
|
||||
if 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Resize(_, _) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.should_quit {
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch and update
|
||||
if let Some(m) = request_metrics(ws).await {
|
||||
self.update_with_metrics(m);
|
||||
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
if let Some(procs) = request_processes(ws).await {
|
||||
if let Some(mm) = self.last_metrics.as_mut() {
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
}
|
||||
}
|
||||
self.last_procs_poll = Instant::now();
|
||||
}
|
||||
|
||||
// Only poll disks every 5s
|
||||
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
||||
if let Some(disks) = request_disks(ws).await {
|
||||
if let Some(mm) = self.last_metrics.as_mut() {
|
||||
mm.disks = disks;
|
||||
}
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw
|
||||
terminal.draw(|f| self.draw(f))?;
|
||||
|
||||
// Tick rate
|
||||
sleep(self.metrics_interval).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 m.disks.is_empty() {
|
||||
m.disks = prev.disks.clone();
|
||||
}
|
||||
if m.top_processes.is_empty() {
|
||||
m.top_processes = prev.top_processes.clone();
|
||||
}
|
||||
// Preserve total processes count across fast updates
|
||||
if m.process_count.is_none() {
|
||||
m.process_count = prev.process_count;
|
||||
}
|
||||
}
|
||||
|
||||
// CPU avg history
|
||||
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.cpu_hist, v, 600);
|
||||
|
||||
// Per-core history (push current samples)
|
||||
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
||||
self.per_core_hist.push_samples(&m.cpu_per_core);
|
||||
|
||||
// NET: sum across all ifaces, compute KB/s via elapsed time
|
||||
let now = Instant::now();
|
||||
let rx_total = m.networks.iter().map(|n| n.received).sum::<u64>();
|
||||
let tx_total = m.networks.iter().map(|n| n.transmitted).sum::<u64>();
|
||||
let (rx_kb, tx_kb) = if let Some((prx, ptx, pts)) = self.last_net_totals {
|
||||
let dt = now.duration_since(pts).as_secs_f64().max(1e-6);
|
||||
let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64;
|
||||
let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64;
|
||||
(rx, tx)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
self.last_net_totals = Some((rx_total, tx_total, now));
|
||||
push_capped(&mut self.rx_hist, rx_kb, 600);
|
||||
push_capped(&mut self.tx_hist, tx_kb, 600);
|
||||
self.rx_peak = self.rx_peak.max(rx_kb);
|
||||
self.tx_peak = self.tx_peak.max(tx_kb);
|
||||
|
||||
// Store merged snapshot
|
||||
self.last_metrics = Some(m);
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
|
||||
let area = f.area();
|
||||
|
||||
// Root rows: header, top (cpu avg + per-core), memory, swap, bottom
|
||||
let rows = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Ratio(1, 3), // top row
|
||||
Constraint::Length(3), // memory (left) + GPU (right, part 1)
|
||||
Constraint::Length(3), // swap (left) + GPU (right, part 2)
|
||||
Constraint::Min(10), // bottom: disks + net (left), top procs (right)
|
||||
])
|
||||
.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,
|
||||
);
|
||||
|
||||
// Top row: left CPU avg, right Per-core (full top-right)
|
||||
let top_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.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_per_core_bars(
|
||||
f,
|
||||
top_lr[1],
|
||||
self.last_metrics.as_ref(),
|
||||
&self.per_core_hist,
|
||||
self.per_core_scroll,
|
||||
);
|
||||
|
||||
// Memory + Swap rows split into left/right columns
|
||||
let mem_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[2]);
|
||||
let swap_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[3]);
|
||||
|
||||
// Left: Memory + Swap
|
||||
draw_mem(f, mem_lr[0], self.last_metrics.as_ref());
|
||||
draw_swap(f, swap_lr[0], self.last_metrics.as_ref());
|
||||
|
||||
// Right: GPU spans the same vertical space as Memory + Swap
|
||||
let gpu_area = ratatui::layout::Rect {
|
||||
x: mem_lr[1].x,
|
||||
y: mem_lr[1].y,
|
||||
width: mem_lr[1].width,
|
||||
height: mem_lr[1].height + swap_lr[1].height,
|
||||
};
|
||||
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
|
||||
|
||||
// Bottom area: left = Disks + Network, right = Top Processes
|
||||
let bottom_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(rows[4]);
|
||||
|
||||
// Left bottom: Disks + Net stacked (make net panes slightly taller)
|
||||
let left_stack = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(4), // Disks shrink slightly
|
||||
Constraint::Length(5), // Download taller
|
||||
Constraint::Length(5), // Upload taller
|
||||
])
|
||||
.split(bottom_lr[0]);
|
||||
|
||||
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
|
||||
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,
|
||||
ratatui::style::Color::Green,
|
||||
);
|
||||
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,
|
||||
ratatui::style::Color::Blue,
|
||||
);
|
||||
|
||||
// Right bottom: Top Processes fills the column
|
||||
let procs_area = bottom_lr[1];
|
||||
// Cache for input handlers
|
||||
self.last_procs_area = Some(procs_area);
|
||||
crate::ui::processes::draw_top_processes(
|
||||
f,
|
||||
procs_area,
|
||||
self.last_metrics.as_ref(),
|
||||
self.procs_scroll_offset,
|
||||
self.procs_sort_by,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Small utilities to manage bounded history buffers for charts.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||
if dq.len() == cap {
|
||||
dq.pop_front();
|
||||
}
|
||||
dq.push_back(v);
|
||||
}
|
||||
|
||||
// Keeps a history deque per core with a fixed capacity
|
||||
pub struct PerCoreHistory {
|
||||
pub deques: Vec<VecDeque<u16>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl PerCoreHistory {
|
||||
pub fn new(cap: usize) -> Self {
|
||||
Self {
|
||||
deques: Vec::new(),
|
||||
cap,
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have one deque per core; resize on CPU topology changes
|
||||
pub fn ensure_cores(&mut self, n: usize) {
|
||||
if self.deques.len() == n {
|
||||
return;
|
||||
}
|
||||
self.deques = (0..n).map(|_| VecDeque::with_capacity(self.cap)).collect();
|
||||
}
|
||||
|
||||
// Push a new sample set for all cores (values 0..=100)
|
||||
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;
|
||||
push_capped(&mut self.deques[i], val, self.cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Library surface for integration tests and reuse.
|
||||
|
||||
pub mod types;
|
||||
pub mod ws;
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Entry point for the socktop TUI. Parses args and runs the App.
|
||||
|
||||
mod app;
|
||||
mod history;
|
||||
mod profiles;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod ws;
|
||||
|
||||
use app::App;
|
||||
use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, ResolveProfile};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
|
||||
pub(crate) struct ParsedArgs {
|
||||
url: Option<String>,
|
||||
tls_ca: Option<String>,
|
||||
profile: Option<String>,
|
||||
save: bool,
|
||||
demo: bool,
|
||||
dry_run: bool, // hidden test helper: skip connecting
|
||||
metrics_interval_ms: Option<u64>,
|
||||
processes_interval_ms: Option<u64>,
|
||||
verify_hostname: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||
let mut it = args.into_iter();
|
||||
let prog = it.next().unwrap_or_else(|| "socktop".into());
|
||||
let mut url: Option<String> = None;
|
||||
let mut tls_ca: Option<String> = None;
|
||||
let mut profile: Option<String> = None;
|
||||
let mut save = false;
|
||||
let mut demo = false;
|
||||
let mut dry_run = false;
|
||||
let mut metrics_interval_ms: Option<u64> = None;
|
||||
let mut processes_interval_ms: Option<u64> = None;
|
||||
let mut verify_hostname = false;
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => {
|
||||
return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"));
|
||||
}
|
||||
"--tls-ca" | "-t" => {
|
||||
tls_ca = it.next();
|
||||
}
|
||||
"--verify-hostname" => {
|
||||
// opt-in hostname (SAN) verification
|
||||
// default behavior is to skip it for easier home network usage
|
||||
// (still pins the provided certificate)
|
||||
verify_hostname = true;
|
||||
}
|
||||
"--profile" | "-P" => {
|
||||
profile = it.next();
|
||||
}
|
||||
"--save" => {
|
||||
save = true;
|
||||
}
|
||||
"--demo" => {
|
||||
demo = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
// intentionally undocumented
|
||||
dry_run = true;
|
||||
}
|
||||
"--metrics-interval-ms" => {
|
||||
metrics_interval_ms = it.next().and_then(|v| v.parse().ok());
|
||||
}
|
||||
"--processes-interval-ms" => {
|
||||
processes_interval_ms = it.next().and_then(|v| v.parse().ok());
|
||||
}
|
||||
_ if arg.starts_with("--tls-ca=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
if !v.is_empty() {
|
||||
tls_ca = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--profile=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
if !v.is_empty() {
|
||||
profile = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--metrics-interval-ms=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
metrics_interval_ms = v.parse().ok();
|
||||
}
|
||||
}
|
||||
_ if arg.starts_with("--processes-interval-ms=") => {
|
||||
if let Some((_, v)) = arg.split_once('=') {
|
||||
processes_interval_ms = v.parse().ok();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if url.is_none() {
|
||||
url = Some(arg);
|
||||
} else {
|
||||
return Err(format!("Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ParsedArgs {
|
||||
url,
|
||||
tls_ca,
|
||||
profile,
|
||||
save,
|
||||
demo,
|
||||
dry_run,
|
||||
metrics_interval_ms,
|
||||
processes_interval_ms,
|
||||
verify_hostname,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let parsed = match parse_args(env::args()) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
}
|
||||
if parsed.verify_hostname {
|
||||
// Set env var consumed by ws::connect logic
|
||||
std::env::set_var("SOCKTOP_VERIFY_NAME", "1");
|
||||
}
|
||||
let profiles_file = load_profiles();
|
||||
let req = ProfileRequest {
|
||||
profile_name: parsed.profile.clone(),
|
||||
url: parsed.url.clone(),
|
||||
tls_ca: parsed.tls_ca.clone(),
|
||||
};
|
||||
let resolved = req.resolve(&profiles_file);
|
||||
let mut profiles_mut = profiles_file.clone();
|
||||
let (url, tls_ca, metrics_interval_ms, processes_interval_ms): (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<u64>,
|
||||
Option<u64>,
|
||||
) = match resolved {
|
||||
ResolveProfile::Direct(u, t) => {
|
||||
if let Some(name) = parsed.profile.as_ref() {
|
||||
let existing = profiles_mut.profiles.get(name);
|
||||
match existing {
|
||||
None => {
|
||||
let (mi, pi) = gather_intervals(
|
||||
parsed.metrics_interval_ms,
|
||||
parsed.processes_interval_ms,
|
||||
)?;
|
||||
profiles_mut.profiles.insert(
|
||||
name.clone(),
|
||||
ProfileEntry {
|
||||
url: u.clone(),
|
||||
tls_ca: t.clone(),
|
||||
metrics_interval_ms: mi,
|
||||
processes_interval_ms: pi,
|
||||
},
|
||||
);
|
||||
let _ = save_profiles(&profiles_mut);
|
||||
(u, t, mi, pi)
|
||||
}
|
||||
Some(entry) => {
|
||||
let changed = entry.url != u || entry.tls_ca != t;
|
||||
if changed {
|
||||
let overwrite = if parsed.save {
|
||||
true
|
||||
} else {
|
||||
prompt_yes_no(&format!(
|
||||
"Overwrite existing profile '{name}'? [y/N]: "
|
||||
))
|
||||
};
|
||||
if overwrite {
|
||||
let (mi, pi) = gather_intervals(
|
||||
parsed.metrics_interval_ms,
|
||||
parsed.processes_interval_ms,
|
||||
)?;
|
||||
profiles_mut.profiles.insert(
|
||||
name.clone(),
|
||||
ProfileEntry {
|
||||
url: u.clone(),
|
||||
tls_ca: t.clone(),
|
||||
metrics_interval_ms: mi,
|
||||
processes_interval_ms: pi,
|
||||
},
|
||||
);
|
||||
let _ = save_profiles(&profiles_mut);
|
||||
(u, t, mi, pi)
|
||||
} else {
|
||||
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
|
||||
}
|
||||
} else {
|
||||
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(
|
||||
u,
|
||||
t,
|
||||
parsed.metrics_interval_ms,
|
||||
parsed.processes_interval_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
ResolveProfile::Loaded(u, t) => {
|
||||
let entry = profiles_mut
|
||||
.profiles
|
||||
.get(parsed.profile.as_ref().unwrap())
|
||||
.unwrap();
|
||||
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
|
||||
}
|
||||
ResolveProfile::PromptSelect(mut names) => {
|
||||
if !names.iter().any(|n: &String| n == "demo") {
|
||||
names.push("demo".into());
|
||||
}
|
||||
eprintln!("Select profile:");
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
eprintln!(" {}. {}", i + 1, n);
|
||||
}
|
||||
eprint!("Enter number (or blank to abort): ");
|
||||
let _ = io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
if io::stdin().read_line(&mut line).is_ok() {
|
||||
if let Ok(idx) = line.trim().parse::<usize>() {
|
||||
if idx >= 1 && idx <= names.len() {
|
||||
let name = &names[idx - 1];
|
||||
if name == "demo" {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
}
|
||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||
(
|
||||
entry.url.clone(),
|
||||
entry.tls_ca.clone(),
|
||||
entry.metrics_interval_ms,
|
||||
entry.processes_interval_ms,
|
||||
)
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
ResolveProfile::PromptCreate(name) => {
|
||||
eprintln!("Profile '{name}' does not exist yet.");
|
||||
let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?;
|
||||
if url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ca = prompt_string("Enter TLS CA path (or leave blank): ")?;
|
||||
let ca_opt = if ca.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ca.trim().to_string())
|
||||
};
|
||||
let (mi, pi) =
|
||||
gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?;
|
||||
profiles_mut.profiles.insert(
|
||||
name.clone(),
|
||||
ProfileEntry {
|
||||
url: url.trim().to_string(),
|
||||
tls_ca: ca_opt.clone(),
|
||||
metrics_interval_ms: mi,
|
||||
processes_interval_ms: pi,
|
||||
},
|
||||
);
|
||||
let _ = save_profiles(&profiles_mut);
|
||||
(url.trim().to_string(), ca_opt, mi, pi)
|
||||
}
|
||||
ResolveProfile::None => {
|
||||
//eprintln!("No URL provided and no profiles to select.");
|
||||
|
||||
//first run, no args, no profiles: show welcome message and offer demo mode
|
||||
if profiles_mut.profiles.is_empty() && parsed.url.is_none() {
|
||||
eprintln!("Welcome to socktop!");
|
||||
eprintln!("It looks like this is your first time running the application.");
|
||||
eprintln!("You can connect to a socktop_agent instance to monitor system metrics and processes.");
|
||||
eprintln!("If you don't have an agent running, you can try the demo mode.");
|
||||
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
} else {
|
||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Err("No URL provided and no profiles to select.".into());
|
||||
}
|
||||
};
|
||||
|
||||
let is_tls = url.starts_with("wss://");
|
||||
let has_token = url.contains("token=");
|
||||
let mut app = App::new()
|
||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||
.with_status(is_tls, has_token);
|
||||
if parsed.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
app.run(&url, tls_ca.as_deref()).await
|
||||
}
|
||||
|
||||
fn prompt_yes_no(prompt: &str) -> bool {
|
||||
eprint!("{prompt}");
|
||||
let _ = io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
if io::stdin().read_line(&mut line).is_ok() {
|
||||
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn prompt_string(prompt: &str) -> io::Result<String> {
|
||||
eprint!("{prompt}");
|
||||
let _ = io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
io::stdin().read_line(&mut line)?;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
fn gather_intervals(
|
||||
arg_metrics: Option<u64>,
|
||||
arg_procs: Option<u64>,
|
||||
) -> Result<(Option<u64>, Option<u64>), Box<dyn std::error::Error>> {
|
||||
let default_metrics = 500u64;
|
||||
let default_procs = 2000u64;
|
||||
let metrics = match arg_metrics {
|
||||
Some(v) => Some(v),
|
||||
None => {
|
||||
let inp = prompt_string(&format!(
|
||||
"Metrics interval ms (default {default_metrics}, Enter for default): "
|
||||
))?;
|
||||
let t = inp.trim();
|
||||
if t.is_empty() {
|
||||
Some(default_metrics)
|
||||
} else {
|
||||
Some(t.parse()?)
|
||||
}
|
||||
}
|
||||
};
|
||||
let procs = match arg_procs {
|
||||
Some(v) => Some(v),
|
||||
None => {
|
||||
let inp = prompt_string(&format!(
|
||||
"Processes interval ms (default {default_procs}, Enter for default): "
|
||||
))?;
|
||||
let t = inp.trim();
|
||||
if t.is_empty() {
|
||||
Some(default_procs)
|
||||
} else {
|
||||
Some(t.parse()?)
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok((metrics, procs))
|
||||
}
|
||||
|
||||
// Demo mode implementation
|
||||
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
let child = spawn_demo_agent(port)?;
|
||||
let mut app = App::new();
|
||||
tokio::select! { res=app.run(&url,None)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||
}
|
||||
struct DemoGuard {
|
||||
port: u16,
|
||||
child: std::sync::Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||
}
|
||||
impl Drop for DemoGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut ch) = self.child.lock().unwrap().take() {
|
||||
let _ = ch.kill();
|
||||
}
|
||||
eprintln!("Stopped demo agent on port {}", self.port);
|
||||
}
|
||||
}
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||
let candidate = find_agent_executable();
|
||||
let mut cmd = std::process::Command::new(candidate);
|
||||
cmd.arg("--port").arg(port.to_string());
|
||||
cmd.env("SOCKTOP_ENABLE_SSL", "0");
|
||||
|
||||
//JW: do not disable GPU and TEMP in demo mode
|
||||
//cmd.env("SOCKTOP_AGENT_GPU", "0");
|
||||
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
Ok(DemoGuard {
|
||||
port,
|
||||
child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))),
|
||||
})
|
||||
}
|
||||
fn find_agent_executable() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
#[cfg(windows)]
|
||||
let name = "socktop_agent.exe";
|
||||
#[cfg(not(windows))]
|
||||
let name = "socktop_agent";
|
||||
let candidate = parent.join(name);
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from("socktop_agent")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca }
|
||||
//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProfileEntry {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls_ca: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metrics_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub processes_interval_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProfilesFile {
|
||||
#[serde(default)]
|
||||
pub profiles: BTreeMap<String, ProfileEntry>,
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(xdg).join("socktop")
|
||||
} else {
|
||||
dirs_next::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("socktop")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profiles_path() -> PathBuf {
|
||||
config_dir().join("profiles.json")
|
||||
}
|
||||
|
||||
pub fn load_profiles() -> ProfilesFile {
|
||||
let path = profiles_path();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => ProfilesFile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> {
|
||||
let path = profiles_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let data = serde_json::to_vec_pretty(p).expect("serialize profiles");
|
||||
fs::write(path, data)
|
||||
}
|
||||
|
||||
pub enum ResolveProfile {
|
||||
/// Use the provided runtime inputs (not persisted). (url, tls_ca)
|
||||
Direct(String, Option<String>),
|
||||
/// Loaded from existing profile entry (url, tls_ca)
|
||||
Loaded(String, Option<String>),
|
||||
/// Should prompt user to select among profile names
|
||||
PromptSelect(Vec<String>),
|
||||
/// Should prompt user to create a new profile (name)
|
||||
PromptCreate(String),
|
||||
/// No profile could be resolved (e.g., missing arguments)
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct ProfileRequest {
|
||||
pub profile_name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub tls_ca: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfileRequest {
|
||||
pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile {
|
||||
// Case: only profile name given -> try load
|
||||
if self.url.is_none() && self.profile_name.is_some() {
|
||||
let name = self.profile_name.unwrap();
|
||||
if let Some(entry) = pf.profiles.get(&name) {
|
||||
return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone());
|
||||
} else {
|
||||
return ResolveProfile::PromptCreate(name);
|
||||
}
|
||||
}
|
||||
// Both provided -> direct (maybe later saved by caller)
|
||||
if let Some(u) = self.url {
|
||||
return ResolveProfile::Direct(u, self.tls_ca);
|
||||
}
|
||||
// Nothing provided -> maybe prompt select if profiles exist
|
||||
if self.url.is_none() && self.profile_name.is_none() {
|
||||
if pf.profiles.is_empty() {
|
||||
ResolveProfile::None
|
||||
} else {
|
||||
ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect())
|
||||
}
|
||||
} else {
|
||||
ResolveProfile::None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Types that mirror the agent's JSON schema.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NetworkInfo {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GpuInfo {
|
||||
pub name: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub vendor: Option<String>,
|
||||
|
||||
// Accept both the new and legacy keys
|
||||
#[serde(
|
||||
default,
|
||||
alias = "utilization_gpu_pct",
|
||||
alias = "gpu_util_pct",
|
||||
alias = "gpu_utilization"
|
||||
)]
|
||||
pub utilization: Option<f32>,
|
||||
|
||||
#[serde(default, alias = "mem_used_bytes", alias = "vram_used_bytes")]
|
||||
pub mem_used: Option<u64>,
|
||||
|
||||
#[serde(default, alias = "mem_total_bytes", alias = "vram_total_bytes")]
|
||||
pub mem_total: Option<u64>,
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[serde(default, alias = "temp_c", alias = "temperature_c")]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Metrics {
|
||||
pub cpu_total: f32,
|
||||
pub cpu_per_core: Vec<f32>,
|
||||
pub mem_total: u64,
|
||||
pub mem_used: u64,
|
||||
pub swap_total: u64,
|
||||
pub swap_used: u64,
|
||||
pub hostname: String,
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
pub disks: Vec<DiskInfo>,
|
||||
pub networks: Vec<NetworkInfo>,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
pub gpus: Option<Vec<GpuInfo>>,
|
||||
// New: keep the last reported total process count
|
||||
#[serde(default)]
|
||||
pub process_count: Option<usize>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProcessesPayload {
|
||||
pub process_count: usize,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! CPU average sparkline + per-core mini bars.
|
||||
|
||||
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
|
||||
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||
};
|
||||
|
||||
use crate::history::PerCoreHistory;
|
||||
use crate::types::Metrics;
|
||||
|
||||
/// State for dragging the scrollbar thumb
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PerCoreScrollDrag {
|
||||
pub active: bool,
|
||||
pub start_y: u16, // mouse row where drag started
|
||||
pub start_top: usize, // thumb top (in track rows) at drag start
|
||||
}
|
||||
|
||||
/// Returns the content area for per-core CPU bars, excluding borders and reserving space for scrollbar.
|
||||
pub fn per_core_content_area(area: Rect) -> Rect {
|
||||
// Inner minus borders
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
// Reserve 1 column on the right for a gutter and 1 for the scrollbar
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles key events for per-core CPU bars.
|
||||
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
|
||||
match key.code {
|
||||
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::PageUp => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_add(step);
|
||||
}
|
||||
KeyCode::Home => *scroll_offset = 0,
|
||||
KeyCode::End => *scroll_offset = usize::MAX, // draw() clamps to max
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles mouse wheel over the content.
|
||||
pub fn per_core_handle_mouse(
|
||||
scroll_offset: &mut usize,
|
||||
mouse: MouseEvent,
|
||||
content_area: Rect,
|
||||
page_size: usize,
|
||||
) {
|
||||
let inside = mouse.column >= content_area.x
|
||||
&& mouse.column < content_area.x + content_area.width
|
||||
&& mouse.row >= content_area.y
|
||||
&& mouse.row < content_area.y + content_area.height;
|
||||
|
||||
if !inside {
|
||||
return;
|
||||
}
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
MouseEventKind::ScrollDown => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
// Optional paging via horizontal wheel
|
||||
MouseEventKind::ScrollLeft => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
}
|
||||
MouseEventKind::ScrollRight => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_add(step);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles mouse interaction with the scrollbar itself (click arrows/page/drag).
|
||||
pub fn per_core_handle_scrollbar_mouse(
|
||||
scroll_offset: &mut usize,
|
||||
drag: &mut Option<PerCoreScrollDrag>,
|
||||
mouse: MouseEvent,
|
||||
per_core_area: Rect,
|
||||
total_rows: usize,
|
||||
) {
|
||||
// Geometry
|
||||
let inner = Rect {
|
||||
x: per_core_area.x + 1,
|
||||
y: per_core_area.y + 1,
|
||||
width: per_core_area.width.saturating_sub(2),
|
||||
height: per_core_area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height < 3 || inner.width < 1 {
|
||||
return;
|
||||
}
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
};
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
let viewport_rows = content.height as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
let mut offset = (*scroll_offset).min(max_off);
|
||||
|
||||
// Track and current thumb
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
if track == 0 {
|
||||
return;
|
||||
}
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let top_for_offset = |off: usize| -> usize {
|
||||
if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * off + max_off / 2) / max_off
|
||||
}
|
||||
};
|
||||
let thumb_top = top_for_offset(offset);
|
||||
|
||||
let inside_scrollbar = mouse.column == scroll_area.x
|
||||
&& mouse.row >= scroll_area.y
|
||||
&& mouse.row < scroll_area.y + scroll_area.height;
|
||||
|
||||
// Helper to page
|
||||
let page_up = || offset.saturating_sub(view.max(1));
|
||||
let page_down = || offset.saturating_add(view.max(1));
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) if inside_scrollbar => {
|
||||
// Where within the track?
|
||||
let row = mouse.row;
|
||||
if row == scroll_area.y {
|
||||
// Top arrow
|
||||
offset = offset.saturating_sub(1);
|
||||
} else if row + 1 == scroll_area.y + scroll_area.height {
|
||||
// Bottom arrow
|
||||
offset = offset.saturating_add(1);
|
||||
} else {
|
||||
// Inside track
|
||||
let rel = (row - (scroll_area.y + 1)) as usize;
|
||||
let thumb_end = thumb_top + thumb_len;
|
||||
if rel < thumb_top {
|
||||
// Page up
|
||||
offset = page_up();
|
||||
} else if rel >= thumb_end {
|
||||
// Page down
|
||||
offset = page_down();
|
||||
} else {
|
||||
// Start dragging
|
||||
*drag = Some(PerCoreScrollDrag {
|
||||
active: true,
|
||||
start_y: row,
|
||||
start_top: thumb_top,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(mut d) = drag.take() {
|
||||
if d.active {
|
||||
let dy = (mouse.row as i32) - (d.start_y as i32);
|
||||
let new_top = (d.start_top as i32 + dy)
|
||||
.clamp(0, (track.saturating_sub(thumb_len)) as i32)
|
||||
as usize;
|
||||
// Inverse mapping top -> offset
|
||||
if track > thumb_len {
|
||||
let denom = track - thumb_len;
|
||||
offset = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
(new_top * max_off + denom / 2) / denom
|
||||
};
|
||||
} else {
|
||||
offset = 0;
|
||||
}
|
||||
// Keep dragging
|
||||
d.start_top = new_top;
|
||||
d.start_y = mouse.row;
|
||||
*drag = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
// End drag
|
||||
*drag = None;
|
||||
}
|
||||
// Also allow wheel scrolling when cursor is over the scrollbar
|
||||
MouseEventKind::ScrollUp if inside_scrollbar => {
|
||||
offset = offset.saturating_sub(1);
|
||||
}
|
||||
MouseEventKind::ScrollDown if inside_scrollbar => {
|
||||
offset = offset.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Clamp and write back
|
||||
if offset > max_off {
|
||||
offset = max_off;
|
||||
}
|
||||
*scroll_offset = offset;
|
||||
}
|
||||
|
||||
/// Clamp scroll offset to the valid range given content and viewport.
|
||||
pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_rows: usize) {
|
||||
let max_offset = total_rows.saturating_sub(viewport_rows);
|
||||
if *scroll_offset > max_offset {
|
||||
*scroll_offset = max_offset;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the CPU average sparkline graph.
|
||||
pub fn draw_cpu_avg_graph(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
m: Option<&Metrics>,
|
||||
) {
|
||||
let title = if let Some(mm) = m {
|
||||
format!("CPU avg (now: {:>5.1}%)", mm.cpu_total)
|
||||
} else {
|
||||
"CPU avg".into()
|
||||
};
|
||||
let 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 spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(&data)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
|
||||
/// Draws the per-core CPU bars with sparklines and trends.
|
||||
pub fn draw_per_core_bars(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
per_core_hist: &PerCoreHistory,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
f.render_widget(
|
||||
Block::default().borders(Borders::ALL).title("Per-core"),
|
||||
area,
|
||||
);
|
||||
let Some(mm) = m else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Compute inner rect and content area
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height == 0 || inner.width <= 2 {
|
||||
return;
|
||||
}
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
};
|
||||
|
||||
let total_rows = mm.cpu_per_core.len();
|
||||
let viewport_rows = content.height as usize;
|
||||
let max_offset = total_rows.saturating_sub(viewport_rows);
|
||||
let offset = scroll_offset.min(max_offset);
|
||||
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||
|
||||
let constraints: Vec<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect();
|
||||
let vchunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(content);
|
||||
|
||||
for i in 0..show_n {
|
||||
let idx = offset + i;
|
||||
let rect = vchunks[i];
|
||||
let hchunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||
.split(rect);
|
||||
|
||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||
let older = per_core_hist
|
||||
.deques
|
||||
.get(idx)
|
||||
.and_then(|d| d.iter().rev().nth(20).copied())
|
||||
.map(|v| v as f32)
|
||||
.unwrap_or(curr);
|
||||
|
||||
let trend = if curr > older + 0.2 {
|
||||
"↑"
|
||||
} else if curr + 0.2 < older {
|
||||
"↓"
|
||||
} else {
|
||||
"╌"
|
||||
};
|
||||
|
||||
let fg = match curr {
|
||||
x if x < 25.0 => Color::Green,
|
||||
x if x < 60.0 => Color::Yellow,
|
||||
_ => 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();
|
||||
|
||||
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}%");
|
||||
let line = Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||
}
|
||||
|
||||
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Disk cards with per-device gauge and title line.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::{disk_icon, human, truncate_middle};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Gauge},
|
||||
};
|
||||
|
||||
pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
f.render_widget(Block::default().borders(Borders::ALL).title("Disks"), area);
|
||||
let Some(mm) = m else {
|
||||
return;
|
||||
};
|
||||
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let per_disk_h = 3u16;
|
||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||
|
||||
let constraints: Vec<Constraint> = (0..max_cards)
|
||||
.map(|_| Constraint::Length(per_disk_h))
|
||||
.collect();
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner);
|
||||
|
||||
for (i, slot) in rows.iter().enumerate() {
|
||||
let d = &mm.disks[i];
|
||||
let used = d.total.saturating_sub(d.available);
|
||||
let ratio = if d.total > 0 {
|
||||
used as f64 / d.total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pct = (ratio * 100.0).round() as u16;
|
||||
|
||||
let color = if pct < 70 {
|
||||
ratatui::style::Color::Green
|
||||
} else if pct < 90 {
|
||||
ratatui::style::Color::Yellow
|
||||
} else {
|
||||
ratatui::style::Color::Red
|
||||
};
|
||||
|
||||
let title = format!(
|
||||
"{} {} {} / {} ({}%)",
|
||||
disk_icon(&d.name),
|
||||
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||
human(used),
|
||||
human(d.total),
|
||||
pct
|
||||
);
|
||||
|
||||
let card = Block::default().borders(Borders::ALL).title(title);
|
||||
f.render_widget(card, *slot);
|
||||
|
||||
let inner_card = Rect {
|
||||
x: slot.x + 1,
|
||||
y: slot.y + 1,
|
||||
width: slot.width.saturating_sub(2),
|
||||
height: slot.height.saturating_sub(2),
|
||||
};
|
||||
if inner_card.height == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let gauge_rect = Rect {
|
||||
x: inner_card.x,
|
||||
y: inner_card.y + inner_card.height / 2,
|
||||
width: inner_card.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let g = Gauge::default()
|
||||
.percent(pct)
|
||||
.gauge_style(Style::default().fg(color));
|
||||
|
||||
f.render_widget(g, gauge_rect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
};
|
||||
|
||||
use crate::types::Metrics;
|
||||
|
||||
fn fmt_bytes(b: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
let fb = b as f64;
|
||||
|
||||
if fb >= GB {
|
||||
format!("{:.1}G", fb / GB)
|
||||
} else if fb >= MB {
|
||||
format!("{:.1}M", fb / MB)
|
||||
} else if fb >= KB {
|
||||
format!("{:.1}K", fb / KB)
|
||||
} else {
|
||||
format!("{b}B")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let mut area = area;
|
||||
let block = Block::default().borders(Borders::ALL).title("GPU");
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Guard: need some space inside the block
|
||||
if area.height <= 2 || area.width <= 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inner padding consistent with the rest of the app
|
||||
area.y += 1;
|
||||
area.height = area.height.saturating_sub(2);
|
||||
area.x += 1;
|
||||
area.width = area.width.saturating_sub(2);
|
||||
|
||||
let Some(metrics) = m else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(gpus) = metrics.gpus.as_ref() else {
|
||||
f.render_widget(Paragraph::new("No GPUs"), area);
|
||||
return;
|
||||
};
|
||||
if gpus.is_empty() {
|
||||
f.render_widget(Paragraph::new("No GPUs"), area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show 3 rows per GPU: name, util bar, vram bar.
|
||||
if area.height < 3 {
|
||||
return;
|
||||
}
|
||||
let per_gpu_rows: u16 = 3;
|
||||
let max_gpus = (area.height / per_gpu_rows) as usize;
|
||||
let count = gpus.len().min(max_gpus);
|
||||
|
||||
let constraints = vec![Constraint::Length(1); count * per_gpu_rows as usize];
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
// Per bar horizontal layout: [gauge] [value]
|
||||
let split_bar = |r: Rect| {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(8), // gauge column
|
||||
Constraint::Length(24), // value column
|
||||
])
|
||||
.split(r)
|
||||
};
|
||||
|
||||
for i in 0..count {
|
||||
let g = &gpus[i];
|
||||
|
||||
// Row 1: GPU name
|
||||
let name_text = g.name.as_deref().unwrap_or("GPU");
|
||||
let name_p = Paragraph::new(Span::raw(name_text)).style(Style::default().fg(Color::Gray));
|
||||
f.render_widget(name_p, rows[i * 3]);
|
||||
|
||||
// Row 2: Utilization bar + right label
|
||||
let util_cols = split_bar(rows[i * 3 + 1]);
|
||||
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
|
||||
let util_gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Green))
|
||||
.label(Span::raw(""))
|
||||
.ratio(util as f64 / 100.0);
|
||||
f.render_widget(util_gauge, util_cols[0]);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::raw(format!("util: {util}%")))
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
util_cols[1],
|
||||
);
|
||||
|
||||
// Row 3: VRAM bar + right label
|
||||
let mem_cols = split_bar(rows[i * 3 + 2]);
|
||||
let used = g.mem_used.unwrap_or(0);
|
||||
let total = g.mem_total.unwrap_or(1);
|
||||
let mem_ratio = used as f64 / total as f64;
|
||||
let mem_pct = (mem_ratio * 100.0).round() as u16;
|
||||
|
||||
let mem_gauge = Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::LightMagenta))
|
||||
.label(Span::raw(""))
|
||||
.ratio(mem_ratio);
|
||||
f.render_widget(mem_gauge, mem_cols[0]);
|
||||
let used_s = fmt_bytes(used);
|
||||
let total_s = fmt_bytes(total);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::raw(format!("vram: {used_s}/{total_s} ({mem_pct}%)")))
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
mem_cols[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Top header with hostname and CPU temperature indicator.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
widgets::{Block, Borders},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn draw_header(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
is_tls: bool,
|
||||
has_token: bool,
|
||||
metrics_interval: Duration,
|
||||
procs_interval: Duration,
|
||||
) {
|
||||
let base = if let Some(mm) = m {
|
||||
let temp = mm
|
||||
.cpu_temp_c
|
||||
.map(|t| {
|
||||
let icon = if t < 50.0 {
|
||||
"😎"
|
||||
} else if t < 85.0 {
|
||||
"⚠️"
|
||||
} else {
|
||||
"🔥"
|
||||
};
|
||||
format!("CPU Temp: {t:.1}°C {icon}")
|
||||
})
|
||||
.unwrap_or_else(|| "CPU Temp: N/A".into());
|
||||
format!("socktop — host: {} | {}", mm.hostname, temp)
|
||||
} else {
|
||||
"socktop — connecting...".into()
|
||||
};
|
||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||
// Token indicator
|
||||
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let mut parts = vec![base, tls_txt.into()];
|
||||
if !tok_txt.is_empty() {
|
||||
parts.push(tok_txt.into());
|
||||
}
|
||||
parts.push(intervals);
|
||||
parts.push("(q to quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Memory gauge.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::human;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge},
|
||||
};
|
||||
|
||||
pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let (used, total, pct) = if let Some(mm) = m {
|
||||
let pct = if mm.mem_total > 0 {
|
||||
(mm.mem_used as f64 / mm.mem_total as f64 * 100.0) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(mm.mem_used, mm.mem_total, pct)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
let g = Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title("Memory"))
|
||||
.gauge_style(Style::default().fg(Color::Magenta))
|
||||
.percent(pct)
|
||||
.label(format!("{} / {}", human(used), human(total)));
|
||||
f.render_widget(g, area);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! UI module root: exposes drawing functions for individual panels.
|
||||
|
||||
pub mod cpu;
|
||||
pub mod disks;
|
||||
pub mod gpu;
|
||||
pub mod header;
|
||||
pub mod mem;
|
||||
pub mod net;
|
||||
pub mod processes;
|
||||
pub mod swap;
|
||||
pub mod theme;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Network sparklines (download/upload).
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Sparkline},
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn draw_net_spark(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
hist: &VecDeque<u64>,
|
||||
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 spark = Sparkline::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string()),
|
||||
)
|
||||
.data(&data)
|
||||
.style(Style::default().fg(color));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Top processes table with per-cell coloring, zebra striping, sorting, and a scrollbar.
|
||||
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, 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;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ProcSortBy {
|
||||
#[default]
|
||||
CpuDesc,
|
||||
MemDesc,
|
||||
}
|
||||
|
||||
// Keep the original header widths here so drawing and hit-testing match.
|
||||
const COLS: [Constraint; 5] = [
|
||||
Constraint::Length(8), // PID
|
||||
Constraint::Percentage(40), // Name
|
||||
Constraint::Length(8), // CPU %
|
||||
Constraint::Length(12), // Mem
|
||||
Constraint::Length(8), // Mem %
|
||||
];
|
||||
|
||||
pub fn draw_top_processes(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
scroll_offset: usize,
|
||||
sort_by: ProcSortBy,
|
||||
) {
|
||||
// Draw outer block and title
|
||||
let Some(mm) = m 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)
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height < 1 || inner.width < 3 {
|
||||
return;
|
||||
}
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
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)
|
||||
}),
|
||||
}
|
||||
|
||||
// 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 show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||
|
||||
// Build visible rows
|
||||
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 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_fg = match cpu_val {
|
||||
x if x < 25.0 => Color::Green,
|
||||
x if x < 60.0 => Color::Yellow,
|
||||
_ => Color::Red,
|
||||
};
|
||||
let mem_fg = match mem_pct {
|
||||
x if x < 5.0 => Color::Blue,
|
||||
x if x < 20.0 => Color::Magenta,
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
let 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);
|
||||
|
||||
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}%"))
|
||||
.style(Style::default().fg(mem_fg)),
|
||||
])
|
||||
.style(emphasis)
|
||||
});
|
||||
|
||||
// Header with sort indicator
|
||||
let cpu_hdr = match sort_by {
|
||||
ProcSortBy::CpuDesc => "CPU % •",
|
||||
_ => "CPU %",
|
||||
};
|
||||
let mem_hdr = match sort_by {
|
||||
ProcSortBy::MemDesc => "Mem •",
|
||||
_ => "Mem",
|
||||
};
|
||||
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
// Render table inside content area (no borders here; outer block already drawn)
|
||||
let table = Table::new(rows_iter, COLS.to_vec())
|
||||
.header(header)
|
||||
.column_spacing(1);
|
||||
f.render_widget(table, content);
|
||||
|
||||
// Draw scrollbar like CPU pane
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
pub fn processes_handle_key(
|
||||
scroll_offset: &mut usize,
|
||||
key: crossterm::event::KeyEvent,
|
||||
page_size: usize,
|
||||
) {
|
||||
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
|
||||
}
|
||||
|
||||
/// Handle mouse for content scrolling and scrollbar dragging.
|
||||
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
|
||||
pub fn processes_handle_mouse(
|
||||
scroll_offset: &mut usize,
|
||||
drag: &mut Option<crate::ui::cpu::PerCoreScrollDrag>,
|
||||
mouse: MouseEvent,
|
||||
area: Rect,
|
||||
total_rows: usize,
|
||||
) -> Option<ProcSortBy> {
|
||||
// Inner and content areas (match draw_top_processes)
|
||||
let inner = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
};
|
||||
if inner.height == 0 || inner.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
};
|
||||
|
||||
// Scrollbar interactions (click arrows/page/drag)
|
||||
per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows);
|
||||
|
||||
// Wheel scrolling when inside the content
|
||||
crate::ui::cpu::per_core_handle_mouse(scroll_offset, 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 = mouse.row == header_area.y
|
||||
&& mouse.column >= header_area.x
|
||||
&& mouse.column < header_area.x + header_area.width;
|
||||
|
||||
if inside_header && matches!(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 mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
|
||||
return Some(ProcSortBy::CpuDesc);
|
||||
}
|
||||
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
|
||||
return Some(ProcSortBy::MemDesc);
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp to valid range
|
||||
per_core_clamp(
|
||||
scroll_offset,
|
||||
total_rows,
|
||||
(content.height.saturating_sub(1)) as usize,
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Swap gauge.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::human;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Gauge},
|
||||
};
|
||||
|
||||
pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
let (used, total, pct) = if let Some(mm) = m {
|
||||
let pct = if mm.swap_total > 0 {
|
||||
(mm.swap_used as f64 / mm.swap_total as f64 * 100.0) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(mm.swap_used, mm.swap_total, pct)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
let g = Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title("Swap"))
|
||||
.gauge_style(Style::default().fg(Color::Yellow))
|
||||
.percent(pct)
|
||||
.label(format!("{} / {}", human(used), human(total)));
|
||||
f.render_widget(g, area);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Shared UI theme constants.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
// Scrollbar colors (same look as before)
|
||||
pub const SB_ARROW: Color = Color::Rgb(170, 170, 180);
|
||||
pub const SB_TRACK: Color = Color::Rgb(170, 170, 180);
|
||||
pub const SB_THUMB: Color = Color::Rgb(170, 170, 180);
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Small UI helpers: human-readable sizes, truncation, icons.
|
||||
|
||||
pub fn human(b: u64) -> String {
|
||||
const K: f64 = 1024.0;
|
||||
let b = b as f64;
|
||||
if b < K {
|
||||
return format!("{b:.0}B");
|
||||
}
|
||||
let kb = b / K;
|
||||
if kb < K {
|
||||
return format!("{kb:.1}KB");
|
||||
}
|
||||
let mb = kb / K;
|
||||
if mb < K {
|
||||
return format!("{mb:.1}MB");
|
||||
}
|
||||
let gb = mb / K;
|
||||
if gb < K {
|
||||
return format!("{gb:.1}GB");
|
||||
}
|
||||
let tb = gb / K;
|
||||
format!("{tb:.2}TB")
|
||||
}
|
||||
|
||||
pub fn truncate_middle(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max <= 3 {
|
||||
return "...".into();
|
||||
}
|
||||
let keep = max - 3;
|
||||
let left = keep / 2;
|
||||
let right = keep - left;
|
||||
format!("{}...{}", &s[..left], &s[s.len() - right..])
|
||||
}
|
||||
|
||||
pub fn disk_icon(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains(':') {
|
||||
"🗄️"
|
||||
} else if n.contains("nvme") {
|
||||
"⚡"
|
||||
} else if n.starts_with("sd") {
|
||||
"💽"
|
||||
} else if n.contains("overlay") {
|
||||
"📦"
|
||||
} else {
|
||||
"🖴"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Minimal WebSocket client helpers for requesting metrics from the agent.
|
||||
|
||||
use flate2::bufread::GzDecoder;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as _;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||
use rustls_pemfile::Item;
|
||||
use std::io::Read;
|
||||
use std::{fs::File, io::BufReader, sync::Arc};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::{
|
||||
connect_async, connect_async_tls_with_config, tungstenite::client::IntoClientRequest,
|
||||
tungstenite::Message, Connector, MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use crate::types::{DiskInfo, Metrics, ProcessInfo, ProcessesPayload};
|
||||
|
||||
mod pb {
|
||||
// generated by build.rs
|
||||
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
|
||||
}
|
||||
|
||||
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
// Connect to the agent and return the WS stream
|
||||
pub async fn connect(
|
||||
url: &str,
|
||||
tls_ca: Option<&str>,
|
||||
) -> Result<WsStream, Box<dyn std::error::Error>> {
|
||||
let mut u = Url::parse(url)?;
|
||||
if let Some(ca_path) = tls_ca {
|
||||
if u.scheme() == "ws" {
|
||||
let _ = u.set_scheme("wss");
|
||||
}
|
||||
return connect_with_ca(u.as_str(), ca_path).await;
|
||||
}
|
||||
let (ws, _) = connect_async(u.as_str()).await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
async fn connect_with_ca(url: &str, ca_path: &str) -> Result<WsStream, Box<dyn std::error::Error>> {
|
||||
let mut root = RootCertStore::empty();
|
||||
let mut reader = BufReader::new(File::open(ca_path)?);
|
||||
let mut der_certs = Vec::new();
|
||||
while let Ok(Some(item)) = rustls_pemfile::read_one(&mut reader) {
|
||||
if let Item::X509Certificate(der) = item {
|
||||
der_certs.push(der);
|
||||
}
|
||||
}
|
||||
root.add_parsable_certificates(der_certs);
|
||||
|
||||
let mut cfg = ClientConfig::builder()
|
||||
.with_root_certificates(root)
|
||||
.with_no_client_auth();
|
||||
|
||||
let req = url.into_client_request()?;
|
||||
let verify_domain = std::env::var("SOCKTOP_VERIFY_NAME").ok().as_deref() == Some("1");
|
||||
if !verify_domain {
|
||||
#[derive(Debug)]
|
||||
struct NoVerify;
|
||||
impl ServerCertVerifier for NoVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
// Provide common schemes; not strictly needed for skipping but keeps API happy
|
||||
vec![
|
||||
SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
SignatureScheme::ED25519,
|
||||
SignatureScheme::RSA_PSS_SHA256,
|
||||
]
|
||||
}
|
||||
}
|
||||
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
|
||||
eprintln!("socktop: hostname verification disabled (default). Use --verify-hostname to enable strict SAN checking.");
|
||||
}
|
||||
let cfg = Arc::new(cfg);
|
||||
let (ws, _) =
|
||||
connect_async_tls_with_config(req, None, verify_domain, Some(Connector::Rustls(cfg)))
|
||||
.await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
// Send a "get_metrics" request and await a single JSON reply
|
||||
pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
|
||||
if ws.send(Message::Text("get_metrics".into())).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Metrics>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Decompress a gzip-compressed binary frame into a String.
|
||||
fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
|
||||
let mut dec = GzDecoder::new(bytes);
|
||||
let mut out = String::new();
|
||||
dec.read_to_string(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn gunzip_to_vec(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut dec = GzDecoder::new(bytes);
|
||||
let mut out = Vec::new();
|
||||
dec.read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn is_gzip(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
|
||||
}
|
||||
// Suppress dead_code until these are wired into the app
|
||||
#[allow(dead_code)]
|
||||
pub enum Payload {
|
||||
Metrics(Metrics),
|
||||
Disks(Vec<DiskInfo>),
|
||||
Processes(ProcessesPayload),
|
||||
}
|
||||
|
||||
// Send a "get_disks" request and await a JSON Vec<DiskInfo>
|
||||
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
|
||||
if ws.send(Message::Text("get_disks".into())).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Vec<DiskInfo>>(&s).ok())
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<Vec<DiskInfo>>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Send a "get_processes" request and await a ProcessesPayload decoded from protobuf (binary, may be gzipped)
|
||||
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
|
||||
if ws
|
||||
.send(Message::Text("get_processes".into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match ws.next().await {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
let gz = is_gzip(&b);
|
||||
let data = if gz { gunzip_to_vec(&b)? } else { b };
|
||||
match pb::Processes::decode(data.as_slice()) {
|
||||
Ok(pb) => {
|
||||
let rows: Vec<ProcessInfo> = pb
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|p: pb::Process| ProcessInfo {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
Some(ProcessesPayload {
|
||||
process_count: pb.process_count as usize,
|
||||
top_processes: rows,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
if std::env::var("SOCKTOP_DEBUG").ok().as_deref() == Some("1") {
|
||||
eprintln!("protobuf decode failed: {e}");
|
||||
}
|
||||
// Fallback: maybe it's JSON (bytes already decompressed if gz)
|
||||
match String::from_utf8(data) {
|
||||
Ok(s) => serde_json::from_str::<ProcessesPayload>(&s).ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! CLI arg parsing tests for socktop (client)
|
||||
use std::process::Command;
|
||||
|
||||
// We test the parsing by invoking the binary with --help and ensuring the help mentions short and long flags.
|
||||
// Also directly test the parse_args function via a tiny helper in a doctest-like fashion using a small
|
||||
// reimplementation here kept in sync with main (compile-time test).
|
||||
|
||||
#[test]
|
||||
fn test_help_mentions_short_and_long_flags() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_socktop"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.expect("run socktop --help");
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.contains("--tls-ca")
|
||||
&& text.contains("-t")
|
||||
&& text.contains("--profile")
|
||||
&& text.contains("-P"),
|
||||
"help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tlc_ca_arg_long_and_short_parsed() {
|
||||
// Use --help combined with flags to avoid network and still exercise arg acceptance
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
// Long form with help
|
||||
let out = Command::new(exe)
|
||||
.args(["--tls-ca", "/tmp/cert.pem", "--help"])
|
||||
.output()
|
||||
.expect("run socktop");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"socktop --tls-ca … --help did not succeed"
|
||||
);
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(text.contains("Usage:"));
|
||||
// Short form with help
|
||||
let out2 = Command::new(exe)
|
||||
.args(["-t", "/tmp/cert.pem", "--help"])
|
||||
.output()
|
||||
.expect("run socktop");
|
||||
assert!(out2.status.success(), "socktop -t … --help did not succeed");
|
||||
let text2 = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out2.stdout),
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
assert!(text2.contains("Usage:"));
|
||||
|
||||
// Profile flags with help (should not error)
|
||||
let out3 = Command::new(exe)
|
||||
.args(["--profile", "dev", "--help"])
|
||||
.output()
|
||||
.expect("run socktop");
|
||||
assert!(
|
||||
out3.status.success(),
|
||||
"socktop --profile dev --help did not succeed"
|
||||
);
|
||||
let text3 = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out3.stdout),
|
||||
String::from_utf8_lossy(&out3.stderr)
|
||||
);
|
||||
assert!(text3.contains("Usage:"));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Tests for profile load/save and resolution logic (non-interactive paths only)
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Global lock to serialize tests that mutate process-wide environment variables.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[allow(dead_code)] // touch crate
|
||||
fn touch() {
|
||||
let _ = socktop::types::Metrics {
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 0,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
process_count: None,
|
||||
hostname: String::new(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
};
|
||||
}
|
||||
|
||||
// We re-import internal modules by copying minimal logic here because profiles.rs isn't public.
|
||||
// Instead of exposing internals, we simulate profile saving through CLI invocations.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn run_socktop(args: &[&str]) -> (bool, String) {
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let output = Command::new(exe).args(args).output().expect("run socktop");
|
||||
let ok = output.status.success();
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
(ok, text)
|
||||
}
|
||||
|
||||
fn config_dir() -> std::path::PathBuf {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
std::path::PathBuf::from(xdg).join("socktop")
|
||||
} else {
|
||||
dirs_next::config_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("socktop")
|
||||
}
|
||||
}
|
||||
|
||||
fn profiles_path() -> std::path::PathBuf {
|
||||
config_dir().join("profiles.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_created_on_first_use() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
// Isolate config in a temp dir
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||
// Ensure directory exists fresh
|
||||
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||
let _ = fs::remove_file(profiles_path());
|
||||
// Provide profile + url => should create profiles.json
|
||||
let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]);
|
||||
// We pass --help to exit early after parsing (no network attempt)
|
||||
let data = fs::read_to_string(profiles_path()).expect("profiles.json created");
|
||||
assert!(
|
||||
data.contains("unittest"),
|
||||
"profiles.json missing profile entry: {data}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_overwrite_only_when_changed() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||
let _ = fs::remove_file(profiles_path());
|
||||
// Initial create
|
||||
let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create
|
||||
let first = fs::read_to_string(profiles_path()).unwrap();
|
||||
// Re-run identical (should not duplicate or corrupt)
|
||||
let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical
|
||||
let second = fs::read_to_string(profiles_path()).unwrap();
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"Profile file changed despite identical input"
|
||||
);
|
||||
// Overwrite with different URL using --save (no prompt path)
|
||||
let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]);
|
||||
let third = fs::read_to_string(profiles_path()).unwrap();
|
||||
assert!(third.contains("two"), "Updated URL not written: {third}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_tls_ca_persisted() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("XDG_CONFIG_HOME", td.path());
|
||||
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
|
||||
let _ = fs::remove_file(profiles_path());
|
||||
let (_ok, _out) = run_socktop(&[
|
||||
"--profile",
|
||||
"secureX",
|
||||
"--tls-ca",
|
||||
"/tmp/cert.pem",
|
||||
"wss://host/ws",
|
||||
"--dry-run",
|
||||
]);
|
||||
let data = fs::read_to_string(profiles_path()).unwrap();
|
||||
assert!(data.contains("secureX"));
|
||||
assert!(data.contains("cert.pem"));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use socktop::ws::{connect, request_metrics, request_processes};
|
||||
|
||||
// Integration probe: only runs when SOCKTOP_WS is set to an agent WebSocket URL.
|
||||
// Example: SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop --test ws_probe -- --nocapture
|
||||
#[tokio::test]
|
||||
async fn probe_ws_endpoints() {
|
||||
// Gate the test to avoid CI failures when no agent is running.
|
||||
let url = match std::env::var("SOCKTOP_WS") {
|
||||
Ok(v) if !v.is_empty() => v,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"skipping ws_probe: set SOCKTOP_WS=ws://host:port/ws to run this integration test"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Optional pinned CA for WSS/self-signed setups
|
||||
let tls_ca = std::env::var("SOCKTOP_TLS_CA").ok();
|
||||
let mut ws = connect(&url, tls_ca.as_deref()).await.expect("connect ws");
|
||||
|
||||
// Should get fast metrics quickly
|
||||
let m = request_metrics(&mut ws).await;
|
||||
assert!(m.is_some(), "expected Metrics payload within timeout");
|
||||
|
||||
// Processes may be gzipped and a bit slower, but should arrive
|
||||
let p = request_processes(&mut ws).await;
|
||||
assert!(p.is_some(), "expected Processes payload within timeout");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "socktop_agent"
|
||||
version = "0.1.3"
|
||||
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
|
||||
description = "Remote system monitor over WebSocket, TUI like top"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
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"
|
||||
once_cell = "1.19"
|
||||
axum-server = { version = "0.6", features = ["tls-rustls"] }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2.1"
|
||||
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build)
|
||||
anyhow = "1"
|
||||
hostname = "0.3"
|
||||
prost = { workspace = true }
|
||||
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
|
||||
|
||||
[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"
|
||||
@@ -0,0 +1,14 @@
|
||||
fn main() {
|
||||
// Vendored protoc for reproducible builds
|
||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
|
||||
std::env::set_var("PROTOC", &protoc);
|
||||
|
||||
println!("cargo:rerun-if-changed=proto/processes.proto");
|
||||
|
||||
// Compile protobuf definitions for processes
|
||||
let mut cfg = prost_build::Config::new();
|
||||
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
|
||||
// Use local path (ensures file is inside published crate tarball)
|
||||
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
|
||||
.expect("compile protos");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
syntax = "proto3";
|
||||
package socktop;
|
||||
|
||||
// All running processes. Sorting is done client-side.
|
||||
message Processes {
|
||||
uint64 process_count = 1; // total processes in the system
|
||||
repeated Process rows = 2; // all processes
|
||||
}
|
||||
|
||||
message Process {
|
||||
uint32 pid = 1;
|
||||
string name = 2;
|
||||
float cpu_usage = 3; // 0..100
|
||||
uint64 mem_bytes = 4; // RSS bytes
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// gpu.rs
|
||||
use gfxinfo::active_gpu;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct GpuMetrics {
|
||||
pub name: String,
|
||||
pub utilization_gpu_pct: u32, // 0..100
|
||||
pub mem_used_bytes: u64,
|
||||
pub mem_total_bytes: u64,
|
||||
}
|
||||
|
||||
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
|
||||
let gpu = active_gpu()?; // Use ? to unwrap Result
|
||||
let info = gpu.info();
|
||||
|
||||
let metrics = GpuMetrics {
|
||||
name: gpu.model().to_string(),
|
||||
utilization_gpu_pct: info.load_pct() as u32,
|
||||
mem_used_bytes: info.used_vram(),
|
||||
mem_total_bytes: info.total_vram(),
|
||||
};
|
||||
|
||||
Ok(vec![metrics])
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
|
||||
|
||||
mod gpu;
|
||||
mod metrics;
|
||||
mod proto;
|
||||
// sampler module removed (metrics now purely request-driven)
|
||||
mod state;
|
||||
mod types;
|
||||
mod ws;
|
||||
|
||||
use axum::{http::StatusCode, routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
mod tls;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
fn arg_flag(name: &str) -> bool {
|
||||
std::env::args().any(|a| a == name)
|
||||
}
|
||||
fn arg_value(name: &str) -> Option<String> {
|
||||
let mut it = std::env::args();
|
||||
while let Some(a) = it.next() {
|
||||
if a == name {
|
||||
return it.next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// (tests moved to end of file to satisfy clippy::items_after_test_module)
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Version flag (print and exit). Keep before heavy initialization.
|
||||
if arg_flag("--version") || arg_flag("-V") {
|
||||
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let state = AppState::new();
|
||||
|
||||
// No background samplers: metrics collected on-demand per websocket request.
|
||||
|
||||
// Web app: route /ws to the websocket handler
|
||||
async fn healthz() -> StatusCode {
|
||||
println!("/healthz request");
|
||||
StatusCode::OK
|
||||
}
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws::ws_handler))
|
||||
.route("/healthz", get(healthz))
|
||||
.with_state(state.clone());
|
||||
|
||||
let enable_ssl =
|
||||
arg_flag("--enableSSL") || std::env::var("SOCKTOP_ENABLE_SSL").ok().as_deref() == Some("1");
|
||||
if enable_ssl {
|
||||
// Port can be overridden by --port or SOCKTOP_PORT; default to 8443 when SSL
|
||||
let port = arg_value("--port")
|
||||
.or_else(|| arg_value("-p"))
|
||||
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(8443);
|
||||
|
||||
let (cert_path, key_path) = tls::ensure_self_signed_cert()?;
|
||||
let cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
|
||||
|
||||
let addr = SocketAddr::from_str(&format!("0.0.0.0:{port}"))?;
|
||||
println!("socktop_agent: TLS enabled. Listening on wss://{addr}/ws");
|
||||
axum_server::bind_rustls(addr, cfg)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Non-TLS HTTP/WS path
|
||||
let port = arg_value("--port")
|
||||
.or_else(|| arg_value("-p"))
|
||||
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(3000);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
println!("socktop_agent: Listening on ws://{addr}/ws");
|
||||
axum_server::bind(addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Unit tests for CLI parsing moved to `tests/port_parse.rs`.
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Metrics collection using sysinfo for socktop_agent.
|
||||
|
||||
use crate::gpu::collect_all_gpus;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload};
|
||||
use once_cell::sync::OnceCell;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::io;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration as StdDuration;
|
||||
use std::time::{Duration, Instant};
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
|
||||
use tracing::warn;
|
||||
|
||||
// Runtime toggles (read once)
|
||||
fn gpu_enabled() -> bool {
|
||||
static ON: OnceCell<bool> = OnceCell::new();
|
||||
*ON.get_or_init(|| {
|
||||
std::env::var("SOCKTOP_AGENT_GPU")
|
||||
.map(|v| v != "0")
|
||||
.unwrap_or(true)
|
||||
})
|
||||
}
|
||||
fn temp_enabled() -> bool {
|
||||
static ON: OnceCell<bool> = OnceCell::new();
|
||||
*ON.get_or_init(|| {
|
||||
std::env::var("SOCKTOP_AGENT_TEMP")
|
||||
.map(|v| v != "0")
|
||||
.unwrap_or(true)
|
||||
})
|
||||
}
|
||||
|
||||
// Tiny TTL caches to avoid rescanning sensors every 500ms
|
||||
const TTL: Duration = Duration::from_millis(1500);
|
||||
struct TempCache {
|
||||
at: Option<Instant>,
|
||||
v: Option<f32>,
|
||||
}
|
||||
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
|
||||
|
||||
struct GpuCache {
|
||||
at: Option<Instant>,
|
||||
v: Option<Vec<crate::gpu::GpuMetrics>>,
|
||||
}
|
||||
static GPUC: OnceCell<Mutex<GpuCache>> = OnceCell::new();
|
||||
|
||||
fn cached_temp() -> Option<f32> {
|
||||
if !temp_enabled() {
|
||||
return None;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let lock = TEMP.get_or_init(|| Mutex::new(TempCache { at: None, v: None }));
|
||||
let mut c = lock.lock().ok()?;
|
||||
if c.at.is_none_or(|t| now.duration_since(t) >= TTL) {
|
||||
c.at = Some(now);
|
||||
// caller will fill this; we just hold a slot
|
||||
c.v = None;
|
||||
}
|
||||
c.v
|
||||
}
|
||||
|
||||
fn set_temp(v: Option<f32>) {
|
||||
if let Some(lock) = TEMP.get() {
|
||||
if let Ok(mut c) = lock.lock() {
|
||||
c.v = v;
|
||||
c.at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_gpus() -> Option<Vec<crate::gpu::GpuMetrics>> {
|
||||
if !gpu_enabled() {
|
||||
return None;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let lock = GPUC.get_or_init(|| Mutex::new(GpuCache { at: None, v: None }));
|
||||
let mut c = lock.lock().ok()?;
|
||||
if c.at.is_none_or(|t| now.duration_since(t) >= TTL) {
|
||||
// mark stale; caller will refresh
|
||||
c.at = Some(now);
|
||||
c.v = None;
|
||||
}
|
||||
c.v.clone()
|
||||
}
|
||||
|
||||
fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
|
||||
if let Some(lock) = GPUC.get() {
|
||||
if let Ok(mut c) = lock.lock() {
|
||||
c.v = v.clone();
|
||||
c.at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
|
||||
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
// TTL (ms) overridable via env, default 250ms
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(250);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_metrics.lock().await;
|
||||
if cache.is_fresh(ttl) {
|
||||
if let Some(c) = cache.take_clone() {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sys = state.sys.lock().await;
|
||||
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
})) {
|
||||
warn!("sysinfo selective refresh panicked: {e:?}");
|
||||
}
|
||||
|
||||
let hostname = state.hostname.clone();
|
||||
let cpu_total = sys.global_cpu_usage();
|
||||
let cpu_per_core: Vec<f32> = sys.cpus().iter().map(|c| c.cpu_usage()).collect();
|
||||
let mem_total = sys.total_memory();
|
||||
let mem_used = mem_total.saturating_sub(sys.available_memory());
|
||||
let swap_total = sys.total_swap();
|
||||
let swap_used = sys.used_swap();
|
||||
drop(sys);
|
||||
|
||||
// CPU temperature: only refresh sensors if cache is stale
|
||||
let cpu_temp_c = if cached_temp().is_some() {
|
||||
cached_temp()
|
||||
} else if temp_enabled() {
|
||||
let val = {
|
||||
let mut components = state.components.lock().await;
|
||||
components.refresh(false);
|
||||
components.iter().find_map(|c| {
|
||||
let l = c.label().to_ascii_lowercase();
|
||||
if l.contains("cpu")
|
||||
|| l.contains("package")
|
||||
|| l.contains("tctl")
|
||||
|| l.contains("tdie")
|
||||
{
|
||||
c.temperature()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
set_temp(val);
|
||||
val
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Networks
|
||||
let networks: Vec<NetworkInfo> = {
|
||||
let mut nets = state.networks.lock().await;
|
||||
nets.refresh(false);
|
||||
nets.iter()
|
||||
.map(|(name, data)| NetworkInfo {
|
||||
name: name.to_string(),
|
||||
received: data.total_received(),
|
||||
transmitted: data.total_transmitted(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// GPUs: if we already determined none exist, short-circuit (no repeated probing)
|
||||
let gpus = if gpu_enabled() {
|
||||
if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
None
|
||||
} else if cached_gpus().is_some() {
|
||||
cached_gpus()
|
||||
} else {
|
||||
let v = match collect_all_gpus() {
|
||||
Ok(v) if !v.is_empty() => Some(v),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("gpu collection failed: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
// First probe records presence; subsequent calls rely on cache flags.
|
||||
if !state
|
||||
.gpu_checked
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
if v.is_some() {
|
||||
state
|
||||
.gpu_present
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
} else {
|
||||
state
|
||||
.gpu_present
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
set_gpus(v.clone());
|
||||
v
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let metrics = Metrics {
|
||||
cpu_total,
|
||||
cpu_per_core,
|
||||
mem_total,
|
||||
mem_used,
|
||||
swap_total,
|
||||
swap_used,
|
||||
hostname,
|
||||
cpu_temp_c,
|
||||
disks: Vec::new(),
|
||||
networks,
|
||||
top_processes: Vec::new(),
|
||||
gpus,
|
||||
};
|
||||
{
|
||||
let mut cache = state.cache_metrics.lock().await;
|
||||
cache.set(metrics.clone());
|
||||
}
|
||||
metrics
|
||||
}
|
||||
|
||||
// Cached disks
|
||||
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1_000);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_disks.lock().await;
|
||||
if cache.is_fresh(ttl) {
|
||||
if let Some(v) = cache.take_clone() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut disks_list = state.disks.lock().await;
|
||||
disks_list.refresh(false); // don't drop missing disks
|
||||
let disks: Vec<DiskInfo> = disks_list
|
||||
.iter()
|
||||
.map(|d| DiskInfo {
|
||||
name: d.name().to_string_lossy().into_owned(),
|
||||
total: d.total_space(),
|
||||
available: d.available_space(),
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let mut cache = state.cache_disks.lock().await;
|
||||
cache.set(disks.clone());
|
||||
}
|
||||
disks
|
||||
}
|
||||
|
||||
// Linux-only helpers and implementation using /proc deltas for accurate CPU%.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[inline]
|
||||
fn read_total_jiffies() -> io::Result<u64> {
|
||||
// /proc/stat first line: "cpu user nice system idle iowait irq softirq steal ..."
|
||||
let s = fs::read_to_string("/proc/stat")?;
|
||||
if let Some(line) = s.lines().next() {
|
||||
let mut it = line.split_whitespace();
|
||||
let _cpu = it.next(); // "cpu"
|
||||
let mut sum: u64 = 0;
|
||||
for tok in it.take(8) {
|
||||
if let Ok(v) = tok.parse::<u64>() {
|
||||
sum = sum.saturating_add(v);
|
||||
}
|
||||
}
|
||||
return Ok(sum);
|
||||
}
|
||||
Err(io::Error::other("no cpu line"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[inline]
|
||||
fn read_proc_jiffies(pid: u32) -> Option<u64> {
|
||||
let path = format!("/proc/{pid}/stat");
|
||||
let s = fs::read_to_string(path).ok()?;
|
||||
// Find the right parenthesis that terminates comm; everything after is space-separated fields starting at "state"
|
||||
let rpar = s.rfind(')')?;
|
||||
let after = s.get(rpar + 2..)?; // skip ") "
|
||||
let mut it = after.split_whitespace();
|
||||
// utime (14th field) is offset 11 from "state", stime (15th) is next
|
||||
let utime = it.nth(11)?.parse::<u64>().ok()?;
|
||||
let stime = it.next()?.parse::<u64>().ok()?;
|
||||
Some(utime.saturating_add(stime))
|
||||
}
|
||||
|
||||
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1_000);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_processes.lock().await;
|
||||
if cache.is_fresh(ttl) {
|
||||
if let Some(v) = cache.take_clone() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reuse shared System to avoid reallocation; refresh processes fully.
|
||||
let mut sys_guard = state.sys.lock().await;
|
||||
let sys = &mut *sys_guard;
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::All,
|
||||
false,
|
||||
ProcessRefreshKind::everything().without_tasks(),
|
||||
);
|
||||
|
||||
let total_count = sys.processes().len();
|
||||
|
||||
// Snapshot current per-pid jiffies
|
||||
let mut current: HashMap<u32, u64> = HashMap::with_capacity(total_count);
|
||||
for p in sys.processes().values() {
|
||||
let pid = p.pid().as_u32();
|
||||
if let Some(j) = read_proc_jiffies(pid) {
|
||||
current.insert(pid, j);
|
||||
}
|
||||
}
|
||||
let total_now = read_total_jiffies().unwrap_or(0);
|
||||
|
||||
// Compute deltas vs last sample
|
||||
let (last_total, mut last_map) = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut t = state.proc_cpu.lock().await;
|
||||
let lt = t.last_total;
|
||||
let lm = std::mem::take(&mut t.last_per_pid);
|
||||
t.last_total = total_now;
|
||||
t.last_per_pid = current.clone();
|
||||
(lt, lm)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _: u64 = total_now; // silence unused warning
|
||||
(0u64, HashMap::new())
|
||||
}
|
||||
};
|
||||
|
||||
// On first run or if total delta is tiny, report zeros
|
||||
if last_total == 0 || total_now <= last_total {
|
||||
let procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| ProcessInfo {
|
||||
pid: p.pid().as_u32(),
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
cpu_usage: 0.0,
|
||||
mem_bytes: p.memory(),
|
||||
})
|
||||
.collect();
|
||||
return ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: procs,
|
||||
};
|
||||
}
|
||||
|
||||
let dt = total_now.saturating_sub(last_total).max(1) as f32;
|
||||
|
||||
let procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| {
|
||||
let pid = p.pid().as_u32();
|
||||
let now = current.get(&pid).copied().unwrap_or(0);
|
||||
let prev = last_map.remove(&pid).unwrap_or(0);
|
||||
let du = now.saturating_sub(prev) as f32;
|
||||
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
|
||||
ProcessInfo {
|
||||
pid,
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
cpu_usage: cpu,
|
||||
mem_bytes: p.memory(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: procs,
|
||||
};
|
||||
{
|
||||
let mut cache = state.cache_processes.lock().await;
|
||||
cache.set(payload.clone());
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
/// Collect all processes (non-Linux): use sysinfo's internal CPU% by doing a double refresh.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
use tokio::time::sleep;
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1_000);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
{
|
||||
let cache = state.cache_processes.lock().await;
|
||||
if cache.is_fresh(ttl) {
|
||||
if let Some(v) = cache.take_clone() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut sys = state.sys.lock().await;
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::All,
|
||||
false,
|
||||
ProcessRefreshKind::everything().without_tasks(),
|
||||
);
|
||||
}
|
||||
// Release lock during sleep interval
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
{
|
||||
let mut sys = state.sys.lock().await;
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::All,
|
||||
false,
|
||||
ProcessRefreshKind::everything().without_tasks(),
|
||||
);
|
||||
let total_count = sys.processes().len();
|
||||
let procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| ProcessInfo {
|
||||
pid: p.pid().as_u32(),
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
cpu_usage: p.cpu_usage(),
|
||||
mem_bytes: p.memory(),
|
||||
})
|
||||
.collect();
|
||||
let payload = ProcessesPayload {
|
||||
process_count: total_count,
|
||||
top_processes: procs,
|
||||
};
|
||||
{
|
||||
let mut cache = state.cache_processes.lock().await;
|
||||
cache.set(payload.clone());
|
||||
}
|
||||
payload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Generated protobuf modules live under OUT_DIR; include them here.
|
||||
// This module will expose socktop::Processes and socktop::Process types.
|
||||
pub mod pb {
|
||||
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Shared agent state: sysinfo handles and hot JSON cache.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub type SharedSystem = Arc<Mutex<System>>;
|
||||
pub type SharedComponents = Arc<Mutex<Components>>;
|
||||
pub type SharedDisks = Arc<Mutex<Disks>>;
|
||||
pub type SharedNetworks = Arc<Mutex<Networks>>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
pub struct ProcCpuTracker {
|
||||
pub last_total: u64,
|
||||
pub last_per_pid: HashMap<u32, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub sys: SharedSystem,
|
||||
pub components: SharedComponents,
|
||||
pub disks: SharedDisks,
|
||||
pub networks: SharedNetworks,
|
||||
pub hostname: String,
|
||||
|
||||
// For correct per-process CPU% using /proc deltas (Linux only path uses this tracker)
|
||||
#[cfg(target_os = "linux")]
|
||||
pub proc_cpu: Arc<Mutex<ProcCpuTracker>>,
|
||||
|
||||
// Connection tracking (to allow future idle sleeps if desired)
|
||||
pub client_count: Arc<AtomicUsize>,
|
||||
|
||||
pub auth_token: Option<String>,
|
||||
// GPU negative cache (probe once). gpu_checked=true after first attempt; gpu_present reflects result.
|
||||
pub gpu_checked: Arc<AtomicBool>,
|
||||
pub gpu_present: Arc<AtomicBool>,
|
||||
|
||||
// Lightweight on-demand caches (TTL based) to cap CPU under bursty polling.
|
||||
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
|
||||
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
|
||||
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CacheEntry<T> {
|
||||
pub at: Option<Instant>,
|
||||
pub value: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
at: None,
|
||||
value: None,
|
||||
}
|
||||
}
|
||||
pub fn is_fresh(&self, ttl: Duration) -> bool {
|
||||
self.at.is_some_and(|t| t.elapsed() < ttl) && self.value.is_some()
|
||||
}
|
||||
pub fn set(&mut self, v: T) {
|
||||
self.value = Some(v);
|
||||
self.at = Some(Instant::now());
|
||||
}
|
||||
pub fn take_clone(&self) -> Option<T>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let sys = System::new();
|
||||
let components = Components::new_with_refreshed_list();
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
|
||||
Self {
|
||||
sys: Arc::new(Mutex::new(sys)),
|
||||
components: Arc::new(Mutex::new(components)),
|
||||
disks: Arc::new(Mutex::new(disks)),
|
||||
networks: Arc::new(Mutex::new(networks)),
|
||||
hostname: System::host_name().unwrap_or_else(|| "unknown".into()),
|
||||
#[cfg(target_os = "linux")]
|
||||
proc_cpu: Arc::new(Mutex::new(ProcCpuTracker::default())),
|
||||
client_count: Arc::new(AtomicUsize::new(0)),
|
||||
auth_token: std::env::var("SOCKTOP_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
gpu_checked: Arc::new(AtomicBool::new(false)),
|
||||
gpu_present: Arc::new(AtomicBool::new(false)),
|
||||
cache_metrics: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_disks: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_processes: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, IsCa, SanType};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("socktop_agent")
|
||||
.join("tls")
|
||||
}
|
||||
|
||||
pub fn cert_paths() -> (PathBuf, PathBuf) {
|
||||
let dir = config_dir();
|
||||
(dir.join("cert.pem"), dir.join("key.pem"))
|
||||
}
|
||||
|
||||
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
|
||||
let (cert_path, key_path) = cert_paths();
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
return Ok((cert_path, key_path));
|
||||
}
|
||||
fs::create_dir_all(cert_path.parent().unwrap())?;
|
||||
|
||||
let hostname = hostname::get()
|
||||
.ok()
|
||||
.and_then(|s| s.into_string().ok())
|
||||
.unwrap_or_else(|| "localhost".to_string());
|
||||
|
||||
let mut params = CertificateParams::new(vec![hostname.clone(), "localhost".into()])?;
|
||||
params
|
||||
.subject_alt_names
|
||||
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V6(
|
||||
::std::net::Ipv6Addr::LOCALHOST,
|
||||
)));
|
||||
params
|
||||
.subject_alt_names
|
||||
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
|
||||
|
||||
// Allow operator to provide extra SANs (comma-separated), e.g. IPs or DNS names
|
||||
if let Ok(extra) = std::env::var("SOCKTOP_AGENT_EXTRA_SANS") {
|
||||
for raw in extra.split(',') {
|
||||
let s = raw.trim();
|
||||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ip) = s.parse::<IpAddr>() {
|
||||
params.subject_alt_names.push(SanType::IpAddress(ip));
|
||||
} else {
|
||||
match s.to_string().try_into() {
|
||||
Ok(dns) => params.subject_alt_names.push(SanType::DnsName(dns)),
|
||||
Err(_) => eprintln!("socktop_agent: ignoring invalid SAN entry: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, hostname.clone());
|
||||
params.distinguished_name = dn;
|
||||
params.is_ca = IsCa::NoCa;
|
||||
// Dynamic validity: start slightly in the past to avoid clock skew issues, end ~397 days later
|
||||
let now = OffsetDateTime::now_utc();
|
||||
params.not_before = now - Duration::minutes(5);
|
||||
params.not_after = now + Duration::days(397);
|
||||
|
||||
// Generate key pair (default is ECDSA P256 SHA256)
|
||||
let key_pair = rcgen::KeyPair::generate()?; // defaults to ECDSA P256 SHA256
|
||||
let cert = params.self_signed(&key_pair)?;
|
||||
let cert_pem = cert.pem();
|
||||
let key_pem = key_pair.serialize_pem();
|
||||
|
||||
let mut f = fs::File::create(&cert_path)?;
|
||||
f.write_all(cert_pem.as_bytes())?;
|
||||
let mut k = fs::File::create(&key_path)?;
|
||||
k.write_all(key_pem.as_bytes())?;
|
||||
|
||||
println!(
|
||||
"socktop_agent: generated self-signed TLS certificate at {}",
|
||||
cert_path.display()
|
||||
);
|
||||
println!("socktop_agent: private key at {}", key_path.display());
|
||||
Ok((cert_path, key_path))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Data types sent to the client over WebSocket.
|
||||
//! Keep this module minimal and stable — it defines the wire format.
|
||||
|
||||
use crate::gpu::GpuMetrics;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NetworkInfo {
|
||||
pub name: String,
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Metrics {
|
||||
pub cpu_total: f32,
|
||||
pub cpu_per_core: Vec<f32>,
|
||||
pub mem_total: u64,
|
||||
pub mem_used: u64,
|
||||
pub swap_total: u64,
|
||||
pub swap_used: u64,
|
||||
pub hostname: String,
|
||||
pub cpu_temp_c: Option<f32>,
|
||||
pub disks: Vec<DiskInfo>,
|
||||
pub networks: Vec<NetworkInfo>,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
pub gpus: Option<Vec<GpuMetrics>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessesPayload {
|
||||
pub process_count: usize,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! WebSocket upgrade and per-connection handler (request-driven).
|
||||
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket},
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
response::Response,
|
||||
};
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all};
|
||||
use crate::proto::pb;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// optional auth
|
||||
if let Some(expected) = state.auth_token.as_ref() {
|
||||
if q.get("token") != Some(expected) {
|
||||
return ws.on_upgrade(|socket| async move {
|
||||
let _ = socket.close().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
state
|
||||
.client_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
while let Some(Ok(msg)) = socket.next().await {
|
||||
match msg {
|
||||
Message::Text(ref text) if text == "get_metrics" => {
|
||||
let m = collect_fast_metrics(&state).await;
|
||||
let _ = send_json(&mut socket, &m).await;
|
||||
}
|
||||
Message::Text(ref text) if text == "get_disks" => {
|
||||
let d = collect_disks(&state).await;
|
||||
let _ = send_json(&mut socket, &d).await;
|
||||
}
|
||||
Message::Text(ref text) if text == "get_processes" => {
|
||||
let payload = collect_processes_all(&state).await;
|
||||
// Map to protobuf message
|
||||
let rows: Vec<pb::Process> = payload
|
||||
.top_processes
|
||||
.into_iter()
|
||||
.map(|p| pb::Process {
|
||||
pid: p.pid,
|
||||
name: p.name,
|
||||
cpu_usage: p.cpu_usage,
|
||||
mem_bytes: p.mem_bytes,
|
||||
})
|
||||
.collect();
|
||||
let pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows,
|
||||
};
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else {
|
||||
// compress if large
|
||||
if buf.len() <= 768 {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
|
||||
if enc.write_all(&buf).is_ok() {
|
||||
let bin = enc.finish().unwrap_or(buf);
|
||||
let _ = socket.send(Message::Binary(bin)).await;
|
||||
} else {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
state
|
||||
.client_count
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Small, cheap gzip for larger payloads; send text for small.
|
||||
async fn send_json<T: serde::Serialize>(ws: &mut WebSocket, value: &T) -> Result<(), axum::Error> {
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
if json.len() <= 768 {
|
||||
return ws.send(Message::Text(json)).await;
|
||||
}
|
||||
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
|
||||
enc.write_all(json.as_bytes()).ok();
|
||||
let bin = enc.finish().unwrap_or_else(|_| json.into_bytes());
|
||||
ws.send(Message::Binary(bin)).await
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! CLI arg parsing tests for socktop_agent (server)
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_help_and_port_short_long() {
|
||||
// We verify port flags are accepted by ensuring the process starts (then we kill quickly).
|
||||
// Use an unlikely port to avoid conflicts.
|
||||
let exe = env!("CARGO_BIN_EXE_socktop_agent");
|
||||
|
||||
// TLS enabled with long --port
|
||||
let mut child = Command::new(exe)
|
||||
.args(["--enableSSL", "--port", "9555"])
|
||||
.spawn()
|
||||
.expect("spawn agent");
|
||||
// Give it a moment to bind
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
// TLS enabled with short -p
|
||||
let mut child2 = Command::new(exe)
|
||||
.args(["--enableSSL", "-p", "9556"])
|
||||
.spawn()
|
||||
.expect("spawn agent");
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let _ = child2.kill();
|
||||
let _ = child2.wait();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Unit test for port parsing logic moved out of `main.rs`.
|
||||
|
||||
fn parse_port<I: IntoIterator<Item = String>>(args: I, default_port: u16) -> u16 {
|
||||
let mut it = args.into_iter();
|
||||
let _ = it.next(); // program name
|
||||
let mut long: Option<String> = None;
|
||||
let mut short: Option<String> = None;
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--port" => long = it.next(),
|
||||
"-p" => short = it.next(),
|
||||
_ if a.starts_with("--port=") => {
|
||||
if let Some((_, v)) = a.split_once('=') {
|
||||
long = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
long.or(short)
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(default_port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_long_short_and_assign() {
|
||||
assert_eq!(
|
||||
parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443),
|
||||
9001
|
||||
);
|
||||
assert_eq!(
|
||||
parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443),
|
||||
9002
|
||||
);
|
||||
assert_eq!(
|
||||
parse_port(vec!["agent".into(), "--port=9003".into()], 8443),
|
||||
9003
|
||||
);
|
||||
assert_eq!(parse_port(vec!["agent".into()], 8443), 8443);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
fn expected_paths(config_home: &std::path::Path) -> (PathBuf, PathBuf) {
|
||||
let base = config_home.join("socktop_agent").join("tls");
|
||||
(base.join("cert.pem"), base.join("key.pem"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_self_signed_cert_and_key_in_xdg_path() {
|
||||
// Create an isolated fake XDG_CONFIG_HOME
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let xdg = tmpdir.path().to_path_buf();
|
||||
|
||||
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed
|
||||
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists");
|
||||
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
|
||||
cmd.env("XDG_CONFIG_HOME", &xdg)
|
||||
.arg("--enableSSL")
|
||||
.arg("-p")
|
||||
.arg("0");
|
||||
|
||||
// Spawn the process and poll for cert generation
|
||||
let mut child = cmd.spawn().expect("spawn agent");
|
||||
|
||||
// Poll up to ~3s for files to appear to avoid timing flakes
|
||||
let (cert_path, key_path) = expected_paths(&xdg);
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(3000);
|
||||
let interval = Duration::from_millis(50);
|
||||
while start.elapsed() < timeout {
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(interval);
|
||||
}
|
||||
|
||||
// Terminate the process regardless
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
// Verify files exist at expected paths
|
||||
assert!(
|
||||
cert_path.exists(),
|
||||
"cert not found at {}",
|
||||
cert_path.display()
|
||||
);
|
||||
assert!(key_path.exists(), "key not found at {}", key_path.display());
|
||||
|
||||
// Also ensure they are non-empty
|
||||
let cert_md = fs::metadata(&cert_path).expect("cert metadata");
|
||||
let key_md = fs::metadata(&key_path).expect("key metadata");
|
||||
assert!(cert_md.len() > 0, "cert is empty");
|
||||
assert!(key_md.len() > 0, "key is empty");
|
||||
}
|
||||