Compare commits

...

28 Commits

Author SHA1 Message Date
jasonwitty 25837063f5 feat(ui): compact layout for small terminal windows
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
In a short window the fixed root layout runs out of rows and the CPU graph and
per-core bars are what collapse first: the header, gauges and process table hold
fixed heights, so at ~18 rows the top row is left with no drawable interior and
both panes disappear entirely.

Add a second layout, entered automatically once the Disks pane can no longer show
even one complete disk card:

- Disks is dropped — it is the pane that degrades worst when partly drawn.
- Memory and Swap move side by side into the row Disks vacated.
- GPU collapses to a single full-width line (utilisation and VRAM, no device
  name), and is omitted entirely on a host that reports no GPU.
- The reclaimed rows go to the CPU panes, with the surplus above their floor
  shared with the bottom half so the process table still grows with the window.

`--compact` pins the layout at any size.

The root layout was duplicated in three places (the draw path and both input
hit-testing paths), which would have drifted the moment a second layout existed.
Move it into ui::layout as the single source of truth and have all three callers
go through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:13:43 -07:00
jason 20966d0c94 fix(socktop): explain how to install socktop_agent when demo mode can't find it (#36)
Demo mode spawns a socktop_agent child process, but the agent is a separate
crate that `cargo install socktop` does not pull in. When it was missing, the
raw spawn error propagated to main and printed as
`Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }`,
which gives the user nothing to act on.

Introduce DemoAgentError so a NotFound spawn failure is distinguishable from
other io errors, and print the path we looked for plus the `cargo install
socktop_agent` fix. Other spawn errors still propagate as before.

Co-authored-by: Jason Witty <jason@localhost-live.localdomain>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:10:46 -07:00
jason f95a64a18b fix(deps): bump aws-lc-rs 1.15.0 -> 1.17.1 to patch aws-lc-sys advisories (#34)
Resolves all five open Dependabot alerts (GHSA-9f94-5g5w-gf6r,
GHSA-394x-vwmw-crm3, GHSA-hfpc-8r3f-gw53, GHSA-65p9-r9h6-22vj,
GHSA-vw5v-4f2q-w9xf) by moving aws-lc-sys from 0.33.0 to 0.42.0.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:52:37 -07:00
jason 1c0c44ec3c fix(agent): gate now binding to logging feature
The Windows CI matrix surfaced an `unused_variables` warning at
metrics.rs:846 — `let now = std::time::Instant::now();` was bound
unconditionally but only consumed inside a `#[cfg(feature = "logging")]`
tracing::debug! call.

This block lives in the non-Linux `collect_processes_all`, so the Linux
CI never compiles it and never sees the warning. Same shape as the
`processes_ttl_ms` Windows fix from earlier: a binding whose only
consumer is cfg-gated needs to be cfg-gated too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 11:15:45 -07:00
jason ee4468ca23 Add Debian packaging support for socktop-agent (#25)
* Add Debian packaging support with cargo-deb

- Add cargo-deb metadata to socktop and socktop_agent Cargo.toml
- Create systemd service file for socktop_agent
- Add postinst/postrm maintainer scripts for user/group management
- Create GitHub Actions workflow to build .deb packages for AMD64 and ARM64
- Add comprehensive documentation in docs/DEBIAN_PACKAGING.md
- Packages will be available as artifacts on every push
- Automatic GitHub releases for version tags

* Add summary documentation for debian packaging

* fix unit test, move to macro cargo_bin!

* hotfix for issue with socktop agent not creating ssl certificate on first launch after upgrade of axum server version.

* Add helpful post-install message to guide users on enabling socktop-agent service

* Fix CI build by installing libdrm development dependencies

* Fix package rename script - cargo-deb already includes architecture in filename

* Make GPU support optional to enable RISC-V builds without libdrm

- Add 'gpu' feature flag (enabled by default)
- Make gfxinfo dependency optional
- Provide no-op GPU metrics when gpu feature disabled
- Disable GPU support for RISC-V builds in CI (libdrm unavailable)
- All other architectures (amd64, arm64, armhf) still get GPU support

* feature gate GPU stats for arm v7

* specify correct package names.

* install aarch64-linux-gnu-gcc build dep

* specify correct package name

* add RISC-V GCC compiler

* add .cargo to gitignore to elimicate issue with riscv64-linux-gnu-gcc linker in config.toml

* add gcc-arm-linux-gnueabihf linker fore armv7

* set correct x-compile lib gcc-aarch64-linux-gnu for arm64 builds.

* add ports.ubuntu.com to sources

* Add ARM64 as a foreign architecture

* fixe for ARM64 build.

* security.ubuntu.com` aNNOYING

* apt repo github page

* copy output to apt repo

* fix secrets path

* fix secrets path

* change build dep

* Fix postinst message box alignment

* ci(deb): restrict APT publish to v* release tags

Previously the workflow built and published on every push to master
and feature/debian-packaging in addition to v* tags. That meant the
gh-pages APT repo got overwritten on every commit with same-version
.debs, causing apt clients to see a phantom "update available" each
time and burning ~5-10 min of cross-compile CI per merge.

After this change:
  - PRs into master still cross-build .debs as a sanity check.
  - v* tags build, publish to gh-pages, and create a GitHub release.
  - workflow_dispatch remains as the manual escape hatch.
  - master pushes no longer trigger this workflow (ci.yml still runs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 13:12:51 -07:00
jason 697a77bdab chore: update ratatui from 0.28 to 0.30 (#33)
* chore: update ratatui from 0.28 to 0.30

* style: cargo fmt

* fix: replace manual zero-guarded divisions with checked_div

* fix: collapse nested if into match guard

* style: cargo fmt

* bump crossterm and optimize various types, remove stale code.

* fix windows build

* only show parent level processes on main tui
2026-06-02 13:02:25 -07:00
dependabot[bot] 8f452a35e6 Bump rand from 0.8.5 to 0.8.6 (#31)
Bumps [rand](https://github.com/rust-random/rand) from 0.8.5 to 0.8.6.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.8.6/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.8.5...0.8.6)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.8.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 13:01:57 -07:00
dependabot[bot] 1d285c3c4e Bump rustls-webpki from 0.103.10 to 0.103.13 (#32)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.10 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.10...v/0.103.13)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 13:01:30 -07:00
dependabot[bot] 0fb45f6c50 Bump rustls-webpki from 0.103.8 to 0.103.10 (#30)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.8 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.8...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:43:38 -07:00
dependabot[bot] be24fa3859 Bump bytes from 1.10.1 to 1.11.1 in /socktop_wasm_test (#27)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 16:14:57 -08:00
dependabot[bot] 39619b2845 Bump bytes from 1.11.0 to 1.11.1 (#28)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 16:14:46 -08:00
dependabot[bot] 40c925e7f9 Bump time from 0.3.44 to 0.3.47 (#29)
Bumps [time](https://github.com/time-rs/time) from 0.3.44 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.44...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 16:14:29 -08:00
jason 8f69e469e6 Update README with website link 2025-11-30 10:21:26 -08:00
jason 3024816525 hotfix for issue with socktop agent not creating ssl certificate on first launch after upgrade of axum server version.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-21 00:21:05 -08:00
jason 1d7bc42d59 fix unit test, move to macro cargo_bin! 2025-11-21 00:07:44 -08:00
jason 518ae8c2bf update axum server version
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-17 15:09:53 -08:00
jason 6eb1809309 set connector back to crate version 2025-11-17 14:15:39 -08:00
jason 1c01902a71 update cargo version number 2025-11-17 14:13:48 -08:00
jason 9d302ad475 patch header for small monitors and increase cargo version in advance of publish. 2025-11-17 11:52:22 -08:00
jason 7875f132f7 Make help modal scrollable for small resolutions
- Add Up/Down arrow key handling in help modal
- Display scrollbar when content exceeds viewport
- Update title to indicate scrollability
- Fixes content cutoff on small terminal windows
2025-11-17 11:29:23 -08:00
jason 0d789fb97c Add TUI improvements: CPU averaging, max memory tracking, and fuzzy process search (#23)
This commit implements several major improvements to the TUI experience:

1. CPU Average Display in Main Window
   - Show average CPU usage over monitoring period alongside current value
   - Format: "CPU avg (now: 45.2% | avg: 52.3%)"
   - Helps identify sustained vs momentary CPU spikes

2. Max Memory Tracking in Process Details Modal
   - Track and display peak memory usage since monitoring started
   - Shown as "Max Memory: 67.8 MB" in yellow for emphasis
   - Helps identify memory leaks and usage patterns
   - Resets when switching to different process

3. Fuzzy Process Search
   - Press / to activate search mode with bordered search box
   - Type to fuzzy-match process names (case-insensitive)
   - Press Enter to auto-select first result
   - Navigate results with arrow keys while typing
   - Press c to clear filter
   - Press / again to edit existing search

   Search box features:
   - Yellow bordered box for high visibility
   - Active mode: "Search: query_"
   - Filter mode: "Filter: query (press / to edit, c to clear)"

   Technical implementation:
   - Centralized filtering with get_filtered_sorted_indices()
   - Consistent filtering across display, navigation, mouse, and auto-scroll
   - Proper content area offset calculation for search box
   - Real-time filtering as user types

4. Code Quality Improvements
   - Created ProcessDisplayParams and ProcessKeyParams structs
   - Created MemoryIoParams struct for process modal rendering
   - Reduced function arguments to stay under clippy limits
   - Exported get_filtered_sorted_indices for reuse

Files Modified:
- socktop/src/app.rs: Search state, auto-scroll with filtering, max memory tracking
- socktop/src/ui/cpu.rs: CPU average calculation and display
- socktop/src/ui/processes.rs: Fuzzy search, filtering, parameter structs
- socktop/src/ui/modal.rs: Updated help modal with new shortcuts
- socktop/src/ui/modal_process.rs: Max memory display, MemoryIoParams struct
- socktop/src/ui/modal_types.rs: Added max_mem_bytes field

Testing:
- All tests pass
- No clippy warnings
- Cargo fmt applied
- Tested search, navigation, mouse clicks, and auto-scroll
- Verified on both filtered and unfiltered process lists

Breaking Changes:
- None (all changes are additive features)

Closes: (performance monitoring improvements)
2025-11-17 11:24:32 -08:00
jason 5ddaed298b Optimize socktop_agent for reduced binary size and memory footprint (#22)
This commit implements several optimizations to make socktop_agent
significantly more lightweight without sacrificing functionality.

Changes:

1. Reduced Tokio Runtime Thread Pool (main.rs)
   - Changed from default (num_cpus) to 2 worker threads
   - Configurable via SOCKTOP_WORKER_THREADS environment variable
   - Rationale: Agent is I/O-bound, not CPU-intensive
   - Memory savings: ~6-12 MB on typical 8-core systems

2. Minimal Tokio Features (Cargo.toml)
   - Changed from features = ["full"] to minimal set:
     ["rt-multi-thread", "net", "sync", "macros"]
   - Removed unused features: io, fs, process, signal, time
   - Binary size reduction: ~200-300 KB
   - Faster compile times

3. Optional Tracing (Cargo.toml, main.rs, metrics.rs)
   - Made tracing dependencies optional with "logging" feature flag
   - Disabled by default for production builds
   - Binary size reduction: 1.5 MB (27%!)
   - Enable with: cargo build --features logging

4. Cleanup (Cargo.toml)
   - Removed unused tokio-process dependency

Results:
- Binary size: 5.6 MB → 4.0 MB (28% reduction)
- Memory usage: 25-40 MB → 15-25 MB (30-40% reduction)
- Worker threads: 8+ → 2 (75% reduction on 8-core systems)

Testing:
- All tests pass with and without logging feature
- No clippy warnings
- Functionality unchanged
- Production-ready

Breaking Changes:
- None (all changes are backward compatible)
- Default behavior is now more lightweight
- Logging can be re-enabled with --features logging

To build with logging for debugging:
  cargo build --package socktop_agent --release --features logging
2025-11-17 09:51:41 -08:00
jason 1528568c30 Merge pull request #21 from jasonwitty/feature/about-modal
Feature/about modal
2025-11-17 00:18:55 -08:00
jason 6f238cdf25 tweak hotkeys, add info panel, optimize fonts and hotkeys for about and info panel.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-17 00:05:02 -08:00
jason ffe451edaa cargo fmt 2025-10-26 02:32:11 -07:00
jason c9bde52cb1 move logo to them file. 2025-10-26 02:30:46 -07:00
jason 0603746d7c cargo fmt 2025-10-26 02:18:01 -07:00
jason 25632f3427 Add About modal with sock ASCII art 2025-10-26 02:16:42 -07:00
51 changed files with 5083 additions and 2078 deletions
+422
View File
@@ -0,0 +1,422 @@
name: Build Debian Packages
on:
# APT publishing is release-driven: we build + publish only on `v*` tag
# pushes. PRs into master still build the .debs as a sanity check (no
# publish). Manual dispatch is kept as an escape hatch.
push:
tags:
- "v*"
pull_request:
branches:
- master
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build-deb:
name: Build .deb for ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
arch: amd64
- target: aarch64-unknown-linux-gnu
arch: arm64
- target: armv7-unknown-linux-gnueabihf
arch: armhf
- target: riscv64gc-unknown-linux-gnu
arch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev
- name: Install cross-compilation tools (ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo dpkg --add-architecture arm64
# Disable all existing sources and create new ones with proper arch specifications
sudo mv /etc/apt/sources.list /etc/apt/sources.list.backup
sudo mv /etc/apt/sources.list.d /etc/apt/sources.list.d.backup || true
sudo mkdir -p /etc/apt/sources.list.d
# Clear APT cache and lists
sudo rm -rf /var/lib/apt/lists/*
sudo mkdir -p /var/lib/apt/lists/partial
# Create new sources.list with both amd64 and arm64
cat << EOF | sudo tee /etc/apt/sources.list
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc) main universe restricted multiverse
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc)-updates main universe restricted multiverse
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc)-backports main universe restricted multiverse
deb [arch=amd64] http://security.ubuntu.com/ubuntu $(lsb_release -sc)-security main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc) main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-updates main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-backports main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-security main universe restricted multiverse
EOF
echo "=== Contents of /etc/apt/sources.list ==="
cat /etc/apt/sources.list
echo "=== Contents of /etc/apt/sources.list.d/ ==="
ls -la /etc/apt/sources.list.d/ || true
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu libdrm-dev:arm64 libdrm-amdgpu1:arm64
- name: Install cross-compilation tools (ARMhf)
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt-get update
sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Install cross-compilation tools (RISC-V)
if: matrix.target == 'riscv64gc-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-riscv64-linux-gnu
- name: Install GPU libraries (x86_64)
if: matrix.target == 'x86_64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y libdrm-dev libdrm-amdgpu1
- name: Configure cross-compilation (ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
mkdir -p .cargo
cat >> .cargo/config.toml << EOF
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
EOF
- name: Configure cross-compilation (ARMhf)
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
mkdir -p .cargo
cat >> .cargo/config.toml << EOF
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
EOF
- name: Configure cross-compilation (RISC-V)
if: matrix.target == 'riscv64gc-unknown-linux-gnu'
run: |
mkdir -p .cargo
cat >> .cargo/config.toml << EOF
[target.riscv64gc-unknown-linux-gnu]
linker = "riscv64-linux-gnu-gcc"
EOF
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
- name: Build socktop .deb package
run: |
cargo deb --package socktop --target ${{ matrix.target }} --no-strip
- name: Build socktop_agent .deb package (with GPU support)
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
run: |
cargo deb --package socktop_agent --target ${{ matrix.target }} --no-strip
- name: Build socktop_agent .deb package (without GPU support)
if: matrix.target == 'armv7-unknown-linux-gnueabihf' || matrix.target == 'riscv64gc-unknown-linux-gnu'
run: |
cargo deb --package socktop_agent --target ${{ matrix.target }} --no-strip --no-default-features
- name: Copy packages to debs directory
run: |
mkdir -p debs
cp target/${{ matrix.target }}/debian/*.deb debs/
- name: List generated packages
run: ls -lh debs/
- name: Upload .deb packages as artifacts
uses: actions/upload-artifact@v4
with:
name: debian-packages-${{ matrix.arch }}
path: debs/*.deb
if-no-files-found: error
retention-days: 90
# Combine all artifacts into a single downloadable archive
combine-artifacts:
name: Combine all .deb packages
needs: build-deb
runs-on: ubuntu-latest
steps:
- name: Download AMD64 packages
uses: actions/download-artifact@v4
with:
name: debian-packages-amd64
path: all-debs
- name: Download ARM64 packages
uses: actions/download-artifact@v4
with:
name: debian-packages-arm64
path: all-debs
- name: Download ARMhf packages
uses: actions/download-artifact@v4
with:
name: debian-packages-armhf
path: all-debs
- name: Download RISC-V packages
uses: actions/download-artifact@v4
with:
name: debian-packages-riscv64
path: all-debs
- name: List all packages
run: |
echo "All generated .deb packages:"
ls -lh all-debs/
- name: Upload combined artifacts
uses: actions/upload-artifact@v4
with:
name: all-debian-packages
path: all-debs/*.deb
if-no-files-found: error
retention-days: 90
- name: Generate checksums
run: |
cd all-debs
sha256sum *.deb > SHA256SUMS
cat SHA256SUMS
- name: Upload checksums
uses: actions/upload-artifact@v4
with:
name: checksums
path: all-debs/SHA256SUMS
retention-days: 90
# Publish packages to gh-pages APT repository
publish-apt-repo:
name: Publish to APT Repository
needs: combine-artifacts
runs-on: ubuntu-latest
# Publish only on `v*` release tags — keep gh-pages stable between
# releases instead of overwriting same-version .debs on every commit.
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download all packages
uses: actions/download-artifact@v4
with:
name: all-debian-packages
path: debs
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev gpg
- name: Checkout gh-pages branch
run: |
git fetch origin gh-pages:gh-pages || echo "gh-pages branch doesn't exist yet"
if git show-ref --verify --quiet refs/heads/gh-pages; then
git checkout gh-pages
else
git checkout --orphan gh-pages
git rm -rf . 2>/dev/null || true
# Create basic structure
mkdir -p dists/stable/main/{binary-amd64,binary-arm64,binary-armhf,binary-riscv64}
mkdir -p pool/main
fi
- name: Copy packages to pool
run: |
mkdir -p pool/main
cp debs/*.deb pool/main/
ls -lh pool/main/
- name: Generate Packages files
run: |
for arch in amd64 arm64 armhf riscv64; do
mkdir -p dists/stable/main/binary-$arch
dpkg-scanpackages --arch $arch pool/main /dev/null > dists/stable/main/binary-$arch/Packages 2>/dev/null || true
if [ -s dists/stable/main/binary-$arch/Packages ]; then
gzip -9 -k -f dists/stable/main/binary-$arch/Packages
echo "Generated Packages file for $arch"
fi
done
- name: Generate Release file
run: |
cat > dists/stable/Release << EOF
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf riscv64
Components: main
Description: socktop APT repository
Date: $(date -Ru)
EOF
# Add MD5Sum
echo "MD5Sum:" >> dists/stable/Release
for arch in amd64 arm64 armhf riscv64; do
for file in dists/stable/main/binary-$arch/Packages*; do
if [ -f "$file" ]; then
md5sum "$file" | awk '{print " " $1, "'$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)'", "'"${file#dists/stable/}"'"}' >> dists/stable/Release
fi
done
done
# Add SHA256
echo "SHA256:" >> dists/stable/Release
for arch in amd64 arm64 armhf riscv64; do
for file in dists/stable/main/binary-$arch/Packages*; do
if [ -f "$file" ]; then
sha256sum "$file" | awk '{print " " $1, "'$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)'", "'"${file#dists/stable/}"'"}' >> dists/stable/Release
fi
done
done
- name: Set GPG available flag
id: check_gpg
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "available=true" >> $GITHUB_OUTPUT
else
echo "available=false" >> $GITHUB_OUTPUT
fi
- name: Import GPG key
if: steps.check_gpg.outputs.available == 'true'
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys
- name: Sign repository
if: steps.check_gpg.outputs.available == 'true'
env:
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
if [ -n "$GPG_PASSPHRASE" ]; then
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
--default-key "$GPG_KEY_ID" \
-abs -o dists/stable/Release.gpg dists/stable/Release
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
--default-key "$GPG_KEY_ID" \
--clearsign -o dists/stable/InRelease dists/stable/Release
else
gpg --batch --yes --no-tty --pinentry-mode loopback \
--default-key "$GPG_KEY_ID" \
-abs -o dists/stable/Release.gpg dists/stable/Release
gpg --batch --yes --no-tty --pinentry-mode loopback \
--default-key "$GPG_KEY_ID" \
--clearsign -o dists/stable/InRelease dists/stable/Release
fi
gpg --armor --export "$GPG_KEY_ID" > KEY.gpg
echo "✓ Repository signed"
- name: Create unsigned repository notice
if: steps.check_gpg.outputs.available == 'false'
run: |
echo "⚠️ Warning: GPG_PRIVATE_KEY not set. Repository will be UNSIGNED."
echo "⚠️ Add GPG secrets to sign the repository automatically."
echo "To add secrets: Settings → Secrets and variables → Actions → Repository secrets"
- name: Copy index.html if exists
run: |
git checkout ${{ github.ref_name }} -- index.html 2>/dev/null || echo "No index.html in source branch"
- name: Commit and push to gh-pages
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add .
if git diff --staged --quiet; then
echo "No changes to commit"
else
COMMIT_MSG="Update APT repository"
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
COMMIT_MSG="$COMMIT_MSG - Release ${{ github.ref_name }}"
else
COMMIT_MSG="$COMMIT_MSG - $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
fi
git commit -m "$COMMIT_MSG"
git push origin gh-pages
echo "✓ Published to gh-pages"
fi
# Optional: Create a release with the .deb files if this is a tag
create-release:
name: Create GitHub Release
needs: combine-artifacts
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Download all packages
uses: actions/download-artifact@v4
with:
name: all-debian-packages
path: release-debs
- name: Download checksums
uses: actions/download-artifact@v4
with:
name: checksums
path: release-debs
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: release-debs/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9
View File
@@ -1,7 +1,16 @@
/target /target
.vscode/ .vscode/
/socktop-wasm-test/target /socktop-wasm-test/target
/.cargo/
# Documentation files from development sessions (context-specific, not for public repo) # Documentation files from development sessions (context-specific, not for public repo)
/OPTIMIZATION_PROCESS_DETAILS.md /OPTIMIZATION_PROCESS_DETAILS.md
/THREAD_SUPPORT.md /THREAD_SUPPORT.md
# APT Repository - Safety: Never commit private keys!
*.asc
*-private.key
*-secret.key
gpg-private-backup.key
secring.gpg
# Note: Release.gpg, InRelease, and KEY.gpg (public) ARE safe to commit
Generated
+1275 -1154
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -24,8 +24,8 @@ serde_json = "1.0"
sysinfo = "0.37" sysinfo = "0.37"
# CLI UI # CLI UI
ratatui = "0.28" ratatui = "0.30"
crossterm = "0.27" crossterm = "0.29"
# web server (remote-agent) # web server (remote-agent)
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws"] }
+156
View File
@@ -0,0 +1,156 @@
# Debian Packaging Implementation Summary
## Overview
Successfully implemented Debian packaging for socktop using `cargo-deb`, with GitHub Actions automation for building packages for both AMD64 and ARM64 architectures.
## Branches Created
1. **`feature/debian-packaging`** - Main branch with debian packaging implementation
2. **`feature/man-pages`** - Separate branch for man pages work (to be researched further)
## What Was Added
### 1. Cargo.toml Updates
Both `socktop/Cargo.toml` and `socktop_agent/Cargo.toml` were updated with:
- `[package.metadata.deb]` sections
- Package metadata (maintainer, description, dependencies)
- Asset definitions (binaries, documentation)
- Systemd service configuration (agent only)
### 2. Systemd Service
**File**: `socktop_agent/socktop-agent.service`
- Runs as `socktop` user/group
- Listens on port 3000 by default
- Security hardening enabled
- Disabled by default (user must explicitly enable)
### 3. Maintainer Scripts
**Directory**: `socktop_agent/debian/`
- **`postinst`**: Creates `socktop` user/group, sets up `/var/lib/socktop` directory
- **`postrm`**: Cleanup on package removal/purge
### 4. GitHub Actions Workflow
**File**: `.github/workflows/build-deb.yml`
Features:
- Builds for both x86_64 and ARM64
- Triggered on:
- Push to `master` or `feature/debian-packaging`
- Pull requests to `master`
- Version tags (v*)
- Manual workflow dispatch
- Creates artifacts:
- `debian-packages-amd64`
- `debian-packages-arm64`
- `all-debian-packages` (combined)
- `checksums` (SHA256SUMS)
- Automatic GitHub releases for version tags
### 5. Documentation
**File**: `docs/DEBIAN_PACKAGING.md`
Comprehensive guide covering:
- Building packages locally
- Cross-compilation for ARM64
- Installation and configuration
- Using GitHub Actions artifacts
- Creating local APT repositories
- Troubleshooting
## Package Details
### socktop (TUI Client)
- **Binary**: `/usr/bin/socktop`
- **Size**: ~3.5 MB (x86_64)
- **Dependencies**: Auto-detected
### socktop_agent (Daemon)
- **Binary**: `/usr/bin/socktop_agent`
- **Service**: `socktop-agent.service`
- **User/Group**: `socktop` (created automatically)
- **State directory**: `/var/lib/socktop`
- **Size**: ~6.7 MB (x86_64)
- **Dependencies**: Auto-detected
## Testing
Both packages successfully built locally:
```
✓ socktop_1.50.0-1_amd64.deb
✓ socktop-agent_1.50.1-1_amd64.deb
```
Verified:
- Package contents (dpkg -c)
- Package metadata (dpkg -I)
- Systemd service file inclusion
- Maintainer scripts inclusion
- Documentation inclusion
## Usage
### For Users
Download pre-built packages from GitHub Actions artifacts:
1. Go to Actions tab
2. Select latest "Build Debian Packages" run
3. Download architecture-specific artifact
4. Install: `sudo dpkg -i socktop*.deb`
### For Developers
Build locally:
```bash
cargo install cargo-deb
cargo deb --package socktop
cargo deb --package socktop_agent
```
Cross-compile for ARM64:
```bash
rustup target add aarch64-unknown-linux-gnu
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
cargo deb --package socktop --target aarch64-unknown-linux-gnu
```
## Next Steps
To get packages in official APT repositories:
1. **Short term**: Host packages on GitHub Releases (automated)
2. **Medium term**: Create PPA for Ubuntu users
3. **Long term**: Submit to Debian/Ubuntu official repositories
## Files Modified/Created
```
Modified:
socktop/Cargo.toml
socktop_agent/Cargo.toml
Created:
.github/workflows/build-deb.yml
docs/DEBIAN_PACKAGING.md
socktop_agent/socktop-agent.service
socktop_agent/debian/postinst
socktop_agent/debian/postrm
```
## Commit
```
532ed16 Add Debian packaging support with cargo-deb
```
## Resources
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
- Full documentation in `docs/DEBIAN_PACKAGING.md`
+39 -2
View File
@@ -5,6 +5,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler) - Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
- TUI: smooth graphs, sortable process table, scrollbars, readable colors - TUI: smooth graphs, sortable process table, scrollbars, readable colors
[socktop.io](https://www.socktop.io)
<img src="./docs/socktop_demo.apng" width="100%"> <img src="./docs/socktop_demo.apng" width="100%">
--- ---
@@ -29,6 +31,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
- Only top-level processes listed (threads hidden) — matches btop/top - Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled) - Optional GPU metrics (can be disabled)
- Optional auth token for the agent - Optional auth token for the agent
- Compact layout for small windows: automatically drops the panes that no longer fit so
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
--- ---
@@ -51,15 +55,23 @@ exec bash # or: exec zsh / exec fish
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, youll need Visual Studio Build Tools. You chose Windows — enjoy the ride. Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, youll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
### Raspberry Pi / Ubuntu / PopOS (required) ### Raspberry Pi / Ubuntu / PopOS (required for GPU support)
Install GPU support with apt command below **Note:** GPU monitoring is only supported on x86_64 and aarch64 (64-bit ARM) platforms. ARMv7 (32-bit) and RISC-V builds do not include GPU support.
For 64-bit systems with GPU support:
```bash ```bash
sudo apt-get update sudo apt-get update
sudo apt-get install libdrm-dev libdrm-amdgpu1 sudo apt-get install libdrm-dev libdrm-amdgpu1
``` ```
For ARMv7 (32-bit Raspberry Pi), build with `--no-default-features` to disable GPU support:
```bash
cargo build --release -p socktop_agent --no-default-features
```
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_ _Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
--- ---
@@ -203,6 +215,8 @@ socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# shorthand: # shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget # Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
# force the small-window layout at any terminal size (normally automatic):
socktop --compact ws://HOST:3000/ws
``` ```
Intervals (client-driven): Intervals (client-driven):
@@ -214,6 +228,29 @@ The agent stays idle unless queried. When queried, it collects just whats nee
--- ---
## Compact mode
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
are the first things to collapse — exactly the panes you are most likely watching. Once
the window is too short for the Disks pane to show even one disk, socktop switches to a
compact layout:
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
- **Memory and Swap move side by side** into the row Disks vacated.
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
with no GPU the pane disappears entirely.
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
below the size where they used to vanish.
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
layout at any window size:
```bash
socktop --compact ws://HOST:3000/ws
```
---
## Connection Profiles (Named) ## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later. You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
+42
View File
@@ -0,0 +1,42 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGkih7QBDADgX6sYMx2Lp6qcZxeCCizcy4TFsxcRJfp5mfbMplVES0hQToIP
EMC11JqPwQdLliXKjUr8Z2kgM2oqvH+dkdgzUGrw6kTK8YHc+qs37iJAOVS9D72X
tTld282NrtFwzb74nS2GKPkpWI7aSKBpHtWFPX/1ONsc56qGqFd3wwikEvCz8MeJ
HwCD1JZ9F+2DyyXWsTJNgDwPloJSUbtyVuk2gd6PeTg7AQdx92Pk/mggmYbHtP8N
wy072ku1g8K/hplmwIOGpSx1JWvAQkDU/Bb/jSqrYg2wSHO7IQnYE8I3x/zglYBl
FYNh47TVQr0zPVSYR1MQkHU5YLBTDc5UgDvtcsYUiTtq4D/m8HWmKja0/UKGxvDJ
P5sUPcp4dk77RdoCtUe5HImYGS8lo5N3+t0lz8sd9rYmRiIO4f7FJaJqJeHbUJyn
iw/GCQh5D5/D571dICrEq/QhL+k5KhJljPGoVMGPFXJIc7q+CxvGp2oOo5fOlbOn
3kSrM93AJPwT8FMAEQEAAbRFSmFzb24gV2l0dHkgKHNvY2t0b3AgYXB0IHNpZ25p
bmcga2V5KSA8amFzb25wd2l0dHkrc29ja3RvcEBwcm90b24ubWU+iQHOBBMBCgA4
FiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkih7QCGwMFCwkIBwIGFQoJCAsCBBYC
AwECHgECF4AACgkQESwaeYRl+/KV+gwAzfZVZEhO7MQV2EmNeKVK1GycFSm2oUAl
ZbwNIEHu6+tOzqXJb8o65BtGlbLSGavsMpgRCK2SL83DdLOkutG1ahQiJr+5GaXC
zbQgX+VWqGPZtQ+I6/rVoYZPMTCrqpAmFgvVpqv0xod7w8/wny8/XmhQ37KY2/0l
B38oNTvdA7C8jzSrI6kr3XqurvQRW7z+MnC+nCp9Ob9bYtY0kpd4U3NrVdb8m32U
d5LVFwD1OGvzLOSqyJ33IKjSJc4KLvW+aEsHXe+fHO9UEzH8Nbo5MmVvX3QIHiyq
jD4zN16AGsGYqCK4irtQCiD3wBOdsG/RVkgIcdlmAH3EGEp7Ux8+7v1PXYI+UrSs
XE7f1xFTJ2r5TMex6W3he073Em4qhQsrnMF5syTZsM6N+5UqXVOM1RuDVVXr7929
hC3G8pK/A2W5Lwpxl2yzock2CxhvUn7M/xm4VbcPlWTCUd/QzU8VtsgaGHcuhi5e
xHY1AU07STLB9RinjBVf2bmk4oDQcmB6uQGNBGkih7QBDACrjE+xSWP92n931/5t
+tXcujwFlIpSZdbSQFr0B0YyjPRUP4FSzEGu8vuM5ChUfWKhmN1dDr5C4qFo9NgQ
6oCN2HubajSGyXNwnOMlMb5ck79Ubmy9yDV9/ZLqpJJiozGap2/EnNoDhaANlmUg
rfqUHpIB8XC2IZ0Itt05tp/u78dJiB+R6ReZn/bVUafNV4jIqYZfLRzI3FTJ4xvK
FGs/ER+JajAdJQ8LPfazmDQSGw0huguxhopZwKQ/qWZMn1OHq/ZaPvCqbQt3irLw
dLPDC4pEaYGRyADYeyuarG0DVyUQ9XRc/NufKDvOAn33LpBPBpcvNQAsVhWTCYl7
ogQ+suVYVN8Tu7v4bUSHKwzXKvLN/ojJX/Fh7eTW4TPsgLHNHAEDUkSQozIe9vO6
o+vydDqRxuXJgdkR7lqP6PQDYrhRYZGJf57eKf6VtTKYFaMbiMWPU+vcHeB0/iDe
Pv81qro2LD2PG5WCzDpNETBceCTjykb9r0VHx4/JsiojKmsAEQEAAYkBtgQYAQoA
IBYhBB51VqgFObg5S8KCDREsGnmEZfvyBQJpIoe0AhsMAAoJEBEsGnmEZfvyNp8M
AIH+6+hGB3qADdnhNgb+3fN0511eK9Uk82lxgGARLcD8GN1UP0HlvEqkxCHy3PUe
tHcsuYVz7i8pmpEGdFx9zv7MelenUsJniUQ++OZKx6iUG/MYqz//NxY+5lyRmcu2
aYvUxhkgf9zgxXTkTyV2VV32mX//cHcwc+c/089QAPzCMaSrHdNK+ED9+k8uquJ1
lSL9Bm15z/EV42v9Q/4KTM5OBLHpNw0Rvn9C0iuZVwHXBrrA/HSGXpA54AqNUMpZ
kRPgLQcy5yVE2y1aXLXt2XdTn6YPzrAjNoazYYuCWHYIZU7dGkIswpsDirDLKHdD
onb3VShmSpemYjsuFiqhfi6qwCkeHsz/CpQAp70SZ+z9oB8H80PJVKPbPIP3zEf3
i7bcsqHA7stF+8sJclXgxBUBeDJ3O2jN/scBOcvNA6xoRp7+oJbnjDRuxBmh+fVg
TIuw2++vTF2Ml0EMv7ePTpr7b1DofuJRNYGkuAIMVXHjLTqMiTJUce3OUy003zMg
Dg==
=AaPQ
-----END PGP PUBLIC KEY BLOCK-----
+38
View File
@@ -0,0 +1,38 @@
# socktop APT Repository
This repository contains Debian packages for socktop and socktop-agent.
## Adding this repository
Add the repository to your system:
```bash
# Add the GPG key
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
# Add the repository
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
# Update and install
sudo apt update
sudo apt install socktop socktop-agent
```
## Manual Installation
You can also download and install packages manually from the `pool/main/` directory.
```bash
wget https://jasonwitty.github.io/socktop/pool/main/socktop_VERSION_ARCH.deb
sudo dpkg -i socktop_VERSION_ARCH.deb
```
## Supported Architectures
- amd64 (x86_64)
- arm64 (aarch64)
- armhf (32-bit ARM)
## Building from Source
See the main repository at https://github.com/jasonwitty/socktop
+32
View File
@@ -0,0 +1,32 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf
Components: main
Description: socktop APT repository
Date: Sun, 23 Nov 2025 04:05:21 +0000
MD5Sum:
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
SHA256:
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
-----BEGIN PGP SIGNATURE-----
iQGzBAEBCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAYACgkQESwaeYRl
+/KBsAv/eYhnK/XrNtPhLyw/zX2cGfUtBsBZrypFhV/n+TvudAIwQaqxDEvLlBUn
HBAhMKDQXGs7V45+nOgDX4rKWUqJh4SPbJgNbVte2PX7U+hsMpZBsYp3vkjApgTO
pq2CCkViyBXgTY+6vUigtvfJ9afTTWI6Qm4dLXZ7hxErBxgHQyowOoO/sF92cNOu
AosBMpE+qSy7sVqJU5g/JXJh0kddKFotXHSGA1kFMzJafJC/n5nLrusDzFJRQqyH
Io+6inYWjlb5o79z0tJzAvG1mgplLRppMBjoVJ/RJ+gT+QE70kokR6wvsgDqsKNd
mvB0TNj0zY0g6Is6V3XMyf0u+6BtLTbua913HPiqBfErgeV58vzsst+y0It42TXi
aw+UF2Kw/YhPq1rZFxgnAVcMja3qlXWpH57gmgIPovBCsPsiywWiHLsSHRzAI22b
zeTsUST/4toR/ruZVbUZvWoWAR4tzsSuwXJFx/hhinTQQTNHErXASOX986UaL9L7
o2/pTKLe
=IeBY
-----END PGP SIGNATURE-----
+14
View File
@@ -0,0 +1,14 @@
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf
Components: main
Description: socktop APT repository
Date: Sun, 23 Nov 2025 04:05:21 +0000
MD5Sum:
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
SHA256:
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
+14
View File
@@ -0,0 +1,14 @@
-----BEGIN PGP SIGNATURE-----
iQGzBAABCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAEACgkQESwaeYRl
+/KzeAv+OUIbxud5FboerwpAJULV+rS3+VX4kvwg/daVZ3yX3tJNrsyNCHgmWLVu
fLeEFFc2Ax9GvFW4jrbxRAGD+3TXQEEFkb5lGzYyDjlgVzR6wLiVTTrmzWoK+cbB
4DMozqeLiZFfQjq4UFn3+mwiYFX9Dj7PVF0M60XAUJSObbJFmaEPZIfx6wcZfkiL
lLLk1eeU5MPiyudPOhVGgaD76KrUCw+8DBNKoCKIEcCY0LvuKtUK8mWYXRSPSved
4Znd3QZz063Z6R+Lj1XlGLoTPResna28T/Nca+2JgLhbrihsLMcHoFxmrvFP9FpT
MChKngj7NnGt0yqHH5J16hdwMra/vvhmF0yoQ0loIcy+q06tYEqOcau8tvAjfbId
k3rgQgnxxVE8WUmV9Bugp7jhNMO+ImKWMwzEr6wGd9ZHqpknUlAaWeO73VP+qtAN
6mEqWhkqvXGg+srH6qp3Sg0W28dYG29X3Kx8jOp7HeyvA/gLZRN7L+bq/XaA7WFA
1hba6LIY
=QoLf
-----END PGP SIGNATURE-----
@@ -0,0 +1,38 @@
Package: socktop
Version: 1.50.0-1
Architecture: amd64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 3459
Filename: pool/main/socktop_1.50.0-1_amd64.deb
Size: 1278940
MD5sum: 0215e178e306d9379669065e8c78582b
SHA1: 04e0416389f5cecd584fd1f6b3568711f2645eee
SHA256: 69eb04b1de48541c95950a97b16357fcd9c51ffaceb143f63de4a9d758fad297
Section: admin
Priority: optional
Homepage: https://github.com/jasonwitty/socktop
Description: Remote system monitor over WebSocket, TUI like top
socktop is a remote system monitor with a rich terminal user interface (TUI)
that connects to remote hosts running the socktop_agent over WebSocket. It
provides real-time monitoring of CPU, memory, processes, and more with an
interface similar to the traditional 'top' command.
Package: socktop-agent
Version: 1.50.2-1
Architecture: amd64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 6793
Filename: pool/main/socktop-agent_1.50.2-1_amd64.deb
Size: 1896272
MD5sum: 22e78d03e83dcf84d6ec4a009b285902
SHA1: 26a9f4fedfdba06a047044027223f2944cf72ba6
SHA256: 11922af475146f60347a9c52cff4bbce1ce524bdb4293b2c436f3c71876e17d5
Section: admin
Priority: optional
Homepage: https://github.com/jasonwitty/socktop
Description: Socktop agent daemon. Serves host metrics over WebSocket.
socktop_agent is the daemon component that runs on remote hosts to collect and
serve system metrics over WebSocket. It gathers CPU, memory, disk, network,
GPU, and process information that can be monitored remotely by the socktop TUI
client.
Binary file not shown.
@@ -0,0 +1,5 @@
Archive: stable
Component: main
Origin: socktop
Label: socktop
Architecture: amd64
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>socktop APT Repository</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
line-height: 1.6;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
}
pre {
background: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
h1 { color: #333; }
h2 { color: #555; margin-top: 30px; }
</style>
</head>
<body>
<h1>socktop APT Repository</h1>
<p>System monitor with remote agent support for Linux systems.</p>
<h2>Adding this repository</h2>
<pre><code># Add the GPG key
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
# Add the repository
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
# Update and install
sudo apt update
sudo apt install socktop socktop-agent</code></pre>
<h2>Manual Installation</h2>
<p>Download packages from <a href="pool/main/">pool/main/</a></p>
<h2>Supported Architectures</h2>
<ul>
<li>amd64 (x86_64)</li>
<li>arm64 (aarch64)</li>
<li>armhf (32-bit ARM)</li>
</ul>
<h2>Source Code</h2>
<p>Visit the <a href="https://github.com/jasonwitty/socktop">GitHub repository</a></p>
</body>
</html>
Binary file not shown.
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
# Debian Packaging for socktop
This document describes how to build and use Debian packages for socktop and socktop_agent.
## Prerequisites
Install `cargo-deb`:
```bash
cargo install cargo-deb
```
## Building Packages Locally
### Build for your current architecture (x86_64)
```bash
# Build socktop TUI client
cargo deb --package socktop
# Build socktop_agent daemon
cargo deb --package socktop_agent
```
The `.deb` files will be created in `target/debian/`.
### Cross-compile for ARM64 (Raspberry Pi, etc.)
First, install cross-compilation tools:
```bash
sudo apt-get update
sudo apt-get install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
```
Add the ARM64 target:
```bash
rustup target add aarch64-unknown-linux-gnu
```
Configure the linker by creating `.cargo/config.toml`:
```toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
```
Build the packages:
```bash
# Build for ARM64
cargo deb --package socktop --target aarch64-unknown-linux-gnu
cargo deb --package socktop_agent --target aarch64-unknown-linux-gnu
```
## Installing Packages
### Install socktop TUI client
```bash
sudo dpkg -i socktop_*.deb
```
### Install socktop_agent daemon
```bash
sudo dpkg -i socktop_agent_*.deb
```
The agent package will:
- Create a `socktop` system user and group
- Install the binary to `/usr/bin/socktop_agent`
- Install a systemd service file (disabled by default)
- Create `/var/lib/socktop` for state files
### Enable and start the agent service
```bash
# Enable to start on boot
sudo systemctl enable socktop-agent
# Start the service
sudo systemctl start socktop-agent
# Check status
sudo systemctl status socktop-agent
```
### Configure the agent
Edit the systemd service to customize settings:
```bash
sudo systemctl edit socktop-agent
```
Add configuration in the override section:
```ini
[Service]
Environment=SOCKTOP_PORT=8080
Environment=SOCKTOP_TOKEN=your-secret-token
Environment=RUST_LOG=info
```
Then restart:
```bash
sudo systemctl restart socktop-agent
```
## GitHub Actions
The project includes a GitHub Actions workflow (`.github/workflows/build-deb.yml`) that automatically builds `.deb` packages for both x86_64 and ARM64 architectures on every push to master or when tags are created.
### Downloading pre-built packages
1. Go to the [Actions tab](https://github.com/jasonwitty/socktop/actions)
2. Click on the latest "Build Debian Packages" workflow run
3. Download the artifacts:
- `debian-packages-amd64` - x86_64 packages
- `debian-packages-arm64` - ARM64 packages
- `all-debian-packages` - All packages combined
- `checksums` - SHA256 checksums
### Release packages
When you create a git tag starting with `v` (e.g., `v1.50.0`), the workflow will automatically create a GitHub Release with all `.deb` packages attached.
```bash
git tag v1.50.0
git push origin v1.50.0
```
## Package Details
### socktop package
- **Binary**: `/usr/bin/socktop`
- **Documentation**: `/usr/share/doc/socktop/`
- **Size**: ~5-8 MB (depends on architecture)
### socktop_agent package
- **Binary**: `/usr/bin/socktop_agent`
- **Service**: `socktop-agent.service`
- **User/Group**: `socktop`
- **State directory**: `/var/lib/socktop`
- **Config directory**: `/etc/socktop` (created but empty by default)
- **Documentation**: `/usr/share/doc/socktop_agent/`
- **Size**: ~5-8 MB (depends on architecture)
## Uninstalling
```bash
# Remove packages but keep configuration
sudo apt remove socktop socktop_agent
# Remove packages and all configuration (purge)
sudo apt purge socktop socktop_agent
```
When purging `socktop_agent`, the following are removed:
- The `socktop` user and group
- `/var/lib/socktop` directory
- Empty `/etc/socktop` directory (if empty)
## Verifying Packages
Check package contents:
```bash
dpkg -c socktop_*.deb
dpkg -c socktop_agent_*.deb
```
Check package information:
```bash
dpkg -I socktop_*.deb
dpkg -I socktop_agent_*.deb
```
After installation, verify files:
```bash
dpkg -L socktop
dpkg -L socktop-agent
```
## Troubleshooting
### Service fails to start
Check logs:
```bash
sudo journalctl -u socktop-agent -f
```
Verify the socktop user exists:
```bash
id socktop
```
### Permission issues
Ensure the state directory has correct permissions:
```bash
sudo chown -R socktop:socktop /var/lib/socktop
sudo chmod 755 /var/lib/socktop
```
### Missing dependencies
If installation fails due to missing dependencies:
```bash
sudo apt --fix-broken install
```
## Creating a Local APT Repository (Advanced)
To create your own APT repository for easy installation:
1. Install required tools:
```bash
sudo apt install dpkg-dev
```
2. Create repository structure:
```bash
mkdir -p ~/socktop-repo/pool/main
cp *.deb ~/socktop-repo/pool/main/
```
3. Generate package index:
```bash
cd ~/socktop-repo
dpkg-scanpackages pool/main /dev/null | gzip -9c > pool/main/Packages.gz
```
4. Serve via HTTP (for testing):
```bash
cd ~/socktop-repo
python3 -m http.server 8000
```
5. Add to sources on client machines:
```bash
echo "deb [trusted=yes] http://your-server:8000 pool/main/" | \
sudo tee /etc/apt/sources.list.d/socktop.list
sudo apt update
sudo apt install socktop socktop-agent
```
## Contributing
When adding new features that affect packaging:
1. Update `Cargo.toml` metadata in the `[package.metadata.deb]` section
2. Add new assets to the `assets` array if needed
3. Update maintainer scripts in `socktop_agent/debian/` if needed
4. Test package building locally before committing
5. Update this documentation
## References
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
- [systemd service files](https://www.freedesktop.org/software/systemd/man/systemd.service.html)
+13 -10
View File
@@ -2,6 +2,8 @@
This guide explains how to cross-compile the socktop_agent on various host systems and deploy it to a Raspberry Pi. Cross-compiling is particularly useful for older or resource-constrained Pi models where native compilation might be slow. This guide explains how to cross-compile the socktop_agent on various host systems and deploy it to a Raspberry Pi. Cross-compiling is particularly useful for older or resource-constrained Pi models where native compilation might be slow.
**Note:** GPU monitoring support is not available on ARMv7 (32-bit) and RISC-V architectures due to library limitations. When building for these platforms, the `--no-default-features` flag must be used to disable GPU support.
## Cross-Compilation Host Setup ## Cross-Compilation Host Setup
Choose your host operating system: Choose your host operating system:
@@ -23,8 +25,9 @@ sudo apt update
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64 sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
# For 32-bit Raspberry Pi (armv7) # For 32-bit Raspberry Pi (armv7)
# Note: GPU support not available on armv7
sudo apt update sudo apt update
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross libdrm-dev:armhf sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
``` ```
### Setup Rust Cross-Compilation Targets ### Setup Rust Cross-Compilation Targets
@@ -65,9 +68,8 @@ sudo pacman -S aarch64-linux-gnu-gcc
yay -S aarch64-linux-gnu-libdrm yay -S aarch64-linux-gnu-libdrm
# For 32-bit Raspberry Pi (armv7) # For 32-bit Raspberry Pi (armv7)
# Note: GPU support not available on armv7
sudo pacman -S arm-linux-gnueabihf-gcc sudo pacman -S arm-linux-gnueabihf-gcc
# Install libdrm for armv7 using an AUR helper
yay -S arm-linux-gnueabihf-libdrm
``` ```
### Setup Rust Cross-Compilation Targets ### Setup Rust Cross-Compilation Targets
@@ -114,8 +116,8 @@ cd path/to/socktop
# For 64-bit Raspberry Pi # For 64-bit Raspberry Pi
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:aarch64-musl cargo build --release --target aarch64-unknown-linux-musl -p socktop_agent docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:aarch64-musl cargo build --release --target aarch64-unknown-linux-musl -p socktop_agent
# For 32-bit Raspberry Pi # For 32-bit Raspberry Pi (without GPU support)
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent --no-default-features
``` ```
The compiled binaries will be available in your local target directory. The compiled binaries will be available in your local target directory.
@@ -133,11 +135,11 @@ The recommended approach for Windows is to use Windows Subsystem for Linux (WSL2
After setting up your environment, build the socktop_agent for your target Raspberry Pi: After setting up your environment, build the socktop_agent for your target Raspberry Pi:
```bash ```bash
# For 64-bit Raspberry Pi # For 64-bit Raspberry Pi (with GPU support)
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
# For 32-bit Raspberry Pi # For 32-bit Raspberry Pi (without GPU support)
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent --no-default-features
``` ```
## Transfer the Binary to Your Raspberry Pi ## Transfer the Binary to Your Raspberry Pi
@@ -161,11 +163,12 @@ SSH into your Raspberry Pi and install the required dependencies:
```bash ```bash
ssh pi@raspberry-pi-ip ssh pi@raspberry-pi-ip
# For Raspberry Pi OS (Debian-based) # For Raspberry Pi OS (Debian-based) - 64-bit only
# (32-bit armv7 builds don't require these)
sudo apt update sudo apt update
sudo apt install libdrm-dev libdrm-amdgpu1 sudo apt install libdrm-dev libdrm-amdgpu1
# For Arch Linux ARM # For Arch Linux ARM - 64-bit only
sudo pacman -Syu sudo pacman -Syu
sudo pacman -S libdrm sudo pacman -S libdrm
``` ```
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Sync this repo to the 'gitea' remote as a mirror.
# - Mirrors ALL refs (branches, tags) and prunes removed ones.
# - This makes the Gitea repo match GitHub exactly.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Error: not inside a git repo" >&2
exit 1
fi
if ! git remote get-url gitea >/dev/null 2>&1; then
echo "Missing 'gitea' remote. Add it with:" >&2
echo " git remote add gitea https://gt.wittyoneoff.com/jason/socktop.git" >&2
exit 1
fi
echo "Fetching from origin (pruning)..."
git fetch origin --prune --tags
echo "Pushing mirror to gitea..."
git push gitea --mirror
echo "Done: Gitea should now match origin (GitHub)."
+22 -3
View File
@@ -1,15 +1,17 @@
[package] [package]
name = "socktop" name = "socktop"
version = "1.40.0" version = "1.50.0"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"] authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top" description = "Remote system monitor over WebSocket, TUI like top"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
homepage = "https://github.com/jasonwitty/socktop"
repository = "https://github.com/jasonwitty/socktop"
[dependencies] [dependencies]
# socktop connector for agent communication # socktop connector for agent communication
socktop_connector = { path = "../socktop_connector" } socktop_connector = "1.50.0"
tokio = { workspace = true } tokio = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
@@ -24,4 +26,21 @@ sysinfo = { workspace = true }
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.0" assert_cmd = "2.0"
tempfile = "3" tempfile = "3"
[package.metadata.deb]
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
license-file = ["../LICENSE", "4"]
extended-description = """\
socktop is a remote system monitor with a rich terminal user interface (TUI) \
that connects to remote hosts running the socktop_agent over WebSocket. \
It provides real-time monitoring of CPU, memory, processes, and more with \
an interface similar to the traditional 'top' command."""
depends = "$auto"
section = "admin"
priority = "optional"
assets = [
["target/release/socktop", "usr/bin/", "755"],
["../README.md", "usr/share/doc/socktop/", "644"],
]
+390 -211
View File
@@ -15,7 +15,7 @@ use ratatui::{
//style::Color, // + add Color //style::Color, // + add Color
Terminal, Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Direction, Rect}, layout::Rect,
}; };
use tokio::time::{sleep, timeout}; use tokio::time::{sleep, timeout};
@@ -27,14 +27,21 @@ use crate::ui::cpu::{
per_core_content_area, per_core_handle_key, per_core_handle_mouse, per_core_content_area, per_core_handle_key, per_core_handle_mouse,
per_core_handle_scrollbar_mouse, per_core_handle_scrollbar_mouse,
}; };
use crate::ui::layout::{AppLayout, compute as compute_layout};
use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::modal::{ModalAction, ModalManager, ModalType};
use crate::ui::processes::{ use crate::ui::processes::{
ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection, ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
processes_handle_mouse_with_selection,
}; };
use crate::ui::{ use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, disks::draw_disks,
gpu::{draw_gpu, draw_gpu_compact},
header::{build_header_intervals, build_header_title, draw_header},
mem::draw_mem,
net::draw_net_spark,
swap::draw_swap, swap::draw_swap,
}; };
use socktop_connector::{ use socktop_connector::{
AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent, AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent,
connect_to_socktop_agent_with_tls, connect_to_socktop_agent_with_tls,
@@ -44,6 +51,15 @@ use socktop_connector::{
const MIN_METRICS_INTERVAL_MS: u64 = 100; const MIN_METRICS_INTERVAL_MS: u64 = 100;
const MIN_PROCESSES_INTERVAL_MS: u64 = 200; const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
/// Drop duplicate-name entries from a disks payload (the agent occasionally
/// reports a partition twice). Done once when fresh disk data arrives so the
/// per-frame draw path doesn't have to rebuild a HashSet.
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(disks.len());
disks.retain(|d| seen.insert(d.name.clone()));
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ConnectionState { pub enum ConnectionState {
Connected, Connected,
@@ -55,8 +71,9 @@ pub struct App {
// Latest metrics + histories // Latest metrics + histories
last_metrics: Option<Metrics>, last_metrics: Option<Metrics>,
// CPU avg history (0..100) // CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame
cpu_hist: VecDeque<u64>, cpu_hist: VecDeque<u64>,
cpu_hist_sum: u64,
// Per-core history (0..100) // Per-core history (0..100)
per_core_hist: PerCoreHistory, per_core_hist: PerCoreHistory,
@@ -83,6 +100,21 @@ pub struct App {
pub selected_process_index: Option<usize>, // Index in the visible/sorted list pub selected_process_index: Option<usize>, // Index in the visible/sorted list
prev_selected_process_pid: Option<u32>, // Track previous selection to detect changes prev_selected_process_pid: Option<u32>, // Track previous selection to detect changes
// Process search state
pub process_search_active: bool,
pub process_search_query: String,
// Cached filtered + sorted process indices. Refreshed lazily when any of
// (metrics, sort order, search query) changes — input handlers, the draw
// path, and auto-scroll all read from this slice so we avoid rebuilding
// an indices Vec on every event.
procs_filtered: Vec<usize>,
procs_filter_dirty: bool,
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
// parallel to `last_metrics.top_processes`.
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
procs_row_peak_cpu: f32,
last_procs_poll: Instant, last_procs_poll: Instant,
last_disks_poll: Instant, last_disks_poll: Instant,
procs_interval: Duration, procs_interval: Duration,
@@ -93,12 +125,14 @@ pub struct App {
pub process_details: Option<socktop_connector::ProcessMetricsResponse>, pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
pub journal_entries: Option<socktop_connector::JournalResponse>, pub journal_entries: Option<socktop_connector::JournalResponse>,
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples) pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples) pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples) pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples) pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation last_io_read_bytes: Option<u64>, // Previous read bytes for delta calculation
last_io_write_bytes: Option<u64>, // Previous write bytes for delta calculation last_io_write_bytes: Option<u64>, // Previous write bytes for delta calculation
pub process_details_unsupported: bool, // Track if agent doesn't support process details pub max_process_mem_bytes: u64, // Maximum memory usage observed for current process
pub process_details_unsupported: bool, // Track if agent doesn't support process details
last_process_details_poll: Instant, last_process_details_poll: Instant,
last_journal_poll: Instant, last_journal_poll: Instant,
process_details_interval: Duration, process_details_interval: Duration,
@@ -112,6 +146,21 @@ pub struct App {
pub is_tls: bool, pub is_tls: bool,
pub has_token: bool, pub has_token: bool,
// --compact: pin the compact layout regardless of window size. Without it the
// layout switches on its own once the window is too short for the Disks pane.
force_compact: bool,
// Cached title strings — only rebuilt when source values change so the
// diff renderer can suppress redraws on idle frames.
header_title: String,
header_title_key: (String, bool, bool),
header_intervals_text: String,
header_intervals_key: (u128, u128),
net_dl_title: String,
net_dl_key: (u64, u64),
net_ul_title: String,
net_ul_key: (u64, u64),
// Modal system // Modal system
pub modal_manager: crate::ui::modal::ModalManager, pub modal_manager: crate::ui::modal::ModalManager,
@@ -129,6 +178,7 @@ impl App {
Self { Self {
last_metrics: None, last_metrics: None,
cpu_hist: VecDeque::with_capacity(600), cpu_hist: VecDeque::with_capacity(600),
cpu_hist_sum: 0,
per_core_hist: PerCoreHistory::new(60), per_core_hist: PerCoreHistory::new(60),
last_net_totals: None, last_net_totals: None,
rx_hist: VecDeque::with_capacity(600), rx_hist: VecDeque::with_capacity(600),
@@ -145,6 +195,12 @@ impl App {
selected_process_pid: None, selected_process_pid: None,
selected_process_index: None, selected_process_index: None,
prev_selected_process_pid: None, prev_selected_process_pid: None,
process_search_active: false,
process_search_query: String::new(),
procs_filtered: Vec::new(),
procs_filter_dirty: true,
procs_row_cache: Vec::new(),
procs_row_peak_cpu: 0.0,
last_procs_poll: Instant::now() last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2)) .checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop .unwrap_or_else(Instant::now), // trigger immediately on first loop
@@ -157,11 +213,13 @@ impl App {
process_details: None, process_details: None,
journal_entries: None, journal_entries: None,
process_cpu_history: VecDeque::with_capacity(600), process_cpu_history: VecDeque::with_capacity(600),
process_cpu_history_sum: 0.0,
process_mem_history: VecDeque::with_capacity(600), process_mem_history: VecDeque::with_capacity(600),
process_io_read_history: VecDeque::with_capacity(600), process_io_read_history: VecDeque::with_capacity(600),
process_io_write_history: VecDeque::with_capacity(600), process_io_write_history: VecDeque::with_capacity(600),
last_io_read_bytes: None, last_io_read_bytes: None,
last_io_write_bytes: None, last_io_write_bytes: None,
max_process_mem_bytes: 0,
process_details_unsupported: false, process_details_unsupported: false,
last_process_details_poll: Instant::now() last_process_details_poll: Instant::now()
.checked_sub(Duration::from_secs(10)) .checked_sub(Duration::from_secs(10))
@@ -176,6 +234,15 @@ impl App {
verify_hostname: false, verify_hostname: false,
is_tls: false, is_tls: false,
has_token: false, has_token: false,
force_compact: false,
header_title: String::new(),
header_title_key: (String::new(), false, false),
header_intervals_text: String::new(),
header_intervals_key: (u128::MAX, u128::MAX),
net_dl_title: String::new(),
net_dl_key: (u64::MAX, u64::MAX),
net_ul_title: String::new(),
net_ul_key: (u64::MAX, u64::MAX),
modal_manager: ModalManager::new(), modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected, connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(), last_connection_attempt: Instant::now(),
@@ -186,6 +253,23 @@ impl App {
} }
} }
/// Pins the compact layout at any window size (`--compact`).
pub fn with_compact(mut self, force_compact: bool) -> Self {
self.force_compact = force_compact;
self
}
/// Pane rects for the current frame. The draw path and the mouse/key hit-testing
/// paths all go through here so they cannot disagree about where a pane is.
fn layout(&self, area: Rect) -> AppLayout {
let has_gpu = self
.last_metrics
.as_ref()
.and_then(|m| m.gpus.as_ref())
.is_some_and(|g| !g.is_empty());
compute_layout(area, self.force_compact, has_gpu)
}
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self { pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
metrics_ms.inspect(|&m| { metrics_ms.inspect(|&m| {
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS)); self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
@@ -455,7 +539,10 @@ impl App {
_url: &str, _url: &str,
_tls_ca: Option<&str>, _tls_ca: Option<&str>,
_verify_hostname: bool, _verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>>
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop { loop {
// Handle input for modal // Handle input for modal
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
@@ -562,7 +649,10 @@ impl App {
&mut self, &mut self,
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
mut ws: SocktopConnector, mut ws: SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>>
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop { loop {
// Main event loop // Main event loop
let result = self.run_event_loop_iteration(terminal, &mut ws).await; let result = self.run_event_loop_iteration(terminal, &mut ws).await;
@@ -582,7 +672,10 @@ impl App {
&mut self, &mut self,
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
ws: &mut SocktopConnector, ws: &mut SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>>
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop { loop {
// Input (non-blocking) // Input (non-blocking)
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
@@ -614,7 +707,8 @@ impl App {
{ {
self.clear_process_details(); self.clear_process_details();
} }
// Modal was dismissed, continue to normal processing // Modal was dismissed, skip normal key processing
continue;
} }
ModalAction::Confirm => { ModalAction::Confirm => {
// Handle confirmation action here if needed in the future // Handle confirmation action here if needed in the future
@@ -647,6 +741,51 @@ impl App {
} }
} }
// Handle search mode
if self.process_search_active {
match k.code {
KeyCode::Esc => {
// Exit search mode
self.process_search_active = false;
self.process_search_query.clear();
self.invalidate_procs_filter();
continue;
}
KeyCode::Enter => {
// Exit search mode, keep filter active, and auto-select first result
self.process_search_active = false;
// Auto-select first filtered result
let first = self.procs_filter().first().copied();
if let (Some(first_idx), Some(m)) =
(first, self.last_metrics.as_ref())
{
self.selected_process_index = Some(first_idx);
self.selected_process_pid =
Some(m.top_processes[first_idx].pid);
}
continue;
}
KeyCode::Backspace => {
self.process_search_query.pop();
self.invalidate_procs_filter();
continue;
}
KeyCode::Char(c) => {
self.process_search_query.push(c);
self.invalidate_procs_filter();
continue;
}
KeyCode::Up | KeyCode::Down => {
// Allow arrow keys to navigate even while in search mode
// Fall through to normal navigation handling
}
_ => {
continue; // Block other keys in search mode
}
}
}
// Normal key handling (only if no modal is active or modal didn't consume the key) // Normal key handling (only if no modal is active or modal didn't consume the key)
if matches!( if matches!(
k.code, k.code,
@@ -654,42 +793,55 @@ impl App {
) { ) {
self.should_quit = true; self.should_quit = true;
} }
// Activate search mode on '/' (clears query if starting new search, or edits existing)
if matches!(k.code, KeyCode::Char('/')) {
self.process_search_active = true;
// Don't clear query - allow editing existing search
continue;
}
// Clear search filter on 'c' or 'C' (when not in search mode)
if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C'))
&& !self.process_search_query.is_empty()
&& !self.process_search_active
{
self.process_search_query.clear();
self.selected_process_pid = None;
self.selected_process_index = None;
self.invalidate_procs_filter();
continue;
}
// Show About modal on 'a' or 'A'
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
self.modal_manager.push_modal(ModalType::About);
}
// Show Help modal on 'h' or 'H'
if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) {
self.modal_manager.push_modal(ModalType::Help);
}
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default() let layout = self.layout(area);
.direction(Direction::Vertical) let content = per_core_content_area(layout.per_core);
.constraints([
Constraint::Length(1), // Refresh the filtered+sorted index cache once before we
Constraint::Ratio(1, 3), // borrow individual fields of `self`.
Constraint::Length(3), let _ = self.procs_filter();
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]);
// First try process selection (only handles arrows if a process is selected) // First try process selection (only handles arrows if a process is selected)
let process_handled = if let Some(p_area) = self.last_procs_area { let process_handled = if self.last_procs_area.is_some() {
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) processes_handle_key_with_selection(ProcessKeyParams {
let total_rows = self selected_process_pid: &mut self.selected_process_pid,
.last_metrics selected_process_index: &mut self.selected_process_index,
.as_ref() key: k,
.map(|m| m.top_processes.len()) metrics: self.last_metrics.as_ref(),
.unwrap_or(0); filtered_indices: &self.procs_filtered,
processes_handle_key_with_selection( })
&mut self.procs_scroll_offset,
&mut self.selected_process_pid,
&mut self.selected_process_index,
k,
page,
total_rows,
self.last_metrics.as_ref(),
)
} else { } else {
false false
}; };
@@ -703,6 +855,41 @@ impl App {
); );
} }
// Auto-scroll to keep selected process visible
if let (Some(selected_idx), Some(p_area)) =
(self.selected_process_index, self.last_procs_area)
&& self.last_metrics.is_some()
{
let idxs = &self.procs_filtered;
// Find the display position of the selected process in filtered list
if let Some(display_pos) =
idxs.iter().position(|&idx| idx == selected_idx)
{
// Calculate viewport size
// Account for: borders (2) + header (1) + search box if active (3)
let extra_rows = if self.process_search_active
|| !self.process_search_query.is_empty()
{
3 // search box with border
} else {
0
};
let viewport_rows =
p_area.height.saturating_sub(3 + extra_rows) as usize;
// Adjust scroll offset to keep selection visible
if display_pos < self.procs_scroll_offset {
// Selection is above viewport, scroll up
self.procs_scroll_offset = display_pos;
} else if display_pos >= self.procs_scroll_offset + viewport_rows {
// Selection is below viewport, scroll down
self.procs_scroll_offset =
display_pos.saturating_sub(viewport_rows - 1);
}
}
}
// Check if process selection changed and clear details if so // Check if process selection changed and clear details if so
if self.selected_process_pid != self.prev_selected_process_pid { if self.selected_process_pid != self.prev_selected_process_pid {
self.clear_process_details(); self.clear_process_details();
@@ -738,23 +925,10 @@ impl App {
// Layout to get areas // Layout to get areas
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default() let layout = self.layout(area);
.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 // Content wheel scrolling
let content = per_core_content_area(top[1]); let content = per_core_content_area(layout.per_core);
per_core_handle_mouse( per_core_handle_mouse(
&mut self.per_core_scroll, &mut self.per_core_scroll,
m, m,
@@ -772,7 +946,7 @@ impl App {
&mut self.per_core_scroll, &mut self.per_core_scroll,
&mut self.per_core_drag, &mut self.per_core_drag,
m, m,
top[1], layout.per_core,
total_rows, total_rows,
); );
@@ -783,11 +957,17 @@ impl App {
content.height as usize, content.height as usize,
); );
// Refresh filter cache before partial borrows of self.
let _ = self.procs_filter();
let search_box_visible =
self.process_search_active || !self.process_search_query.is_empty();
// Processes table: sort by column on header click and handle row selection // Processes table: sort by column on header click and handle row selection
if let (Some(mm), Some(p_area)) = if let (Some(_mm), Some(p_area)) =
(self.last_metrics.as_ref(), self.last_procs_area) (self.last_metrics.as_ref(), self.last_procs_area)
{ {
use crate::ui::processes::ProcessMouseParams; use crate::ui::processes::ProcessMouseParams;
let total_rows = self.procs_filtered.len();
if let Some(new_sort) = if let Some(new_sort) =
processes_handle_mouse_with_selection(ProcessMouseParams { processes_handle_mouse_with_selection(ProcessMouseParams {
scroll_offset: &mut self.procs_scroll_offset, scroll_offset: &mut self.procs_scroll_offset,
@@ -796,12 +976,14 @@ impl App {
drag: &mut self.procs_drag, drag: &mut self.procs_drag,
mouse: m, mouse: m,
area: p_area, area: p_area,
total_rows: mm.top_processes.len(), total_rows,
metrics: self.last_metrics.as_ref(), metrics: self.last_metrics.as_ref(),
sort_by: self.procs_sort_by, search_box_visible,
filtered_indices: &self.procs_filtered,
}) })
{ {
self.procs_sort_by = new_sort; self.procs_sort_by = new_sort;
self.invalidate_procs_filter();
} }
} }
@@ -838,22 +1020,36 @@ impl App {
// Only poll processes every 2s // Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval { if self.last_procs_poll.elapsed() >= self.procs_interval {
let mut updated = false;
if let Ok(AgentResponse::Processes(procs)) = if let Ok(AgentResponse::Processes(procs)) =
ws.request(AgentRequest::Processes).await ws.request(AgentRequest::Processes).await
&& let Some(mm) = self.last_metrics.as_mut() && let Some(mm) = self.last_metrics.as_mut()
{ {
mm.top_processes = procs.top_processes; mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count); mm.process_count = Some(procs.process_count);
updated = true;
}
if updated {
self.invalidate_procs_filter();
// Rebuild the pre-formatted row cache for the next
// ~N frames. Done once per poll, not per frame.
if let Some(mm) = self.last_metrics.as_ref() {
self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache(
mm,
&mut self.procs_row_cache,
);
}
} }
self.last_procs_poll = Instant::now(); self.last_procs_poll = Instant::now();
} }
// Only poll disks every 5s // Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval { if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Ok(AgentResponse::Disks(disks)) = if let Ok(AgentResponse::Disks(mut disks)) =
ws.request(AgentRequest::Disks).await ws.request(AgentRequest::Disks).await
&& let Some(mm) = self.last_metrics.as_mut() && let Some(mm) = self.last_metrics.as_mut()
{ {
dedup_disks(&mut disks);
mm.disks = disks; mm.disks = disks;
} }
self.last_disks_poll = Instant::now(); self.last_disks_poll = Instant::now();
@@ -879,11 +1075,23 @@ impl App {
Ok(Ok(AgentResponse::ProcessMetrics(details))) => { Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
// Update history for sparklines // Update history for sparklines
let cpu_usage = details.process.cpu_usage; let cpu_usage = details.process.cpu_usage;
push_capped(&mut self.process_cpu_history, cpu_usage, 600); let evicted_cpu = push_capped(
&mut self.process_cpu_history,
cpu_usage,
600,
);
self.process_cpu_history_sum = self.process_cpu_history_sum
+ cpu_usage
- evicted_cpu.unwrap_or(0.0);
let mem_bytes = details.process.mem_bytes; let mem_bytes = details.process.mem_bytes;
push_capped(&mut self.process_mem_history, mem_bytes, 600); push_capped(&mut self.process_mem_history, mem_bytes, 600);
// Track maximum memory usage
if mem_bytes > self.max_process_mem_bytes {
self.max_process_mem_bytes = mem_bytes;
}
// I/O bytes from agent are cumulative, calculate deltas // I/O bytes from agent are cumulative, calculate deltas
if let Some(read) = details.process.read_bytes { if let Some(read) = details.process.read_bytes {
let delta = if let Some(last) = self.last_io_read_bytes let delta = if let Some(last) = self.last_io_read_bytes
@@ -979,37 +1187,65 @@ impl App {
Ok(()) Ok(())
} }
/// Mark the filtered-process cache stale. Call this whenever
/// `procs_sort_by`, `process_search_query`, or the top_processes content
/// changes — the cache is rebuilt lazily on the next read.
pub fn invalidate_procs_filter(&mut self) {
self.procs_filter_dirty = true;
}
/// Lazily refresh and return the cached filtered+sorted process indices.
/// Empty slice when there are no metrics yet.
pub fn procs_filter(&mut self) -> &[usize] {
if self.procs_filter_dirty {
self.procs_filtered.clear();
if let Some(m) = self.last_metrics.as_ref() {
crate::ui::processes::fill_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
&mut self.procs_filtered,
);
}
self.procs_filter_dirty = false;
}
&self.procs_filtered
}
/// Clear process details when modal is closed or selection changes /// Clear process details when modal is closed or selection changes
pub fn clear_process_details(&mut self) { pub fn clear_process_details(&mut self) {
self.process_details = None; self.process_details = None;
self.journal_entries = None; self.journal_entries = None;
self.process_cpu_history.clear(); self.process_cpu_history.clear();
self.process_cpu_history_sum = 0.0;
self.process_mem_history.clear(); self.process_mem_history.clear();
self.process_io_read_history.clear(); self.process_io_read_history.clear();
self.process_io_write_history.clear(); self.process_io_write_history.clear();
self.last_io_read_bytes = None; self.last_io_read_bytes = None;
self.last_io_write_bytes = None; self.last_io_write_bytes = None;
self.max_process_mem_bytes = 0;
self.process_details_unsupported = false; self.process_details_unsupported = false;
} }
fn update_with_metrics(&mut self, mut m: Metrics) { fn update_with_metrics(&mut self, mut m: Metrics) {
if let Some(prev) = &self.last_metrics { if let Some(prev) = self.last_metrics.as_mut() {
// Preserve slower fields when the fast payload omits them // Preserve slower fields when the fast payload omits them.
// prev is about to be dropped so we can move its Vecs instead of cloning.
if m.disks.is_empty() { if m.disks.is_empty() {
m.disks = prev.disks.clone(); m.disks = std::mem::take(&mut prev.disks);
} }
if m.top_processes.is_empty() { if m.top_processes.is_empty() {
m.top_processes = prev.top_processes.clone(); m.top_processes = std::mem::take(&mut prev.top_processes);
} }
// Preserve total processes count across fast updates
if m.process_count.is_none() { if m.process_count.is_none() {
m.process_count = prev.process_count; m.process_count = prev.process_count;
} }
} }
// CPU avg history // CPU avg history with running sum
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64; let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.cpu_hist, v, 600); let evicted = push_capped(&mut self.cpu_hist, v, 600);
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
// Per-core history (push current samples) // Per-core history (push current samples)
self.per_core_hist.ensure_cores(m.cpu_per_core.len()); self.per_core_hist.ensure_cores(m.cpu_per_core.len());
@@ -1039,120 +1275,118 @@ impl App {
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) { pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
let area = f.area(); let area = f.area();
let l = self.layout(area);
// Root rows: header, top (cpu avg + per-core), memory, swap, bottom // Header — refresh cached strings only when their inputs change so the
let rows = ratatui::layout::Layout::default() // ratatui diff renderer can suppress repaints on idle frames.
.direction(Direction::Vertical) {
.constraints([ let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
Constraint::Length(1), // header let key = (
Constraint::Ratio(1, 3), // top row hostname.unwrap_or("").to_string(),
Constraint::Length(3), // memory (left) + GPU (right, part 1) self.is_tls,
Constraint::Length(3), // swap (left) + GPU (right, part 2) self.has_token,
Constraint::Min(10), // bottom: disks + net (left), top procs (right) );
]) if self.header_title_key != key {
.split(area); self.header_title = build_header_title(hostname, self.is_tls, self.has_token);
self.header_title_key = key;
}
// Header let intervals_key = (
draw_header( self.metrics_interval.as_millis(),
self.procs_interval.as_millis(),
);
if self.header_intervals_key != intervals_key {
self.header_intervals_text =
build_header_intervals(intervals_key.0, intervals_key.1);
self.header_intervals_key = intervals_key;
}
}
draw_header(f, l.header, &self.header_title, &self.header_intervals_text);
draw_cpu_avg_graph(
f, f,
rows[0], l.cpu,
&mut self.cpu_hist,
self.cpu_hist_sum,
self.last_metrics.as_ref(), 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( draw_per_core_bars(
f, f,
top_lr[1], l.per_core,
self.last_metrics.as_ref(), self.last_metrics.as_ref(),
&self.per_core_hist, &mut self.per_core_hist,
self.per_core_scroll, self.per_core_scroll,
); );
// Memory + Swap rows split into left/right columns // Memory + Swap: stacked vertically in the normal layout, side by side in the
let mem_lr = ratatui::layout::Layout::default() // row Disks vacates in compact mode.
.direction(Direction::Horizontal) draw_mem(f, l.mem, self.last_metrics.as_ref());
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) draw_swap(f, l.swap, self.last_metrics.as_ref());
.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 // GPU: a panel beside Memory/Swap normally, a single full-width line in compact
draw_mem(f, mem_lr[0], self.last_metrics.as_ref()); // mode, and absent entirely when the host reports no GPU while compact.
draw_swap(f, swap_lr[0], self.last_metrics.as_ref()); if let Some(gpu_area) = l.gpu {
if l.mode.is_compact() {
draw_gpu_compact(f, gpu_area, self.last_metrics.as_ref());
} else {
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
}
}
// Right: GPU spans the same vertical space as Memory + Swap if let Some(disks_area) = l.disks {
let gpu_area = ratatui::layout::Rect { draw_disks(f, disks_area, self.last_metrics.as_ref());
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 // Net titles only change when the throughput or peak changes.
let bottom_lr = ratatui::layout::Layout::default() let rx_now = self.rx_hist.back().copied().unwrap_or(0);
.direction(Direction::Horizontal) let rx_key = (rx_now, self.rx_peak);
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) if self.net_dl_key != rx_key {
.split(rows[4]); self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak);
self.net_dl_key = rx_key;
// 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( draw_net_spark(
f, f,
left_stack[1], l.download,
&format!( &self.net_dl_title,
"Download (KB/s) — now: {} | peak: {}", &mut self.rx_hist,
self.rx_hist.back().copied().unwrap_or(0),
self.rx_peak
),
&self.rx_hist,
ratatui::style::Color::Green, ratatui::style::Color::Green,
); );
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
let tx_key = (tx_now, self.tx_peak);
if self.net_ul_key != tx_key {
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
self.net_ul_key = tx_key;
}
draw_net_spark( draw_net_spark(
f, f,
left_stack[2], l.upload,
&format!( &self.net_ul_title,
"Upload (KB/s) — now: {} | peak: {}", &mut self.tx_hist,
self.tx_hist.back().copied().unwrap_or(0),
self.tx_peak
),
&self.tx_hist,
ratatui::style::Color::Blue, ratatui::style::Color::Blue,
); );
// Right bottom: Top Processes fills the column // Right bottom: Top Processes fills the column
let procs_area = bottom_lr[1]; let procs_area = l.procs;
// Cache for input handlers // Cache for input handlers
self.last_procs_area = Some(procs_area); self.last_procs_area = Some(procs_area);
// Refresh the filter cache before partial borrows of self.
let _ = self.procs_filter();
crate::ui::processes::draw_top_processes( crate::ui::processes::draw_top_processes(
f, f,
procs_area, procs_area,
self.last_metrics.as_ref(), crate::ui::processes::ProcessDisplayParams {
self.procs_scroll_offset, metrics: self.last_metrics.as_ref(),
self.procs_sort_by, scroll_offset: self.procs_scroll_offset,
self.selected_process_pid, sort_by: self.procs_sort_by,
self.selected_process_index, selected_process_pid: self.selected_process_pid,
selected_process_index: self.selected_process_index,
search_query: &self.process_search_query,
search_active: self.process_search_active,
filtered_indices: &self.procs_filtered,
cached_rows: &self.procs_row_cache,
peak_cpu: self.procs_row_peak_cpu,
},
); );
// Render modals on top of everything else // Render modals on top of everything else
@@ -1165,10 +1399,12 @@ impl App {
journal: self.journal_entries.as_ref(), journal: self.journal_entries.as_ref(),
history: ProcessHistoryData { history: ProcessHistoryData {
cpu: &self.process_cpu_history, cpu: &self.process_cpu_history,
cpu_sum: self.process_cpu_history_sum,
mem: &self.process_mem_history, mem: &self.process_mem_history,
io_read: &self.process_io_read_history, io_read: &self.process_io_read_history,
io_write: &self.process_io_write_history, io_write: &self.process_io_write_history,
}, },
max_mem_bytes: self.max_process_mem_bytes,
unsupported: self.process_details_unsupported, unsupported: self.process_details_unsupported,
}, },
); );
@@ -1178,63 +1414,6 @@ impl App {
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
Self { Self::new()
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,
selected_process_pid: None,
selected_process_index: None,
prev_selected_process_pid: 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),
process_details: None,
journal_entries: None,
process_cpu_history: VecDeque::with_capacity(600),
process_mem_history: VecDeque::with_capacity(600),
process_io_read_history: VecDeque::with_capacity(600),
process_io_write_history: VecDeque::with_capacity(600),
last_io_read_bytes: None,
last_io_write_bytes: None,
process_details_unsupported: false,
last_process_details_poll: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
last_journal_poll: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
process_details_interval: Duration::from_millis(500),
journal_interval: Duration::from_secs(5),
ws_url: String::new(),
tls_ca: None,
verify_hostname: false,
is_tls: false,
has_token: false,
modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(),
original_disconnect_time: None,
connection_retry_count: 0,
last_auto_retry: None,
replacement_connection: None,
}
} }
} }
+16 -7
View File
@@ -2,16 +2,25 @@
use std::collections::VecDeque; use std::collections::VecDeque;
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) { /// Push a value into a capped deque. Returns the evicted front element if any.
if dq.len() == cap { /// Callers maintaining a running sum can use this to update the sum without
dq.pop_front(); /// re-iterating the whole deque.
} pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> {
let evicted = if dq.len() == cap {
dq.pop_front()
} else {
None
};
dq.push_back(v); dq.push_back(v);
evicted
} }
// Keeps a history deque per core with a fixed capacity // Keeps a history deque per core with a fixed capacity.
// Storage is u64 so sparkline rendering can hand the slice directly to
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
// allocation or widening conversion.
pub struct PerCoreHistory { pub struct PerCoreHistory {
pub deques: Vec<VecDeque<u16>>, pub deques: Vec<VecDeque<u64>>,
cap: usize, cap: usize,
} }
@@ -35,7 +44,7 @@ impl PerCoreHistory {
pub fn push_samples(&mut self, samples: &[f32]) { pub fn push_samples(&mut self, samples: &[f32]) {
self.ensure_cores(samples.len()); self.ensure_cores(samples.len());
for (i, v) in samples.iter().enumerate() { for (i, v) in samples.iter().enumerate() {
let val = v.clamp(0.0, 100.0).round() as u16; let val = v.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.deques[i], val, self.cap); push_capped(&mut self.deques[i], val, self.cap);
} }
} }
+77 -12
View File
@@ -22,6 +22,7 @@ pub(crate) struct ParsedArgs {
metrics_interval_ms: Option<u64>, metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>, processes_interval_ms: Option<u64>,
verify_hostname: bool, verify_hostname: bool,
compact: bool,
} }
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> { pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
@@ -36,11 +37,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut metrics_interval_ms: Option<u64> = None; let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None; let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false; let mut verify_hostname = false;
let mut compact = false;
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
match arg.as_str() { match arg.as_str() {
"-h" | "--help" => { "-h" | "--help" => {
return Err(format!( return Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n" "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
)); ));
} }
"--tls-ca" | "-t" => { "--tls-ca" | "-t" => {
@@ -61,6 +63,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
"--demo" => { "--demo" => {
demo = true; demo = true;
} }
"--compact" => {
// Force the small-window layout at any terminal size. Without it the
// layout switches on its own once the window gets too short.
compact = true;
}
"--dry-run" => { "--dry-run" => {
// intentionally undocumented // intentionally undocumented
dry_run = true; dry_run = true;
@@ -100,7 +107,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
url = Some(arg); url = Some(arg);
} else { } else {
return Err(format!( return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]" "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
)); ));
} }
} }
@@ -116,6 +123,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
metrics_interval_ms, metrics_interval_ms,
processes_interval_ms, processes_interval_ms,
verify_hostname, verify_hostname,
compact,
}) })
} }
@@ -136,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref()).await; return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
} }
let profiles_file = load_profiles(); let profiles_file = load_profiles();
@@ -241,7 +249,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if (1..=names.len()).contains(&idx) { if (1..=names.len()).contains(&idx) {
let name = &names[idx - 1]; let name = &names[idx - 1];
if name == "demo" { if name == "demo" {
return run_demo_mode(parsed.tls_ca.as_deref()).await; return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
} }
if let Some(entry) = profiles_mut.profiles.get(name) { if let Some(entry) = profiles_mut.profiles.get(name) {
( (
@@ -301,7 +309,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
eprintln!("If you don't have an agent running, you can try the demo mode."); eprintln!("If you don't have an agent running, you can try the demo mode.");
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") { if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
return run_demo_mode(parsed.tls_ca.as_deref()).await; return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
} else { } else {
eprintln!("Aborting. You can run 'socktop --help' for usage information."); eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(()); return Ok(());
@@ -315,7 +323,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let has_token = url.contains("token="); let has_token = url.contains("token=");
let mut app = App::new() let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms) .with_intervals(metrics_interval_ms, processes_interval_ms)
.with_status(is_tls, has_token); .with_status(is_tls, has_token)
.with_compact(parsed.compact);
if parsed.dry_run { if parsed.dry_run {
return Ok(()); return Ok(());
} }
@@ -379,11 +388,23 @@ fn gather_intervals(
} }
// Demo mode implementation // Demo mode implementation
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { async fn run_demo_mode(
_tls_ca: Option<&str>,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231; let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws"); let url = format!("ws://127.0.0.1:{port}/ws");
let child = spawn_demo_agent(port)?; let child = match spawn_demo_agent(port) {
let mut app = App::new(); Ok(child) => child,
// The agent ships as its own binary, so a missing one is a setup problem,
// not a crash: tell the user how to fix it instead of dumping an io error.
Err(e @ DemoAgentError::NotFound(_)) => {
eprintln!("{e}");
return Ok(());
}
Err(e) => return Err(e.into()),
};
let mut app = App::new().with_compact(compact);
// Demo mode connects to localhost, so disable hostname verification // Demo mode connects to localhost, so disable hostname verification
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } } tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
} }
@@ -399,9 +420,50 @@ impl Drop for DemoGuard {
eprintln!("Stopped demo agent on port {}", self.port); eprintln!("Stopped demo agent on port {}", self.port);
} }
} }
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> { #[derive(Debug)]
enum DemoAgentError {
/// The socktop_agent executable could not be located.
NotFound(std::path::PathBuf),
Io(std::io::Error),
}
impl std::fmt::Display for DemoAgentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(candidate) => write!(
f,
"Could not start demo mode: '{}' was not found{}.\n\
\n\
Demo mode runs a local agent, which is shipped as a separate binary\n\
and is not installed alongside the socktop TUI. Install it with:\n\
\n cargo install socktop_agent\n\n\
then run socktop again. See {} for other install options.",
candidate.display(),
// A bare file name means find_agent_executable() fell back to a PATH lookup.
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
" on your PATH"
} else {
""
},
env!("CARGO_PKG_HOMEPAGE"),
),
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
}
}
}
impl std::error::Error for DemoAgentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NotFound(_) => None,
Self::Io(e) => Some(e),
}
}
}
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
let candidate = find_agent_executable(); let candidate = find_agent_executable();
let mut cmd = std::process::Command::new(candidate); let mut cmd = std::process::Command::new(&candidate);
cmd.arg("--port").arg(port.to_string()); cmd.arg("--port").arg(port.to_string());
cmd.env("SOCKTOP_ENABLE_SSL", "0"); cmd.env("SOCKTOP_ENABLE_SSL", "0");
@@ -409,7 +471,10 @@ fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>>
//cmd.env("SOCKTOP_AGENT_GPU", "0"); //cmd.env("SOCKTOP_AGENT_GPU", "0");
//cmd.env("SOCKTOP_AGENT_TEMP", "0"); //cmd.env("SOCKTOP_AGENT_TEMP", "0");
let child = cmd.spawn()?; let child = cmd.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
_ => DemoAgentError::Io(e),
})?;
std::thread::sleep(std::time::Duration::from_millis(300)); std::thread::sleep(std::time::Duration::from_millis(300));
Ok(DemoGuard { Ok(DemoGuard {
port, port,
+196 -64
View File
@@ -7,7 +7,9 @@ use ratatui::style::{Color, Style};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Sparkline}, widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
},
}; };
use crate::history::PerCoreHistory; use crate::history::PerCoreHistory;
@@ -42,8 +44,8 @@ pub fn per_core_content_area(area: Rect) -> Rect {
/// Handles key events for per-core CPU bars. /// Handles key events for per-core CPU bars.
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) { pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
match key.code { match key.code {
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1), KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1),
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1), KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1),
KeyCode::PageUp => { KeyCode::PageUp => {
let step = page_size.max(1); let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_sub(step); *scroll_offset = scroll_offset.saturating_sub(step);
@@ -133,11 +135,9 @@ pub fn per_core_handle_scrollbar_mouse(
} }
let thumb_len = (track * view).div_ceil(total).max(1).min(track); let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let top_for_offset = |off: usize| -> usize { let top_for_offset = |off: usize| -> usize {
if max_off == 0 { ((track - thumb_len) * off + max_off / 2)
0 .checked_div(max_off)
} else { .unwrap_or(0)
((track - thumb_len) * off + max_off / 2) / max_off
}
}; };
let thumb_top = top_for_offset(offset); let thumb_top = top_for_offset(offset);
@@ -190,11 +190,9 @@ pub fn per_core_handle_scrollbar_mouse(
// Inverse mapping top -> offset // Inverse mapping top -> offset
if track > thumb_len { if track > thumb_len {
let denom = track - thumb_len; let denom = track - thumb_len;
offset = if max_off == 0 { offset = (new_top * max_off + denom / 2)
0 .checked_div(denom)
} else { .unwrap_or(0);
(new_top * max_off + denom / 2) / denom
};
} else { } else {
offset = 0; offset = 0;
} }
@@ -234,26 +232,71 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
} }
/// Draws the CPU average sparkline graph. /// Draws the CPU average sparkline graph.
///
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
/// fold the (up to 600-element) deque on every frame.
pub fn draw_cpu_avg_graph( pub fn draw_cpu_avg_graph(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
hist: &std::collections::VecDeque<u64>, hist: &mut std::collections::VecDeque<u64>,
hist_sum: u64,
m: Option<&Metrics>, m: Option<&Metrics>,
) { ) {
let avg_cpu = if hist.is_empty() {
0.0
} else {
hist_sum as f64 / hist.len() as f64
};
let title = if let Some(mm) = m { let title = if let Some(mm) = m {
format!("CPU avg (now: {:>5.1}%)", mm.cpu_total) format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
} else { } else {
"CPU avg".into() "CPU avg".into()
}; };
// Build the top-right info (CPU temp and polling intervals)
let top_right_info = if let Some(mm) = m {
mm.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into())
} else {
String::new()
};
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
// us skip the per-frame Vec allocation .collect() used to do.
let max_points = area.width.saturating_sub(2) as usize; let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points); let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect(); let slice = &hist.make_contiguous()[start..];
let spark = Sparkline::default() let spark = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(title)) .block(Block::default().borders(Borders::ALL).title(title))
.data(&data) .data(slice)
.max(100) .max(100)
.style(Style::default().fg(Color::Cyan)); .style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area); f.render_widget(spark, area);
// Render the top-right info as text overlay in the top-right corner
if !top_right_info.is_empty() {
let info_area = Rect {
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
y: area.y,
width: top_right_info.len() as u16 + 1,
height: 1,
};
let info_line = Line::from(Span::raw(top_right_info));
f.render_widget(Paragraph::new(info_line), info_area);
}
} }
/// Draws the per-core CPU bars with sparklines and trends. /// Draws the per-core CPU bars with sparklines and trends.
@@ -261,7 +304,7 @@ pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
m: Option<&Metrics>, m: Option<&Metrics>,
per_core_hist: &PerCoreHistory, per_core_hist: &mut PerCoreHistory,
scroll_offset: usize, scroll_offset: usize,
) { ) {
f.render_widget( f.render_widget(
@@ -306,7 +349,7 @@ pub fn draw_per_core_bars(
let rect = vchunks[i]; let rect = vchunks[i];
let hchunks = Layout::default() let hchunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Min(6), Constraint::Length(12)]) .constraints([Constraint::Min(6), Constraint::Length(13)])
.split(rect); .split(rect);
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0); let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
@@ -317,12 +360,17 @@ pub fn draw_per_core_bars(
.map(|v| v as f32) .map(|v| v as f32)
.unwrap_or(curr); .unwrap_or(curr);
// Trend indicator. Various Unicode glyphs we tried for the "flat"
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
// coverage; combined with the next column being `100.0` they read as
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
// for the flat case — no character, no fallback, no confusion.
let trend = if curr > older + 0.2 { let trend = if curr > older + 0.2 {
"" ""
} else if curr + 0.2 < older { } else if curr + 0.2 < older {
"" ""
} else { } else {
"" " "
}; };
let fg = match curr { let fg = match curr {
@@ -331,24 +379,24 @@ pub fn draw_per_core_bars(
_ => Color::Red, _ => Color::Red,
}; };
let hist: Vec<u64> = per_core_hist // Borrow the per-core deque mutably so we can hand a contiguous slice
.deques // to Sparkline without allocating a fresh Vec each frame.
.get(idx) if let Some(d) = per_core_hist.deques.get_mut(idx) {
.map(|d| { let max_points = hchunks[0].width as usize;
let max_points = hchunks[0].width as usize; let start = d.len().saturating_sub(max_points);
let start = d.len().saturating_sub(max_points); let slice = &d.make_contiguous()[start..];
d.iter().skip(start).map(|&v| v as u64).collect() let spark = Sparkline::default()
}) .data(slice)
.unwrap_or_default(); .max(100)
.style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]);
}
let spark = Sparkline::default() // Hard space between the trend mark and the number — even if the
.data(&hist) // arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
.max(100) // them, this space prevents the trend mark from visually joining
.style(Style::default().fg(fg)); // `100.0` to look like a negative value.
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
f.render_widget(spark, hchunks[0]);
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
let line = Line::from(Span::styled( let line = Line::from(Span::styled(
label, label,
Style::default().fg(fg).add_modifier(Modifier::BOLD), Style::default().fg(fg).add_modifier(Modifier::BOLD),
@@ -356,38 +404,122 @@ pub fn draw_per_core_bars(
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]); f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
} }
// Custom 1-col scrollbar with arrows, track, and exact mapping // 1-col scrollbar (ratatui built-in widget). Skips drawing when the
// content fits in the viewport, matching the previous behaviour.
let scroll_area = Rect { let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1), x: inner.x + inner.width.saturating_sub(1),
y: inner.y, y: inner.y,
width: 1, width: 1,
height: inner.height, height: inner.height,
}; };
if scroll_area.height >= 3 { let max_off = total_rows.saturating_sub(viewport_rows);
let track = (scroll_area.height - 2) as usize; if scroll_area.height >= 3 && max_off > 0 {
let total = total_rows.max(1); let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
let view = viewport_rows.clamp(1, total); .begin_symbol(Some(""))
let max_off = total.saturating_sub(view); .end_symbol(Some(""))
.thumb_symbol("")
let thumb_len = (track * view).div_ceil(total).max(1).min(track); .track_symbol(Some(""))
let thumb_top = if max_off == 0 { .thumb_style(Style::default().fg(SB_THUMB))
0 .track_style(Style::default().fg(SB_TRACK))
} else { .begin_style(Style::default().fg(SB_ARROW))
((track - thumb_len) * offset + max_off / 2) / max_off .end_style(Style::default().fg(SB_ARROW));
}; let mut state = ScrollbarState::new(max_off).position(offset);
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
// 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 { #[cfg(test)]
if i >= thumb_top && i < thumb_top + thumb_len { mod render_tests {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB)))); use super::*;
} else { use ratatui::Terminal;
lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK)))); use ratatui::backend::TestBackend;
} use socktop_connector::Metrics;
}
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW)))); fn fake_metrics(cores: Vec<f32>) -> Metrics {
Metrics {
f.render_widget(Paragraph::new(lines), scroll_area); cpu_total: 0.0,
cpu_per_core: cores,
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus: None,
process_count: Some(0),
}
}
fn dump(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
/// coverage. When a core sat at exactly 100% the label rendered as
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
/// literal space for the flat case AND insert a hard space between every
/// trend mark and the number, so no glyph substitution can produce a
/// "-100" substring. We assert that across flat AND transitioning cores.
#[test]
fn percore_label_never_renders_as_negative() {
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
let mut hist = PerCoreHistory::new(60);
hist.ensure_cores(4);
// First sample: history is empty, no trend on first frame.
hist.push_samples(&m.cpu_per_core);
// Second sample: identical values → flat trend (the user's complaint).
hist.push_samples(&m.cpu_per_core);
let backend = TestBackend::new(120, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
})
.unwrap();
let out = dump(&terminal);
eprintln!("---flat 100% render---\n{out}");
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
// Decreasing trend at saturation: hist was high, current drops a bit.
let mut hist2 = PerCoreHistory::new(60);
hist2.ensure_cores(4);
for _ in 0..25 {
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
}
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
hist2.push_samples(&m2.cpu_per_core);
let backend = TestBackend::new(120, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
})
.unwrap();
let out = dump(&terminal);
eprintln!("---decreasing render---\n{out}");
assert!(
!out.contains("-100"),
"found '-100' in decreasing-trend render"
);
assert!(
!out.contains("-80"),
"found '-80' in decreasing-trend render"
);
} }
} }
+5 -10
View File
@@ -24,16 +24,11 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
return; return;
} }
// Filter duplicates by keeping first occurrence of each unique name // Deduplication is performed once on the App side when fresh disk data
let mut seen_names = std::collections::HashSet::new(); // arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
let unique_disks: Vec<_> = mm // here would rebuild a HashSet ~10x per refresh for no reason).
.disks
.iter()
.filter(|d| seen_names.insert(d.name.clone()))
.collect();
let per_disk_h = 3u16; let per_disk_h = 3u16;
let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize; let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
let constraints: Vec<Constraint> = (0..max_cards) let constraints: Vec<Constraint> = (0..max_cards)
.map(|_| Constraint::Length(per_disk_h)) .map(|_| Constraint::Length(per_disk_h))
@@ -44,7 +39,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
.split(inner); .split(inner);
for (i, slot) in rows.iter().enumerate() { for (i, slot) in rows.iter().enumerate() {
let d = unique_disks[i]; let d = &mm.disks[i];
let used = d.total.saturating_sub(d.available); let used = d.total.saturating_sub(d.available);
let ratio = if d.total > 0 { let ratio = if d.total > 0 {
used as f64 / d.total as f64 used as f64 / d.total as f64
+206
View File
@@ -121,3 +121,209 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
); );
} }
} }
/// One-line GPU strip for compact mode: no device name (it is the first thing to lose
/// value when rows are scarce), just utilisation and VRAM on the single content row
/// between the block borders. Only the first GPU fits; the title says so when there are
/// more.
pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let gpus = m.and_then(|mm| mm.gpus.as_ref());
let count = gpus.map(|g| g.len()).unwrap_or(0);
let title = if count > 1 {
format!("GPU (1/{count})")
} else {
"GPU".to_string()
};
f.render_widget(Block::default().borders(Borders::ALL).title(title), area);
if area.height < 3 || area.width <= 2 {
return;
}
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width - 2,
height: 1,
};
let Some(g) = gpus.and_then(|v| v.first()) else {
f.render_widget(Paragraph::new("No GPUs"), inner);
return;
};
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = if total > 0 {
(used as f64 / total as f64).clamp(0.0, 1.0)
} else {
0.0
};
let util_label = format!("util: {util}%");
let mem_label = format!(
"vram: {}/{} ({}%)",
fmt_bytes(used),
fmt_bytes(total),
(mem_ratio * 100.0).round() as u16
);
// Bars are sized explicitly rather than left to stretch: an idle bar renders as
// empty cells, so a full-width one turns into a long blank run between two labels.
const MIN_GAUGE_W: u16 = 6;
const MAX_GAUGE_W: u16 = 24;
let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side
let gauge_w = inner
.width
.saturating_sub(labels_w)
.min(2 * MAX_GAUGE_W)
.div_euclid(2);
// Too narrow for bars worth drawing: keep the numbers, drop the bars.
if gauge_w < MIN_GAUGE_W {
f.render_widget(
Paragraph::new(Span::raw(format!("{util_label} {mem_label}")))
.style(Style::default().fg(Color::Gray)),
inner,
);
return;
}
// Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous
// on a single line: with an idle bar rendering empty, the next pair's fill ends up
// flush against the previous pair's text and reads as belonging to it.
let mut x = inner.x;
let mut place = |w: u16| {
let r = Rect {
x,
y: inner.y,
width: w,
height: 1,
};
x += w;
r
};
let util_rect = place(util_label.len() as u16 + 2);
let util_bar = place(gauge_w);
let mem_rect = place(mem_label.len() as u16 + 2);
let mem_bar = place(gauge_w);
let label = |text: &str| {
Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray))
};
f.render_widget(label(&util_label), util_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
.ratio(util as f64 / 100.0),
util_bar,
);
f.render_widget(label(&mem_label), mem_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::LightMagenta))
.label(Span::raw(""))
.ratio(mem_ratio),
mem_bar,
);
}
#[cfg(test)]
mod render_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use socktop_connector::{GpuInfo, Metrics};
fn gpu(name: &str) -> GpuInfo {
GpuInfo {
name: Some(name.into()),
vendor: None,
utilization: Some(42.0),
mem_used: Some(4_724_464_025),
mem_total: Some(17_070_817_280),
temp: None,
}
}
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
Metrics {
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus,
process_count: Some(0),
}
}
fn render(width: u16, m: &Metrics) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
terminal
.draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m)))
.unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Compact mode drops the device name — the row is one line and the numbers are
/// what the space is for.
#[test]
fn compact_strip_omits_the_device_name() {
let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")]));
let out = render(80, &m);
assert!(
!out.contains("NVIDIA"),
"name leaked into compact strip:\n{out}"
);
assert!(out.contains("util: 42%"), "{out}");
assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}");
}
/// A second GPU cannot fit on one line, so the title has to say the strip is partial
/// rather than silently showing only the first card.
#[test]
fn multiple_gpus_are_flagged_in_the_title() {
let one = render(80, &metrics(Some(vec![gpu("a")])));
assert!(one.contains("GPU") && !one.contains("1/"), "{one}");
let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")])));
assert!(two.contains("GPU (1/2)"), "{two}");
}
/// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must
/// never drop the numbers.
#[test]
fn narrow_strip_keeps_the_numbers() {
let m = metrics(Some(vec![gpu("a")]));
for width in [20u16, 30, 40, 47, 48, 80, 200] {
let out = render(width, &m);
if width >= 40 {
assert!(out.contains("util: 42%"), "width {width}:\n{out}");
}
// No panic, and the block always closes on the last row.
assert_eq!(out.lines().count(), 3, "width {width}");
}
}
#[test]
fn missing_gpu_payload_does_not_panic() {
assert!(render(80, &metrics(None)).contains("No GPUs"));
assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs"));
}
}
+33 -41
View File
@@ -1,52 +1,44 @@
//! Top header with hostname and CPU temperature indicator. //! Top header with hostname and CPU temperature indicator.
use crate::types::Metrics;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
widgets::{Block, Borders}, text::{Line, Span},
widgets::{Block, Borders, Paragraph},
}; };
use std::time::Duration;
pub fn draw_header( /// Build the header's left-side title from session state. Callers cache the
f: &mut ratatui::Frame<'_>, /// returned String and only rebuild it when one of the inputs changes.
area: Rect, pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
m: Option<&Metrics>, let base = match hostname {
is_tls: bool, Some(h) => format!("socktop — host: {h}"),
has_token: bool, None => "socktop — connecting...".into(),
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" }; 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()]; let mut parts = vec![base, tls_txt.into()];
if !tok_txt.is_empty() { if has_token {
parts.push(tok_txt.into()); parts.push("🔑 token".into());
}
parts.push("(a: about, h: help, q: quit)".into());
parts.join(" | ")
}
/// Build the right-side polling interval text. Callers cache this string.
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
format!("{metrics_ms}ms metrics | {procs_ms}ms procs")
}
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
let intervals_width = intervals.len() as u16;
if area.width > intervals_width + 2 {
let right_area = Rect {
x: area.x + area.width.saturating_sub(intervals_width + 1),
y: area.y,
width: intervals_width,
height: 1,
};
let intervals_line = Line::from(Span::raw(intervals));
f.render_widget(Paragraph::new(intervals_line), right_area);
} }
parts.push(intervals);
parts.push("(q to quit)".into());
let title = parts.join(" | ");
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
} }
+352
View File
@@ -0,0 +1,352 @@
//! Root layout computation, shared by the draw path and the input hit-testing paths.
//!
//! Two modes:
//!
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
//! network graphs next to the process table.
//!
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
//! by side into the space it vacated, the GPU collapses to a single full-width line
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
//! long before the rest of the panes stop being useful.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Which of the two layouts [`compute`] produced.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayoutMode {
Normal,
Compact,
}
impl LayoutMode {
pub fn is_compact(self) -> bool {
matches!(self, LayoutMode::Compact)
}
}
/// Rows the Disks pane needs before it can show one disk card: the card itself is
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
const DISKS_MIN_H: u16 = 5;
/// Header line.
const HEADER_H: u16 = 1;
/// Memory and Swap gauges: 1 content row between borders.
const GAUGE_H: u16 = 3;
/// A network graph at its preferred height.
const NET_H: u16 = 5;
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
// borders) before the network graphs are allowed to shrink, because restoring the CPU
// panes is the entire point of the mode.
const TOP_MIN_H: u16 = 5;
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
#[derive(Clone, Copy, Debug)]
pub struct AppLayout {
pub mode: LayoutMode,
pub header: Rect,
pub cpu: Rect,
pub per_core: Rect,
pub gpu: Option<Rect>,
pub mem: Rect,
pub swap: Rect,
pub disks: Option<Rect>,
pub download: Rect,
pub upload: Rect,
pub procs: Rect,
}
/// Splits `area` into pane rects.
///
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
if force_compact {
return compact(area, has_gpu);
}
let normal = normal(area);
match normal.disks {
Some(d) if d.height >= DISKS_MIN_H => normal,
_ => compact(area, has_gpu),
}
}
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(dir)
.constraints(constraints)
.split(area)
}
/// 66/34 split used by every full-width row in the normal layout.
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
split(
area,
Direction::Horizontal,
&[Constraint::Percentage(66), Constraint::Percentage(34)],
)
}
fn normal(area: Rect) -> AppLayout {
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H), // header
Constraint::Ratio(1, 3), // top row
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
],
);
let top = left_right(rows[1]);
let mem_lr = left_right(rows[2]);
let swap_lr = left_right(rows[3]);
// GPU spans the same vertical space as Memory + Swap.
let gpu = Rect {
x: mem_lr[1].x,
y: mem_lr[1].y,
width: mem_lr[1].width,
height: mem_lr[1].height + swap_lr[1].height,
};
let bottom = split(
rows[4],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Min(4), // disks absorbs the slack
Constraint::Length(NET_H), // download
Constraint::Length(NET_H), // upload
],
);
AppLayout {
mode: LayoutMode::Normal,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: Some(gpu),
mem: mem_lr[0],
swap: swap_lr[0],
disks: Some(left_stack[0]),
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
// Give the top row its floor first, then share any surplus with the bottom so the
// process table keeps growing with the window instead of staying pinned at 13 rows.
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
(top, avail - top)
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
(TOP_MIN_H, avail - TOP_MIN_H)
} else {
// Smaller than both floors: the network graphs are already at their minimum, so
// the top row takes what is left (panes clip below this point).
let bottom = BOTTOM_MIN_H.min(avail);
(avail - bottom, bottom)
};
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H),
Constraint::Length(top_h),
Constraint::Length(gpu_h),
Constraint::Length(bottom_h),
],
);
let top = left_right(rows[1]);
let bottom = split(
rows[3],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Length(GAUGE_H),
Constraint::Fill(1),
Constraint::Fill(1),
],
);
let gauges = split(
left_stack[0],
Direction::Horizontal,
&[Constraint::Percentage(50), Constraint::Percentage(50)],
);
AppLayout {
mode: LayoutMode::Compact,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: has_gpu.then_some(rows[2]),
mem: gauges[0],
swap: gauges[1],
disks: None,
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area(w: u16, h: u16) -> Rect {
Rect::new(0, 0, w, h)
}
/// The height where the normal layout still fits a full disk card. Below it the CPU
/// panes are the ones that collapse, which is what compact mode exists to prevent.
#[test]
fn tall_window_stays_normal() {
let l = compute(area(120, 40), false, true);
assert_eq!(l.mode, LayoutMode::Normal);
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
}
#[test]
fn short_window_switches_to_compact() {
let l = compute(area(120, 24), false, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
/// The switch happens exactly when Disks can no longer show one card, and never
/// oscillates: every height above the crossover is normal, every height below is
/// compact.
#[test]
fn mode_is_monotonic_in_height() {
let mut first_normal = None;
for h in 10..=60u16 {
let mode = compute(area(120, h), false, true).mode;
match (mode, first_normal) {
(LayoutMode::Normal, None) => first_normal = Some(h),
(LayoutMode::Compact, Some(prev)) => {
panic!("height {h} went back to compact after normal at {prev}")
}
_ => {}
}
}
assert!(first_normal.is_some(), "never reached the normal layout");
}
#[test]
fn force_compact_overrides_a_tall_window() {
let l = compute(area(200, 80), true, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
#[test]
fn compact_drops_the_gpu_row_without_a_gpu() {
let with = compute(area(120, 24), true, true);
let without = compute(area(120, 24), true, false);
assert!(with.gpu.is_some());
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
assert!(without.gpu.is_none());
// The rows a GPU-less host saves are shared between the CPU panes and the
// bottom half, and none of them are left as a gap.
assert!(without.cpu.height > with.cpu.height);
assert!(without.procs.height > with.procs.height);
assert_eq!(without.procs.y + without.procs.height, 24);
}
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
/// content rows inside their borders.
#[test]
fn compact_keeps_the_cpu_panes_drawable() {
for h in 18..=32u16 {
let l = compute(area(120, h), false, true);
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
assert!(
l.cpu.height >= TOP_MIN_H,
"height {h}: cpu pane only {} rows",
l.cpu.height
);
assert_eq!(l.per_core.height, l.cpu.height);
}
}
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
/// left the top row with no drawable interior at all.
#[test]
fn compact_beats_the_fixed_layout_at_18_rows() {
let compact = compute(area(120, 18), false, true);
let fixed = normal(area(120, 18));
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
assert!(compact.cpu.height > fixed.cpu.height);
}
#[test]
fn compact_panes_tile_the_area_without_gaps() {
for h in 16..=32u16 {
for has_gpu in [true, false] {
let l = compute(area(120, h), true, has_gpu);
assert_eq!(l.header.y, 0);
assert_eq!(l.cpu.y, l.header.y + l.header.height);
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
let after_cpu = l.cpu.y + l.cpu.height;
let bottom_y = match l.gpu {
Some(g) => {
assert_eq!(g.y, after_cpu);
assert_eq!(g.width, 120, "gpu strip spans the full width");
g.y + g.height
}
None => after_cpu,
};
assert_eq!(l.mem.y, bottom_y);
// Memory and Swap sit side by side on one row.
assert_eq!(l.swap.y, l.mem.y);
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
assert_eq!(l.mem.height, GAUGE_H);
assert_eq!(l.download.y, l.mem.y + l.mem.height);
assert_eq!(l.upload.y, l.download.y + l.download.height);
assert_eq!(l.procs.y, bottom_y);
}
}
}
/// A degenerate size must not panic or produce rects outside the frame.
#[test]
fn tiny_windows_stay_inside_the_frame() {
for h in 0..=16u16 {
for w in [0u16, 1, 20, 80] {
let l = compute(area(w, h), false, true);
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
}
}
}
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod cpu;
pub mod disks; pub mod disks;
pub mod gpu; pub mod gpu;
pub mod header; pub mod header;
pub mod layout;
pub mod mem; pub mod mem;
pub mod modal; pub mod modal;
pub mod modal_connection; pub mod modal_connection;
+235
View File
@@ -6,6 +6,7 @@ use ratatui::{
Frame, Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph, Wrap}, widgets::{Block, Borders, Clear, Paragraph, Wrap},
}; };
@@ -22,6 +23,7 @@ pub struct ModalManager {
pub journal_scroll_offset: usize, pub journal_scroll_offset: usize,
pub thread_scroll_max: usize, pub thread_scroll_max: usize,
pub journal_scroll_max: usize, pub journal_scroll_max: usize,
pub help_scroll_offset: usize,
} }
impl ModalManager { impl ModalManager {
@@ -33,6 +35,7 @@ impl ModalManager {
journal_scroll_offset: 0, journal_scroll_offset: 0,
thread_scroll_max: 0, thread_scroll_max: 0,
journal_scroll_max: 0, journal_scroll_max: 0,
help_scroll_offset: 0,
} }
} }
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
@@ -55,6 +58,12 @@ impl ModalManager {
self.journal_scroll_max = 0; self.journal_scroll_max = 0;
ModalButton::Ok ModalButton::Ok
} }
Some(ModalType::About) => ModalButton::Ok,
Some(ModalType::Help) => {
// Reset scroll state for help modal
self.help_scroll_offset = 0;
ModalButton::Ok
}
Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Confirmation { .. }) => ModalButton::Confirm,
Some(ModalType::Info { .. }) => ModalButton::Ok, Some(ModalType::Info { .. }) => ModalButton::Ok,
None => ModalButton::Ok, None => ModalButton::Ok,
@@ -66,6 +75,8 @@ impl ModalManager {
self.active_button = match next { self.active_button = match next {
ModalType::ConnectionError { .. } => ModalButton::Retry, ModalType::ConnectionError { .. } => ModalButton::Retry,
ModalType::ProcessDetails { .. } => ModalButton::Ok, ModalType::ProcessDetails { .. } => ModalButton::Ok,
ModalType::About => ModalButton::Ok,
ModalType::Help => ModalButton::Ok,
ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Confirmation { .. } => ModalButton::Confirm,
ModalType::Info { .. } => ModalButton::Ok, ModalType::Info { .. } => ModalButton::Ok,
}; };
@@ -192,6 +203,22 @@ impl ModalManager {
ModalAction::None ModalAction::None
} }
} }
KeyCode::Up => {
if matches!(self.stack.last(), Some(ModalType::Help)) {
self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
ModalAction::Handled
} else {
ModalAction::None
}
}
KeyCode::Down => {
if matches!(self.stack.last(), Some(ModalType::Help)) {
self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
ModalAction::Handled
} else {
ModalAction::None
}
}
_ => ModalAction::None, _ => ModalAction::None,
} }
} }
@@ -205,6 +232,14 @@ impl ModalManager {
self.pop_modal(); self.pop_modal();
ModalAction::Dismiss ModalAction::Dismiss
} }
(Some(ModalType::About), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
}
(Some(ModalType::Help), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
}
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
(Some(ModalType::Info { .. }), ModalButton::Ok) => { (Some(ModalType::Info { .. }), ModalButton::Ok) => {
@@ -253,6 +288,14 @@ impl ModalManager {
// Process details modal uses almost full screen (95% width, 90% height) // Process details modal uses almost full screen (95% width, 90% height)
self.centered_rect(95, 90, area) self.centered_rect(95, 90, area)
} }
ModalType::About => {
// About modal uses medium size
self.centered_rect(90, 90, area)
}
ModalType::Help => {
// Help modal uses medium size
self.centered_rect(70, 80, area)
}
_ => { _ => {
// Other modals use smaller size // Other modals use smaller size
self.centered_rect(70, 50, area) self.centered_rect(70, 50, area)
@@ -276,6 +319,8 @@ impl ModalManager {
ModalType::ProcessDetails { pid } => { ModalType::ProcessDetails { pid } => {
self.render_process_details(f, modal_area, *pid, data) self.render_process_details(f, modal_area, *pid, data)
} }
ModalType::About => self.render_about(f, modal_area),
ModalType::Help => self.render_help(f, modal_area),
ModalType::Confirmation { ModalType::Confirmation {
title, title,
message, message,
@@ -378,6 +423,196 @@ impl ModalManager {
); );
} }
fn render_about(&self, f: &mut Frame, area: Rect) {
//get ASCII art from a constant stored in theme.rs
use super::theme::ASCII_ART;
let version = env!("CARGO_PKG_VERSION");
let about_text = format!(
"{}\n\
Version {}\n\
\n\
A terminal first remote monitoring tool\n\
\n\
Website: https://socktop.io\n\
GitHub: https://github.com/jasonwitty/socktop\n\
\n\
License: MIT License\n\
\n\
Created by Jason Witty\n\
jasonpwitty+socktop@proton.me",
ASCII_ART, version
);
// Render the border block
let block = Block::default()
.title(" About socktop ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
f.render_widget(block, area);
// Calculate inner area manually to avoid any parent styling
let inner_area = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2), // Leave room for button at bottom
};
// Render content area with explicit black background
f.render_widget(
Paragraph::new(about_text)
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
inner_area,
);
// Button area
let button_area = Rect {
x: area.x + 1,
y: area.y + area.height.saturating_sub(2),
width: area.width.saturating_sub(2),
height: 1,
};
let ok_style = if self.active_button == ModalButton::Ok {
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue).bg(Color::Black)
};
f.render_widget(
Paragraph::new("[ Enter ] Close")
.style(ok_style)
.alignment(Alignment::Center),
button_area,
);
}
fn render_help(&self, f: &mut Frame, area: Rect) {
let help_lines = vec![
"GLOBAL",
" q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help",
"",
"PROCESS LIST",
" / .............. Start/edit fuzzy search",
" c/C ............ Clear search filter",
" ↑/↓ ............ Select/navigate processes",
" Enter .......... Open Process Details",
" x/X ............ Clear selection",
" Click header ... Sort by column (CPU/Mem)",
" Click row ...... Select process",
"",
"SEARCH MODE (after pressing /)",
" Type ........... Enter search query (fuzzy match)",
" ↑/↓ ............ Navigate results while typing",
" Esc ............ Cancel search and clear filter",
" Enter .......... Apply filter and select first result",
"",
"CPU PER-CORE",
" ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down",
" Home/End ....... Jump to first/last core",
"",
"PROCESS DETAILS MODAL",
" x/X ............ Close modal (all parent modals)",
" p/P ............ Navigate to parent process",
" j/k ............ Scroll threads ↓/↑ (1 line)",
" d/u ............ Scroll threads ↓/↑ (10 lines)",
" [ / ] .......... Scroll journal ↑/↓",
" Esc/Enter ...... Close modal",
"",
"MODAL NAVIGATION",
" Tab/→ .......... Next button │ Shift+Tab/← ... Previous button",
" Enter .......... Confirm/OK │ Esc ............ Cancel/Close",
];
// Render the border block
let block = Block::default()
.title(" Hotkey Help (use ↑/↓ to scroll) ")
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
f.render_widget(block, area);
// Split into content area and button area
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
});
let content_area = chunks[0];
let button_area = chunks[1];
// Calculate visible window
let visible_height = content_area.height as usize;
let total_lines = help_lines.len();
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll_offset = self.help_scroll_offset.min(max_scroll);
// Get visible lines
let visible_lines: Vec<Line> = help_lines
.iter()
.skip(scroll_offset)
.take(visible_height)
.map(|s| Line::from(*s))
.collect();
// Render scrollable content
f.render_widget(
Paragraph::new(visible_lines)
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
.alignment(Alignment::Left),
content_area,
);
// Render scrollbar if needed
if total_lines > visible_height {
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
let scrollbar_area = Rect {
x: area.x + area.width.saturating_sub(2),
y: area.y + 1,
width: 1,
height: area.height.saturating_sub(2),
};
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""))
.style(Style::default().fg(Color::DarkGray));
f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
}
// Button area
let ok_style = if self.active_button == ModalButton::Ok {
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue).bg(Color::Black)
};
f.render_widget(
Paragraph::new("[ Enter ] Close")
.style(ok_style)
.alignment(Alignment::Center),
button_area,
);
}
fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let vert = Layout::default() let vert = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
+6 -5
View File
@@ -41,11 +41,12 @@ impl ModalManager {
]) ])
.split(area); .split(area);
let block = Block::default() let block = Block::default()
.title(ICON_WARNING_TITLE) .title(
.title_style( Line::from(ICON_WARNING_TITLE).style(
Style::default() Style::default()
.fg(MODAL_TITLE_FG) .fg(MODAL_TITLE_FG)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
),
) )
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER_FG)) .border_style(Style::default().fg(MODAL_BORDER_FG))
+98 -60
View File
@@ -16,6 +16,15 @@ use super::modal_format::{calculate_dynamic_y_max, format_uptime, normalize_cpu_
use super::modal_types::{ProcessModalData, ScatterPlotParams}; use super::modal_types::{ProcessModalData, ScatterPlotParams};
use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT}; use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT};
/// Parameters for rendering memory and I/O graphs
struct MemoryIoParams<'a> {
process: &'a socktop_connector::DetailedProcessInfo,
mem_history: &'a std::collections::VecDeque<u64>,
io_read_history: &'a std::collections::VecDeque<u64>,
io_write_history: &'a std::collections::VecDeque<u64>,
max_mem_bytes: u64,
}
impl ModalManager { impl ModalManager {
pub(super) fn render_process_details( pub(super) fn render_process_details(
&mut self, &mut self,
@@ -51,16 +60,20 @@ impl ModalManager {
main_chunks[0], main_chunks[0],
&details.process, &details.process,
data.history.cpu, data.history.cpu,
data.history.cpu_sum,
); );
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
self.render_middle_row_with_metadata( self.render_middle_row_with_metadata(
f, f,
main_chunks[1], main_chunks[1],
&details.process, MemoryIoParams {
data.history.mem, process: &details.process,
data.history.io_read, mem_history: data.history.mem,
data.history.io_write, io_read_history: data.history.io_read,
io_write_history: data.history.io_write,
max_mem_bytes: data.max_mem_bytes,
},
); );
// Bottom Row: Journal Events // Bottom Row: Journal Events
@@ -169,22 +182,14 @@ impl ModalManager {
f.render_widget(plot_block, area); f.render_widget(plot_block, area);
} }
fn render_memory_io_graphs( fn render_memory_io_graphs(&self, f: &mut Frame, area: Rect, params: MemoryIoParams) {
&self,
f: &mut Frame,
area: Rect,
process: &socktop_connector::DetailedProcessInfo,
mem_history: &std::collections::VecDeque<u64>,
io_read_history: &std::collections::VecDeque<u64>,
io_write_history: &std::collections::VecDeque<u64>,
) {
let graphs_block = Block::default() let graphs_block = Block::default()
.title("Memory & I/O") .title("Memory & I/O")
.borders(Borders::ALL) .borders(Borders::ALL)
.padding(Padding::horizontal(1)); .padding(Padding::horizontal(1));
let mem_mb = process.mem_bytes as f64 / 1_048_576.0; let mem_mb = params.process.mem_bytes as f64 / 1_048_576.0;
let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; let virtual_mb = params.process.virtual_mem_bytes as f64 / 1_048_576.0;
let mut content_lines = vec![ let mut content_lines = vec![
Line::from(vec![ Line::from(vec![
@@ -198,8 +203,12 @@ impl ModalManager {
]; ];
// Add memory sparkline if we have history // Add memory sparkline if we have history
if mem_history.len() >= 2 { if params.mem_history.len() >= 2 {
let mem_data: Vec<u64> = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB let mem_data: Vec<u64> = params
.mem_history
.iter()
.map(|&bytes| bytes / 1_048_576)
.collect(); // Convert to MB
let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1);
// Create mini sparkline using Unicode blocks // Create mini sparkline using Unicode blocks
@@ -228,8 +237,23 @@ impl ModalManager {
Span::raw(format!("{virtual_mb:.1} MB")), Span::raw(format!("{virtual_mb:.1} MB")),
])); ]));
// Add max memory if we have tracked it
if params.max_mem_bytes > 0 {
let max_mb = params.max_mem_bytes as f64 / 1_048_576.0;
content_lines.push(Line::from(vec![
Span::styled(
" Max Memory: ",
Style::default().add_modifier(Modifier::DIM),
),
Span::styled(
format!("{max_mb:.1} MB"),
Style::default().fg(Color::Yellow),
),
]));
}
// Add shared memory if available // Add shared memory if available
if let Some(shared_bytes) = process.shared_mem_bytes { if let Some(shared_bytes) = params.process.shared_mem_bytes {
let shared_mb = shared_bytes as f64 / 1_048_576.0; let shared_mb = shared_bytes as f64 / 1_048_576.0;
content_lines.push(Line::from(vec![ content_lines.push(Line::from(vec![
Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)),
@@ -244,7 +268,7 @@ impl ModalManager {
])); ]));
// Add I/O stats if available // Add I/O stats if available
match (process.read_bytes, process.write_bytes) { match (params.process.read_bytes, params.process.write_bytes) {
(Some(read), Some(write)) => { (Some(read), Some(write)) => {
let read_mb = read as f64 / 1_048_576.0; let read_mb = read as f64 / 1_048_576.0;
let write_mb = write as f64 / 1_048_576.0; let write_mb = write as f64 / 1_048_576.0;
@@ -254,8 +278,9 @@ impl ModalManager {
])); ]));
// Add read I/O sparkline if we have history // Add read I/O sparkline if we have history
if io_read_history.len() >= 2 { if params.io_read_history.len() >= 2 {
let read_data: Vec<u64> = io_read_history let read_data: Vec<u64> = params
.io_read_history
.iter() .iter()
.map(|&bytes| bytes / 1_048_576) .map(|&bytes| bytes / 1_048_576)
.collect(); // Convert to MB .collect(); // Convert to MB
@@ -282,8 +307,9 @@ impl ModalManager {
])); ]));
// Add write I/O sparkline if we have history // Add write I/O sparkline if we have history
if io_write_history.len() >= 2 { if params.io_write_history.len() >= 2 {
let write_data: Vec<u64> = io_write_history let write_data: Vec<u64> = params
.io_write_history
.iter() .iter()
.map(|&bytes| bytes / 1_048_576) .map(|&bytes| bytes / 1_048_576)
.collect(); // Convert to MB .collect(); // Convert to MB
@@ -394,7 +420,7 @@ impl ModalManager {
) )
.header(header) .header(header)
.block(block) .block(block)
.highlight_style(Style::default()); .row_highlight_style(Style::default());
f.render_widget(table, area); f.render_widget(table, area);
@@ -539,8 +565,13 @@ impl ModalManager {
return; return;
} }
// Create a 2D grid to represent the plot // Flat plot grid indexed as grid[y * plot_width + x]. One allocation
let mut plot_grid = vec![vec![' '; plot_width]; plot_height]; // instead of `plot_height` inner Vec<char>s like the old version did.
let mut plot_grid: Vec<char> = vec![' '; plot_width * plot_height];
let cell = |grid: &[char], x: usize, y: usize| grid[y * plot_width + x];
let put = |grid: &mut [char], x: usize, y: usize, ch: char| {
grid[y * plot_width + x] = ch;
};
// Plot main process // Plot main process
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
@@ -548,7 +579,7 @@ impl ModalManager {
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
); );
if main_x < plot_width && main_y < plot_height { if main_x < plot_width && main_y < plot_height {
plot_grid[main_y][main_x] = '●'; // Main process marker put(&mut plot_grid, main_x, main_y, '●');
} }
// Plot threads (use different marker) // Plot threads (use different marker)
@@ -562,13 +593,13 @@ impl ModalManager {
); );
if thread_x < plot_width && thread_y < plot_height { if thread_x < plot_width && thread_y < plot_height {
if plot_grid[thread_y][thread_x] == ' ' { let ch = cell(&plot_grid, thread_x, thread_y);
plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle) let next = match ch {
} else if plot_grid[thread_y][thread_x] == '○' { ' ' => '○',
plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point '○' => '◎',
} else { _ => '◉',
plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point };
} put(&mut plot_grid, thread_x, thread_y, next);
} }
} }
@@ -583,28 +614,34 @@ impl ModalManager {
); );
if child_x < plot_width && child_y < plot_height { if child_x < plot_width && child_y < plot_height {
if plot_grid[child_y][child_x] == ' ' { let ch = cell(&plot_grid, child_x, child_y);
plot_grid[child_y][child_x] = '•'; // Child process marker let next = if ch == ' ' { '•' } else { '◉' };
} else { put(&mut plot_grid, child_x, child_y, next);
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
}
} }
} }
// Render the plot // Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
let mut lines = Vec::new(); // labels + axis title + (top) Y-axis title + legend + spacing.
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
// Add Y-axis labels and plot content // Y-axis labels and plot content
for (i, row) in plot_grid.iter().enumerate() { let mut row_buf = String::with_capacity(plot_width);
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64)); for y in 0..plot_height {
// Always format with 4 characters width, right-aligned, to prevent axis shifting let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64));
// 4-char fixed-width label so the axis doesn't shift as digits change.
let y_label = if y_value >= 100.0 { let y_label = if y_value >= 100.0 {
format!("{y_value:>4.0}") format!("{y_value:>4.0}")
} else { } else {
format!("{y_value:>4.1}") format!("{y_value:>4.1}")
}; };
let plot_content: String = row.iter().collect(); // Build the row's char slice into a reusable String buffer.
row_buf.clear();
let start = y * plot_width;
row_buf.extend(plot_grid[start..start + plot_width].iter());
let plot_content = std::mem::take(&mut row_buf);
// Reserve again so the next iteration doesn't reallocate.
row_buf.reserve(plot_width);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(y_label, Style::default()), Span::styled(y_label, Style::default()),
@@ -808,6 +845,7 @@ impl ModalManager {
area: Rect, area: Rect,
process: &socktop_connector::DetailedProcessInfo, process: &socktop_connector::DetailedProcessInfo,
cpu_history: &std::collections::VecDeque<f32>, cpu_history: &std::collections::VecDeque<f32>,
cpu_history_sum: f32,
) { ) {
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
let top_chunks = Layout::default() let top_chunks = Layout::default()
@@ -818,7 +856,7 @@ impl ModalManager {
]) ])
.split(area); .split(area);
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history); self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum);
self.render_thread_scatter_plot(f, top_chunks[1], process); self.render_thread_scatter_plot(f, top_chunks[1], process);
} }
@@ -828,6 +866,7 @@ impl ModalManager {
area: Rect, area: Rect,
process: &socktop_connector::DetailedProcessInfo, process: &socktop_connector::DetailedProcessInfo,
cpu_history: &std::collections::VecDeque<f32>, cpu_history: &std::collections::VecDeque<f32>,
cpu_history_sum: f32,
) { ) {
// Normalize CPU to 0-100% by dividing by thread count // Normalize CPU to 0-100% by dividing by thread count
// This shows per-core utilization rather than total utilization across all cores // This shows per-core utilization rather than total utilization across all cores
@@ -839,10 +878,9 @@ impl ModalManager {
let avg_cpu = if cpu_history.is_empty() { let avg_cpu = if cpu_history.is_empty() {
0.0 0.0
} else { } else {
let total: f32 = cpu_history.iter().sum(); normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count)
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
}; };
let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
// Similar to main CPU rendering but for process CPU // Similar to main CPU rendering but for process CPU
if cpu_history.len() < 2 { if cpu_history.len() < 2 {
@@ -913,10 +951,7 @@ impl ModalManager {
&mut self, &mut self,
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
process: &socktop_connector::DetailedProcessInfo, params: MemoryIoParams,
mem_history: &std::collections::VecDeque<u64>,
io_read_history: &std::collections::VecDeque<u64>,
io_write_history: &std::collections::VecDeque<u64>,
) { ) {
// Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%)
let middle_chunks = Layout::default() let middle_chunks = Layout::default()
@@ -931,13 +966,16 @@ impl ModalManager {
self.render_memory_io_graphs( self.render_memory_io_graphs(
f, f,
middle_chunks[0], middle_chunks[0],
process, MemoryIoParams {
mem_history, process: params.process,
io_read_history, mem_history: params.mem_history,
io_write_history, io_read_history: params.io_read_history,
io_write_history: params.io_write_history,
max_mem_bytes: params.max_mem_bytes,
},
); );
self.render_thread_table(f, middle_chunks[1], process); self.render_thread_table(f, middle_chunks[1], params.process);
self.render_command_and_metadata(f, middle_chunks[2], process); self.render_command_and_metadata(f, middle_chunks[2], params.process);
} }
fn render_command_and_metadata( fn render_command_and_metadata(
+5
View File
@@ -5,6 +5,8 @@ use std::time::Instant;
/// History data for process metrics rendering /// History data for process metrics rendering
pub struct ProcessHistoryData<'a> { pub struct ProcessHistoryData<'a> {
pub cpu: &'a std::collections::VecDeque<f32>, pub cpu: &'a std::collections::VecDeque<f32>,
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
pub cpu_sum: f32,
pub mem: &'a std::collections::VecDeque<u64>, pub mem: &'a std::collections::VecDeque<u64>,
pub io_read: &'a std::collections::VecDeque<u64>, pub io_read: &'a std::collections::VecDeque<u64>,
pub io_write: &'a std::collections::VecDeque<u64>, pub io_write: &'a std::collections::VecDeque<u64>,
@@ -15,6 +17,7 @@ pub struct ProcessModalData<'a> {
pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, pub details: Option<&'a socktop_connector::ProcessMetricsResponse>,
pub journal: Option<&'a socktop_connector::JournalResponse>, pub journal: Option<&'a socktop_connector::JournalResponse>,
pub history: ProcessHistoryData<'a>, pub history: ProcessHistoryData<'a>,
pub max_mem_bytes: u64,
pub unsupported: bool, pub unsupported: bool,
} }
@@ -38,6 +41,8 @@ pub enum ModalType {
ProcessDetails { ProcessDetails {
pid: u32, pid: u32,
}, },
About,
Help,
#[allow(dead_code)] #[allow(dead_code)]
Confirmation { Confirmation {
title: String, title: String,
+3 -3
View File
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
title: &str, title: &str,
hist: &VecDeque<u64>, hist: &mut VecDeque<u64>,
color: Color, color: Color,
) { ) {
let max_points = area.width.saturating_sub(2) as usize; let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points); let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect(); let slice = &hist.make_contiguous()[start..];
let spark = Sparkline::default() let spark = Sparkline::default()
.block( .block(
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
.borders(Borders::ALL) .borders(Borders::ALL)
.title(title.to_string()), .title(title.to_string()),
) )
.data(&data) .data(slice)
.style(Style::default().fg(color)); .style(Style::default().fg(color));
f.render_widget(spark, area); f.render_widget(spark, area);
} }
+325 -123
View File
@@ -5,8 +5,8 @@ use ratatui::style::Modifier;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style}, style::{Color, Style},
text::{Line, Span}, text::Span,
widgets::{Block, Borders, Paragraph, Table}, widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
}; };
use std::cmp::Ordering; use std::cmp::Ordering;
@@ -16,7 +16,77 @@ use crate::ui::theme::{
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW, PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
SB_THUMB, SB_TRACK, SB_THUMB, SB_TRACK,
}; };
use crate::ui::util::human;
/// Simple fuzzy matching: returns true if all characters in needle appear in
/// haystack in order, ASCII-case-insensitive. Lowercase normalization is done
/// on the fly so we don't allocate two `String`s per haystack like the old
/// version did (this runs once per process per frame).
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
if !haystack_chars.any(|c| c == needle_char) {
return false;
}
}
true
}
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first
/// and reused across calls so callers can amortize the allocation. This is
/// the underlying helper for the App-side cached slice.
pub fn fill_filtered_sorted_indices(
metrics: &Metrics,
search_query: &str,
sort_by: ProcSortBy,
out: &mut Vec<usize>,
) {
out.clear();
out.reserve(metrics.top_processes.len());
if search_query.is_empty() {
out.extend(0..metrics.top_processes.len());
} else {
out.extend(
(0..metrics.top_processes.len())
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
);
}
match sort_by {
ProcSortBy::CpuDesc => out.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].cpu_usage;
let bb = metrics.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => out.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].mem_bytes;
let bb = metrics.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
}
/// Parameters for drawing the top processes table
pub struct ProcessDisplayParams<'a> {
pub metrics: Option<&'a Metrics>,
pub scroll_offset: usize,
pub sort_by: ProcSortBy,
pub selected_process_pid: Option<u32>,
pub selected_process_index: Option<usize>,
pub search_query: &'a str,
pub search_active: bool,
/// Precomputed filtered + sorted indices into `metrics.top_processes`.
/// Maintained on the App side so the draw path never recomputes the list.
pub filtered_indices: &'a [usize],
/// Pre-formatted strings for each row of `metrics.top_processes`.
/// Indexed the same as `metrics.top_processes`. Empty when no procs poll
/// has run yet (the draw path falls back to fast inline formatting).
pub cached_rows: &'a [CachedRow],
/// Peak cpu_usage from the most recent cache build; used to bold the
/// busiest process. -1.0 if no cache.
pub peak_cpu: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProcSortBy { pub enum ProcSortBy {
@@ -25,6 +95,45 @@ pub enum ProcSortBy {
MemDesc, MemDesc,
} }
/// Pre-formatted strings for one row of the process table. Built once per
/// `Processes` poll (cadence ~2s) and reused by every draw frame in between
/// so the diff renderer can suppress repaints when nothing changed.
#[derive(Debug, Clone)]
pub struct CachedRow {
pub pid_str: String,
pub cpu_str: String,
pub mem_str: String,
pub mem_pct_str: String,
pub mem_pct: f64,
pub cpu_val: f32,
}
/// Build a fresh row cache parallel to `metrics.top_processes`. Reuses `out`'s
/// allocation when possible. Also returns the peak cpu_usage observed, which
/// the draw path uses to bold the busiest process.
pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
out.clear();
out.reserve(metrics.top_processes.len());
let total = metrics.mem_total.max(1);
let mut peak = 0.0_f32;
for p in &metrics.top_processes {
let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0;
let cpu_val = p.cpu_usage;
if cpu_val > peak {
peak = cpu_val;
}
out.push(CachedRow {
pid_str: p.pid.to_string(),
cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)),
mem_str: crate::ui::util::human(p.mem_bytes),
mem_pct_str: format!("{mem_pct:.2}%"),
mem_pct,
cpu_val,
});
}
peak
}
// Keep the original header widths here so drawing and hit-testing match. // Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [ const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID Constraint::Length(8), // PID
@@ -34,30 +143,61 @@ const COLS: [Constraint; 5] = [
Constraint::Length(8), // Mem % Constraint::Length(8), // Mem %
]; ];
pub fn draw_top_processes( pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
scroll_offset: usize,
sort_by: ProcSortBy,
selected_process_pid: Option<u32>,
selected_process_index: Option<usize>,
) {
// Draw outer block and title // Draw outer block and title
let Some(mm) = m else { return }; let Some(mm) = params.metrics else { return };
let total = mm.process_count.unwrap_or(mm.top_processes.len()); let total = mm.process_count.unwrap_or(mm.top_processes.len());
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(format!("Top Processes ({total} total)")); .title(format!("Top Processes ({total} total)"));
f.render_widget(block, area); f.render_widget(block, area);
// Inner area and content area (reserve 2 columns for scrollbar) // Inner area (reserve space for search box if active)
let inner = Rect { let inner = Rect {
x: area.x + 1, x: area.x + 1,
y: area.y + 1, y: area.y + 1,
width: area.width.saturating_sub(2), width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2), height: area.height.saturating_sub(2),
}; };
// Draw search box if active
let content_start_y = if params.search_active || !params.search_query.is_empty() {
let search_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 3, // Height for border + content
};
let search_text = if params.search_active {
format!("Search: {}_", params.search_query)
} else {
format!(
"Filter: {} (press / to edit, c to clear)",
params.search_query
)
};
let search_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let search_paragraph = Paragraph::new(search_text)
.block(search_block)
.style(Style::default().fg(Color::Yellow));
f.render_widget(search_paragraph, search_area);
inner.y + 3
} else {
inner.y
};
// Content area (reserve 2 columns for scrollbar)
let inner = Rect {
x: inner.x,
y: content_start_y,
width: inner.width,
height: inner.height.saturating_sub(content_start_y - (area.y + 1)),
};
if inner.height < 1 || inner.width < 3 { if inner.height < 1 || inner.width < 3 {
return; return;
} }
@@ -68,42 +208,70 @@ pub fn draw_top_processes(
height: inner.height, height: inner.height,
}; };
// Sort rows (by CPU% or Mem bytes), descending. let idxs = params.filtered_indices;
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 // Scrolling
let total_rows = idxs.len(); let total_rows = idxs.len();
let header_rows = 1usize; let header_rows = 1usize;
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize; let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
let max_off = total_rows.saturating_sub(viewport_rows); let max_off = total_rows.saturating_sub(viewport_rows);
let offset = scroll_offset.min(max_off); let offset = params.scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows); let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Build visible rows // Use the App-side cache when available so we avoid allocating ~5 strings
// per row every frame. Falls back to inline formatting (slow path) when
// the cache hasn't been built yet — e.g. the very first frame before the
// initial procs poll completes.
let cache_ok = params.cached_rows.len() == mm.top_processes.len();
let total_mem_bytes = mm.mem_total.max(1); let total_mem_bytes = mm.mem_total.max(1);
let peak_cpu = mm let peak_cpu = if cache_ok {
.top_processes params.peak_cpu
.iter() } else {
.map(|p| p.cpu_usage) mm.top_processes
.fold(0.0_f32, f32::max); .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 rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix]; let p = &mm.top_processes[ix];
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let cpu_val = p.cpu_usage; let (
cpu_val,
mem_pct,
pid_span,
name_span,
cpu_span_text,
mem_span_text,
mem_pct_span_text,
) = if cache_ok {
let row = &params.cached_rows[ix];
(
row.cpu_val,
row.mem_pct,
Span::raw(row.pid_str.as_str()),
Span::raw(p.name.as_str()),
row.cpu_str.as_str(),
row.mem_str.as_str(),
row.mem_pct_str.as_str(),
)
} else {
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
// SLOW path: only the very first frame before the cache exists.
// We leak the formatted strings via Box::leak'd statics? No —
// simpler: emit empty placeholders. Cache will exist within
// ~500ms and the diff renderer fills it in.
(
p.cpu_usage,
mem_pct,
Span::raw(""),
Span::raw(""),
"",
"",
"",
)
};
let cpu_fg = match cpu_val { let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green, x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow, x if x < 60.0 => Color::Yellow,
@@ -121,16 +289,14 @@ pub fn draw_top_processes(
Style::default() Style::default()
}; };
// Check if this process is selected - prioritize PID matching let is_selected = if let Some(selected_pid) = params.selected_process_pid {
let is_selected = if let Some(selected_pid) = selected_process_pid {
selected_pid == p.pid selected_pid == p.pid
} else if let Some(selected_idx) = selected_process_index { } else if let Some(selected_idx) = params.selected_process_index {
selected_idx == ix // ix is the absolute index in the sorted list selected_idx == ix
} else { } else {
false false
}; };
// Apply selection highlighting
if is_selected { if is_selected {
emphasis = emphasis emphasis = emphasis
.bg(PROCESS_SELECTION_BG) .bg(PROCESS_SELECTION_BG)
@@ -138,26 +304,24 @@ pub fn draw_top_processes(
.add_modifier(Modifier::BOLD); .add_modifier(Modifier::BOLD);
} }
let cpu_str = fmt_cpu_pct(cpu_val);
ratatui::widgets::Row::new(vec![ ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(p.pid.to_string()) ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
.style(Style::default().fg(Color::DarkGray)), ratatui::widgets::Cell::from(name_span),
ratatui::widgets::Cell::from(p.name.clone()), ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)), .style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(human(p.mem_bytes)), ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%")) ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)), .style(Style::default().fg(mem_fg)),
]) ])
.style(emphasis) .style(emphasis)
}); });
// Header with sort indicator // Header with sort indicator
let cpu_hdr = match sort_by { let cpu_hdr = match params.sort_by {
ProcSortBy::CpuDesc => "CPU % •", ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %", _ => "CPU %",
}; };
let mem_hdr = match sort_by { let mem_hdr = match params.sort_by {
ProcSortBy::MemDesc => "Mem •", ProcSortBy::MemDesc => "Mem •",
_ => "Mem", _ => "Mem",
}; };
@@ -174,9 +338,9 @@ pub fn draw_top_processes(
f.render_widget(table, content); f.render_widget(table, content);
// Draw tooltip if a process is selected // Draw tooltip if a process is selected
if let Some(selected_pid) = selected_process_pid { if let Some(selected_pid) = params.selected_process_pid {
// Find the selected process to get its name // Find the selected process to get its name
let process_info = if let Some(metrics) = m { let process_info = if let Some(metrics) = params.metrics {
metrics metrics
.top_processes .top_processes
.iter() .iter()
@@ -214,46 +378,39 @@ pub fn draw_top_processes(
} }
} }
// Draw scrollbar like CPU pane // Scrollbar (ratatui built-in). Skip drawing when content fits in viewport.
let scroll_area = Rect { let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1), x: inner.x + inner.width.saturating_sub(1),
y: inner.y, y: inner.y,
width: 1, width: 1,
height: inner.height, height: inner.height,
}; };
if scroll_area.height >= 3 { let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
let track = (scroll_area.height - 2) as usize; if scroll_area.height >= 3 && max_off_for_bar > 0 {
let total = total_rows.max(1); let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
let view = viewport_rows.clamp(1, total); .begin_symbol(Some(""))
let max_off = total.saturating_sub(view); .end_symbol(Some(""))
.thumb_symbol("")
let thumb_len = (track * view).div_ceil(total).max(1).min(track); .track_symbol(Some(""))
let thumb_top = if max_off == 0 { .thumb_style(Style::default().fg(SB_THUMB))
0 .track_style(Style::default().fg(SB_TRACK))
} else { .begin_style(Style::default().fg(SB_ARROW))
((track - thumb_len) * offset + max_off / 2) / max_off .end_style(Style::default().fg(SB_ARROW));
}; let mut state = ScrollbarState::new(max_off_for_bar).position(offset);
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
// 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 { /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
format!("{:>5.1}", v.clamp(0.0, 100.0)) /// Parameters for process key event handling
pub struct ProcessKeyParams<'a> {
pub selected_process_pid: &'a mut Option<u32>,
pub selected_process_index: &'a mut Option<usize>,
pub key: crossterm::event::KeyEvent,
pub metrics: Option<&'a Metrics>,
pub filtered_indices: &'a [usize],
} }
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
#[allow(dead_code)] #[allow(dead_code)]
pub fn processes_handle_key( pub fn processes_handle_key(
@@ -264,32 +421,77 @@ pub fn processes_handle_key(
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
} }
/// Enhanced keyboard handler that also manages process selection pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
pub fn processes_handle_key_with_selection(
_scroll_offset: &mut usize,
selected_process_pid: &mut Option<u32>,
selected_process_index: &mut Option<usize>,
key: crossterm::event::KeyEvent,
_page_size: usize,
_total_rows: usize,
_metrics: Option<&Metrics>,
) -> bool {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
match key.code { let move_selection = |delta: isize,
KeyCode::Char('x') | KeyCode::Char('X') => { sel_idx: &mut Option<usize>,
// Unselect any selected process sel_pid: &mut Option<u32>,
if selected_process_pid.is_some() || selected_process_index.is_some() { metrics: Option<&Metrics>,
*selected_process_pid = None; idxs: &[usize]| {
*selected_process_index = None; let Some(m) = metrics else { return };
true // Handled if idxs.is_empty() {
} else { *sel_idx = None;
false // No selection to clear *sel_pid = None;
return;
}
if sel_idx.is_none() || sel_pid.is_none() {
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
return;
}
let current_idx = sel_idx.unwrap();
match idxs.iter().position(|&idx| idx == current_idx) {
Some(pos) => {
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
if new_pos != pos {
let new_idx = idxs[new_pos];
*sel_idx = Some(new_idx);
*sel_pid = Some(m.top_processes[new_idx].pid);
}
}
None => {
// Current selection no longer in filtered list
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
} }
} }
};
match params.key.code {
KeyCode::Up => {
move_selection(
-1,
params.selected_process_index,
params.selected_process_pid,
params.metrics,
params.filtered_indices,
);
true
}
KeyCode::Down => {
move_selection(
1,
params.selected_process_index,
params.selected_process_pid,
params.metrics,
params.filtered_indices,
);
true
}
KeyCode::Char('x') | KeyCode::Char('X')
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
{
*params.selected_process_pid = None;
*params.selected_process_index = None;
true
}
KeyCode::Char('x') | KeyCode::Char('X') => false,
KeyCode::Enter => { KeyCode::Enter => {
// Signal that Enter was pressed with a selection // Signal that Enter was pressed with a selection
selected_process_pid.is_some() // Return true if we have a selection to handle params.selected_process_pid.is_some() // Return true if we have a selection to handle
} }
_ => { _ => {
// No other keys handled - let scrollbar handle all navigation // No other keys handled - let scrollbar handle all navigation
@@ -376,7 +578,11 @@ pub struct ProcessMouseParams<'a> {
pub area: Rect, pub area: Rect,
pub total_rows: usize, pub total_rows: usize,
pub metrics: Option<&'a Metrics>, pub metrics: Option<&'a Metrics>,
pub sort_by: ProcSortBy, /// True when the on-screen search box is currently being drawn (active
/// edit mode OR a non-empty filter is showing). The caller computes this
/// from the same condition as the draw path.
pub search_box_visible: bool,
pub filtered_indices: &'a [usize],
} }
/// Enhanced mouse handler that also manages process selection /// Enhanced mouse handler that also manages process selection
@@ -392,11 +598,23 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
if inner.height == 0 || inner.width <= 2 { if inner.height == 0 || inner.width <= 2 {
return None; return None;
} }
// Calculate content area - must match draw_top_processes exactly!
// If a search box is being drawn (active edit mode OR a filter showing),
// content starts 3 rows below.
let content_start_y = if params.search_box_visible {
inner.y + 3
} else {
inner.y
};
let content = Rect { let content = Rect {
x: inner.x, x: inner.x,
y: inner.y, y: content_start_y,
width: inner.width.saturating_sub(2), width: inner.width.saturating_sub(2),
height: inner.height, height: inner
.height
.saturating_sub(if params.search_box_visible { 3 } else { 0 }),
}; };
// Scrollbar interactions (click arrows/page/drag) // Scrollbar interactions (click arrows/page/drag)
@@ -453,24 +671,8 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
{ {
let clicked_row = (params.mouse.row - data_start_row) as usize; let clicked_row = (params.mouse.row - data_start_row) as usize;
// Find the actual process using the same sorting logic as the drawing code
if let Some(m) = params.metrics { if let Some(m) = params.metrics {
// Create the same sorted index array as in draw_top_processes let idxs = params.filtered_indices;
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
match params.sort_by {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
let aa = m.top_processes[a].cpu_usage;
let bb = m.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal)
}),
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
let aa = m.top_processes[a].mem_bytes;
let bb = m.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
// Calculate which process was actually clicked based on sorted order
let visible_process_position = *params.scroll_offset + clicked_row; let visible_process_position = *params.scroll_offset + clicked_row;
if visible_process_position < idxs.len() { if visible_process_position < idxs.len() {
let actual_process_index = idxs[visible_process_position]; let actual_process_index = idxs[visible_process_position];
+27 -1
View File
@@ -49,7 +49,7 @@ pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry "; pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
pub const BTN_EXIT_TEXT: &str = " ❌ Exit "; pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
// Large multi-line warning icon // warning icon
pub const LARGE_ERROR_ICON: &[&str] = &[ pub const LARGE_ERROR_ICON: &[&str] = &[
" /\\ ", " /\\ ",
" / \\ ", " / \\ ",
@@ -60,3 +60,29 @@ pub const LARGE_ERROR_ICON: &[&str] = &[
" / !! \\ ", " / !! \\ ",
" /______________\\ ", " /______________\\ ",
]; ];
//about logo
pub const ASCII_ART: &str = r#"
"#;
+33
View File
@@ -73,3 +73,36 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
); );
assert!(text3.contains("Usage:")); assert!(text3.contains("Usage:"));
} }
#[test]
fn test_compact_flag_documented_and_accepted() {
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--compact", "--help"])
.output()
.expect("run socktop --compact --help");
assert!(
out.status.success(),
"socktop --compact --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("--compact"),
"help text missing --compact\n{text}"
);
// The flag must not be mistaken for the positional URL argument.
let out2 = Command::new(exe)
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop --compact --dry-run");
assert!(
out2.status.success(),
"socktop --compact with a URL was rejected: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
+38 -11
View File
@@ -1,41 +1,68 @@
[package] [package]
name = "socktop_agent" name = "socktop_agent"
version = "1.40.70" version = "1.50.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"] authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket." description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
homepage = "https://github.com/jasonwitty/socktop"
repository = "https://github.com/jasonwitty/socktop"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } # Tokio: Use minimal features instead of "full" to reduce binary size
# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test])
# Excluded: io, fs, process, signal, time (not needed for this workload)
# Savings: ~200-300KB binary size, faster compile times
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] }
axum = { version = "0.7", features = ["ws", "macros"] } axum = { version = "0.7", features = ["ws", "macros"] }
sysinfo = { version = "0.37", features = ["network", "disk", "component"] } sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
futures-util = "0.3.31" futures-util = "0.3.31"
tracing = "0.1" tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now) gfxinfo = { version = "0.1.2", optional = true }
gfxinfo = "0.1.2"
once_cell = "1.19" once_cell = "1.19"
axum-server = { version = "0.6", features = ["tls-rustls"] } axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = "0.23" rustls = { version = "0.23", features = ["aws-lc-rs"] }
rustls-pemfile = "2.1" rustls-pemfile = "2.1"
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build) rcgen = "0.13"
anyhow = "1" anyhow = "1"
hostname = "0.3" hostname = "0.3"
prost = { workspace = true } prost = { workspace = true }
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] } time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
# For executing journalctl commands
tokio-process = "0.2" [features]
default = ["gpu"]
gpu = ["gfxinfo"]
logging = ["tracing", "tracing-subscriber"]
[build-dependencies] [build-dependencies]
prost-build = "0.13" prost-build = "0.13"
tonic-build = { version = "0.12", default-features = false, optional = true } tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3" protoc-bin-vendored = "3"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.0" assert_cmd = "2.0"
tempfile = "3.10" tempfile = "3.10"
tokio-tungstenite = "0.21" tokio-tungstenite = "0.21"
[package.metadata.deb]
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
license-file = ["../LICENSE", "4"]
extended-description = """\
socktop_agent is the daemon component that runs on remote hosts to collect \
and serve system metrics over WebSocket. It gathers CPU, memory, disk, network, \
GPU, and process information that can be monitored remotely by the socktop TUI client."""
depends = "$auto"
section = "admin"
priority = "optional"
assets = [
["target/release/socktop_agent", "usr/bin/", "755"],
["../README.md", "usr/share/doc/socktop_agent/", "644"],
]
maintainer-scripts = "debian/"
systemd-units = { unit-name = "socktop-agent", unit-scripts = ".", enable = false }
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
set -e
# Create socktop user and group if they don't exist
if ! getent group socktop >/dev/null; then
addgroup --system socktop
fi
if ! getent passwd socktop >/dev/null; then
adduser --system --ingroup socktop --home /var/lib/socktop \
--no-create-home --disabled-password --disabled-login \
--gecos "Socktop Agent" socktop
fi
# Create state directory
mkdir -p /var/lib/socktop
chown socktop:socktop /var/lib/socktop
chmod 755 /var/lib/socktop
# Create config directory if it doesn't exist
mkdir -p /etc/socktop
chmod 755 /etc/socktop
#DEBHELPER#
# Print helpful message to the user
cat <<EOF
┌─────────────────────────────────────────────────────────────────────┐
│ socktop-agent has been installed successfully! │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ The systemd service has been installed but is NOT enabled by │
│ default. To enable and start the service: │
│ │
│ sudo systemctl enable --now socktop-agent │
│ │
│ To start without enabling on boot: │
│ │
│ sudo systemctl start socktop-agent │
│ │
│ To check service status: │
│ │
│ sudo systemctl status socktop-agent │
│ │
│ Default settings: │
│ - Port: 3000 (use -p or --port to change) │
│ - SSL/TLS: disabled (use --enableSSL to enable) │
│ │
│ For more information, see: │
│ /usr/share/doc/socktop_agent/README.md │
│ │
└─────────────────────────────────────────────────────────────────────┘
EOF
exit 0
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -e
case "$1" in
purge)
# Remove user and group on purge
if getent passwd socktop >/dev/null; then
deluser --quiet socktop || true
fi
if getent group socktop >/dev/null; then
delgroup --quiet socktop || true
fi
# Remove state directory on purge
rm -rf /var/lib/socktop
# Remove config directory if empty
rmdir --ignore-fail-on-non-empty /etc/socktop 2>/dev/null || true
;;
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
# Do nothing on remove/upgrade
;;
*)
echo "postrm called with unknown argument \`$1'" >&2
exit 1
;;
esac
#DEBHELPER#
exit 0
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Socktop Agent - Remote System Monitor
Documentation=https://github.com/jasonwitty/socktop
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/socktop_agent --port 3000
Environment=RUST_LOG=info
# Optional authentication token:
# Environment=SOCKTOP_TOKEN=changeme
Restart=on-failure
RestartSec=5
User=socktop
Group=socktop
NoNewPrivileges=true
# Security hardening
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/socktop
StateDirectory=socktop
[Install]
WantedBy=multi-user.target
-95
View File
@@ -1,95 +0,0 @@
//! Caching for process metrics and journal entries
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use crate::types::{ProcessMetricsResponse, JournalResponse};
#[derive(Debug, Clone)]
struct CacheEntry<T> {
data: T,
cached_at: Instant,
ttl: Duration,
}
impl<T> CacheEntry<T> {
fn is_expired(&self) -> bool {
self.cached_at.elapsed() > self.ttl
}
}
#[derive(Debug)]
pub struct ProcessCache {
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
}
impl ProcessCache {
pub fn new() -> Self {
Self {
process_metrics: RwLock::new(HashMap::new()),
journal_entries: RwLock::new(HashMap::new()),
}
}
/// Get cached process metrics if available and not expired (250ms TTL)
pub async fn get_process_metrics(&self, pid: u32) -> Option<ProcessMetricsResponse> {
let cache = self.process_metrics.read().await;
if let Some(entry) = cache.get(&pid) {
if !entry.is_expired() {
return Some(entry.data.clone());
}
}
None
}
/// Cache process metrics with 250ms TTL
pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) {
let mut cache = self.process_metrics.write().await;
cache.insert(pid, CacheEntry {
data,
cached_at: Instant::now(),
ttl: Duration::from_millis(250),
});
}
/// Get cached journal entries if available and not expired (1s TTL)
pub async fn get_journal_entries(&self, pid: u32) -> Option<JournalResponse> {
let cache = self.journal_entries.read().await;
if let Some(entry) = cache.get(&pid) {
if !entry.is_expired() {
return Some(entry.data.clone());
}
}
None
}
/// Cache journal entries with 1s TTL
pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) {
let mut cache = self.journal_entries.write().await;
cache.insert(pid, CacheEntry {
data,
cached_at: Instant::now(),
ttl: Duration::from_secs(1),
});
}
/// Clean up expired entries periodically
pub async fn cleanup_expired(&self) {
{
let mut cache = self.process_metrics.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
{
let mut cache = self.journal_entries.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
}
}
impl Default for ProcessCache {
fn default() -> Self {
Self::new()
}
}
+8
View File
@@ -1,4 +1,5 @@
// gpu.rs // gpu.rs
#[cfg(feature = "gpu")]
use gfxinfo::active_gpu; use gfxinfo::active_gpu;
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -9,6 +10,7 @@ pub struct GpuMetrics {
pub mem_total_bytes: u64, pub mem_total_bytes: u64,
} }
#[cfg(feature = "gpu")]
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> { pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
let gpu = active_gpu()?; // Use ? to unwrap Result let gpu = active_gpu()?; // Use ? to unwrap Result
let info = gpu.info(); let info = gpu.info();
@@ -22,3 +24,9 @@ pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>>
Ok(vec![metrics]) Ok(vec![metrics])
} }
#[cfg(not(feature = "gpu"))]
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
// GPU support not available on this platform
Ok(vec![])
}
+45 -2
View File
@@ -29,10 +29,53 @@ fn arg_value(name: &str) -> Option<String> {
None None
} }
#[tokio::main] fn main() -> anyhow::Result<()> {
async fn main() -> anyhow::Result<()> { // Install rustls crypto provider before any TLS operations
// This is required when using axum-server's tls-rustls feature
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok(); // Ignore error if already installed
#[cfg(feature = "logging")]
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// Configure Tokio runtime with optimized thread pool for reduced overhead.
//
// The agent is primarily I/O-bound (WebSocket, /proc file reads, sysinfo)
// with no CPU-intensive or blocking operations, so a smaller thread pool
// is beneficial:
//
// Benefits:
// - Lower memory footprint (~1-2MB per thread saved)
// - Reduced context switching overhead
// - Fewer idle threads consuming resources
// - Better for resource-constrained systems
//
// Trade-offs:
// - Slightly reduced throughput under very high concurrent connections
// - Could introduce latency if blocking operations are added (don't do this!)
//
// Default: 2 threads (sufficient for typical workloads with 1-10 clients)
// Override: Set SOCKTOP_WORKER_THREADS=4 to use more threads if needed
//
// Note: Default Tokio uses num_cpus threads which is excessive for this workload.
let worker_threads = std::env::var("SOCKTOP_WORKER_THREADS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2)
.clamp(1, 16); // Ensure 1-16 threads
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(worker_threads)
.thread_name("socktop-agent")
.enable_all()
.build()?;
runtime.block_on(async_main())
}
async fn async_main() -> anyhow::Result<()> {
// Version flag (print and exit). Keep before heavy initialization. // Version flag (print and exit). Keep before heavy initialization.
if arg_flag("--version") || arg_flag("-V") { if arg_flag("--version") || arg_flag("-V") {
println!("socktop_agent {}", env!("CARGO_PKG_VERSION")); println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
+351 -238
View File
@@ -18,49 +18,45 @@ use std::sync::Mutex;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
#[cfg(feature = "logging")]
use tracing::warn; use tracing::warn;
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
// Helper functions to get CPU time from /proc/stat on Linux // Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go.
// Returns (0, 0) if the file can't be read.
//
// We use `rfind(')')` to step past the `comm` field, which can contain
// arbitrary characters (including spaces and parens), then index the
// post-comm fields by position. This is the same trick `read_proc_jiffies`
// uses below — `split_whitespace().collect::<Vec<_>>()` from the start of
// the file would mis-parse process names with spaces, and also wastes an
// allocation per call. Two callers used to read this file twice (once for
// user, once for system); now it's one syscall per detailed-process record.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn get_cpu_time_user(pid: u32) -> u64 { fn get_cpu_times_ms(pid: u32) -> (u64, u64) {
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
let fields: Vec<&str> = stat.split_whitespace().collect(); return (0, 0);
if fields.len() > 13 { };
// Field 13 (0-indexed) is utime (user CPU time in clock ticks) let Some(rpar) = s.rfind(')') else {
if let Ok(utime) = fields[13].parse::<u64>() { return (0, 0);
// Convert clock ticks to milliseconds (assuming 100 Hz) };
return utime * 10; // 1 tick = 10ms at 100 Hz let Some(after) = s.get(rpar + 2..) else {
} return (0, 0);
} };
} let mut it = after.split_whitespace();
0 // Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid,
} // flags, minflt, cminflt, majflt, cmajflt, utime, stime, ...
// utime is offset 11; stime follows.
#[cfg(target_os = "linux")] let utime = it.nth(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
fn get_cpu_time_system(pid: u32) -> u64 { let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { // 1 tick = 10ms at 100 Hz (USER_HZ).
let fields: Vec<&str> = stat.split_whitespace().collect(); (utime * 10, stime * 10)
if fields.len() > 14 {
// Field 14 (0-indexed) is stime (system CPU time in clock ticks)
if let Ok(stime) = fields[14].parse::<u64>() {
// Convert clock ticks to milliseconds (assuming 100 Hz)
return stime * 10; // 1 tick = 10ms at 100 Hz
}
}
}
0
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn get_cpu_time_user(_pid: u32) -> u64 { fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
0 // Not implemented for non-Linux platforms (0, 0)
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_time_system(_pid: u32) -> u64 {
0 // Not implemented for non-Linux platforms
} }
// Runtime toggles (read once) // Runtime toggles (read once)
fn gpu_enabled() -> bool { fn gpu_enabled() -> bool {
@@ -80,6 +76,47 @@ fn temp_enabled() -> bool {
}) })
} }
// TTL knobs read once at first use, then cached. These hit the hot polling
// paths (every 250ms-1.5s), so re-reading via libc getenv per call is wasted.
fn metrics_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(250)
})
}
fn disks_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000)
})
}
#[cfg(target_os = "linux")]
fn processes_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_500)
})
}
#[cfg(not(target_os = "linux"))]
fn name_cache_cleanup_threshold() -> usize {
static V: OnceCell<usize> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms // Tiny TTL caches to avoid rescanning sensors every 500ms
const TTL: Duration = Duration::from_millis(1500); const TTL: Duration = Duration::from_millis(1500);
struct TempCache { struct TempCache {
@@ -88,6 +125,32 @@ struct TempCache {
} }
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new(); static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
// Last time `state.components` was refreshed (by any caller). Both
// collect_fast_metrics and collect_disks need fresh sensor values; without
// this gate they were each doing their own `Components::refresh` on their
// own cadence, paying the hwmon syscall cost twice per polling cycle.
// 1s is short enough that disk temps stay accurate (they change slowly) and
// long enough to suppress back-to-back refreshes from concurrent endpoints.
const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000);
static COMPONENTS_LAST_REFRESH: OnceCell<Mutex<Option<Instant>>> = OnceCell::new();
/// Refresh `state.components` only if the cached refresh timestamp is older
/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components
/// lock.
fn refresh_components_if_stale(components: &mut sysinfo::Components) {
let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None));
let mut last = match lock.lock() {
Ok(g) => g,
Err(_) => return, // Poisoned — skip; values stay as-is until next call
};
let now = Instant::now();
let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL);
if stale {
components.refresh(false);
*last = Some(now);
}
}
struct GpuCache { struct GpuCache {
at: Option<Instant>, at: Option<Instant>,
v: Option<Vec<crate::gpu::GpuMetrics>>, v: Option<Vec<crate::gpu::GpuMetrics>>,
@@ -153,12 +216,7 @@ fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus). // Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
pub async fn collect_fast_metrics(state: &AppState) -> Metrics { pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
// TTL (ms) overridable via env, default 250ms let ttl = StdDuration::from_millis(metrics_ttl_ms());
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; let cache = state.cache_metrics.lock().await;
if cache.is_fresh(ttl) if cache.is_fresh(ttl)
@@ -168,11 +226,12 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
} }
} }
let mut sys = state.sys.lock().await; let mut sys = state.sys.lock().await;
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Err(_e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sys.refresh_cpu_usage(); sys.refresh_cpu_usage();
sys.refresh_memory(); sys.refresh_memory();
})) { })) {
warn!("sysinfo selective refresh panicked: {e:?}"); #[cfg(feature = "logging")]
warn!("sysinfo selective refresh panicked: {_e:?}");
} }
// Get or initialize hostname once // Get or initialize hostname once
@@ -200,7 +259,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
} else if temp_enabled() { } else if temp_enabled() {
let val = { let val = {
let mut components = state.components.lock().await; let mut components = state.components.lock().await;
components.refresh(false); refresh_components_if_stale(&mut components);
components.iter().find_map(|c| { components.iter().find_map(|c| {
let l = c.label().to_ascii_lowercase(); let l = c.label().to_ascii_lowercase();
if l.contains("cpu") if l.contains("cpu")
@@ -234,12 +293,19 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
}); });
let mut cache = cache.lock().unwrap(); let mut cache = cache.lock().unwrap();
// Collect current network names // Detect a topology change without allocating: compare lengths first,
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect(); // then zip and walk. Only on a real diff do we materialize the new
// names list. Was: `nets.keys().map(to_string).collect::<Vec<_>>()`
// Update cached network names if they changed // every tick — a fresh Vec<String> just to compare.
if cache.names != current_names { let topology_changed = cache.names.len() != nets.keys().count()
cache.names = current_names; || cache
.names
.iter()
.zip(nets.keys())
.any(|(cached, current)| cached.as_str() != current.as_str());
if topology_changed {
cache.names.clear();
cache.names.extend(nets.keys().map(|n| n.to_string()));
} }
// Reuse NetworkInfo objects // Reuse NetworkInfo objects
@@ -266,8 +332,9 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let v = match collect_all_gpus() { let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v), Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None, Ok(_) => None,
Err(e) => { Err(_e) => {
warn!("gpu collection failed: {e}"); #[cfg(feature = "logging")]
warn!("gpu collection failed: {_e}");
None None
} }
}; };
@@ -316,11 +383,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
// Cached disks // Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> { pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS") let ttl = StdDuration::from_millis(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; let cache = state.cache_disks.lock().await;
if cache.is_fresh(ttl) if cache.is_fresh(ttl)
@@ -336,7 +399,9 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// NVMe temps show up as "Composite" under different chip names // NVMe temps show up as "Composite" under different chip names
let disk_temps = { let disk_temps = {
let mut components = state.components.lock().await; let mut components = state.components.lock().await;
components.refresh(true); // true = refresh values, not just the list // Shared TTL-gated refresh: avoids paying the hwmon scan twice when
// both endpoints converge in the same second.
refresh_components_if_stale(&mut components);
let mut composite_temps = Vec::new(); let mut composite_temps = Vec::new();
@@ -348,6 +413,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
if label.contains("composite") if label.contains("composite")
&& let Some(temp) = c.temperature() && let Some(temp) = c.temperature()
{ {
#[cfg(feature = "logging")]
tracing::debug!("Found Composite temp: {}°C", temp); tracing::debug!("Found Composite temp: {}°C", temp);
composite_temps.push(temp); composite_temps.push(temp);
} }
@@ -357,9 +423,11 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let mut temps = std::collections::HashMap::new(); let mut temps = std::collections::HashMap::new();
for (idx, temp) in composite_temps.iter().enumerate() { for (idx, temp) in composite_temps.iter().enumerate() {
let key = format!("nvme{}n1", idx); let key = format!("nvme{}n1", idx);
#[cfg(feature = "logging")]
tracing::debug!("Mapping {} -> {}°C", key, temp); tracing::debug!("Mapping {} -> {}°C", key, temp);
temps.insert(key, *temp); temps.insert(key, *temp);
} }
#[cfg(feature = "logging")]
tracing::debug!("Final disk_temps map: {:?}", temps); tracing::debug!("Final disk_temps map: {:?}", temps);
temps temps
}; };
@@ -394,6 +462,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// Try to find temperature for this disk // Try to find temperature for this disk
let temperature = disk_temps.iter().find_map(|(key, &temp)| { let temperature = disk_temps.iter().find_map(|(key, &temp)| {
if name.starts_with(key) { if name.starts_with(key) {
#[cfg(feature = "logging")]
tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp); tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp);
Some(temp) Some(temp)
} else { } else {
@@ -402,6 +471,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
}); });
if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") { if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") {
#[cfg(feature = "logging")]
tracing::debug!("No temperature found for disk: {}", name); tracing::debug!("No temperature found for disk: {}", name);
} }
@@ -564,12 +634,7 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client. /// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS") let ttl = StdDuration::from_millis(processes_ttl_ms());
.ok()
.and_then(|v| v.parse().ok())
// Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates).
.unwrap_or(1_500);
let ttl = StdDuration::from_millis(ttl_ms);
{ {
let cache = state.cache_processes.lock().await; let cache = state.cache_processes.lock().await;
if cache.is_fresh(ttl) if cache.is_fresh(ttl)
@@ -578,13 +643,24 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
return c.clone(); return c.clone();
} }
} }
// Reuse shared System to avoid reallocation; refresh processes fully. // Reuse shared System to avoid reallocation. We only need name + memory
// from sysinfo here — per-process CPU% is computed below from /proc/{pid}/stat
// jiffies (see `read_proc_jiffies` + `read_total_jiffies`), so asking sysinfo
// to gather CPU/exe/cmd/cwd/env per process is wasted /proc traffic on a Pi
// (was reading /proc/{pid}/{cmdline,exe,cwd,environ,io,status} for every PID
// on every 2 s poll via `everything()`).
//
// `without_tasks()` is REQUIRED: it suppresses per-thread entries in the
// process map (without it, sysinfo returns one entry per /proc/[tid] —
// 780+ entries on a typical desktop because of glib/gdbus/Chrome thread
// pools). The original code paired this with `everything()`; we keep the
// filter when downgrading to a minimal refresh spec.
let mut sys_guard = state.sys.lock().await; let mut sys_guard = state.sys.lock().await;
let sys = &mut *sys_guard; let sys = &mut *sys_guard;
sys.refresh_processes_specifics( sys.refresh_processes_specifics(
ProcessesToUpdate::All, ProcessesToUpdate::All,
false, false,
ProcessRefreshKind::everything().without_tasks(), ProcessRefreshKind::nothing().with_memory().without_tasks(),
); );
let total_count = sys.processes().len(); let total_count = sys.processes().len();
@@ -599,36 +675,50 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
} }
let total_now = read_total_jiffies().unwrap_or(0); let total_now = read_total_jiffies().unwrap_or(0);
// Compute deltas vs last sample // Compute deltas vs last sample. We hold the proc_cpu lock for the whole
let (last_total, mut last_map) = { // collection below so we can read+update the per-pid name cache in one
#[cfg(target_os = "linux")] // critical section.
{ let mut tracker = state.proc_cpu.lock().await;
let mut t = state.proc_cpu.lock().await; let last_total = tracker.last_total;
let lt = t.last_total; // Move the old per-pid jiffies map out for delta computation.
let lm = std::mem::take(&mut t.last_per_pid); let mut last_map = std::mem::take(&mut tracker.last_per_pid);
t.last_total = total_now; tracker.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 // Resolve a name through the per-pid cache. Allocates only on miss.
let resolve_name =
|tracker: &mut crate::state::ProcCpuTracker, pid: u32, p: &sysinfo::Process| -> String {
if let Some(cached) = tracker.names.get(&pid) {
return cached.clone();
}
let new_name = p.name().to_string_lossy().into_owned();
tracker.names.insert(pid, new_name.clone());
new_name
};
// On first run or if total delta is tiny, report zeros.
if last_total == 0 || total_now <= last_total { if last_total == 0 || total_now <= last_total {
let procs: Vec<ProcessInfo> = sys let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
.processes() for p in sys.processes().values() {
.values() let pid = p.pid().as_u32();
.map(|p| ProcessInfo { let name = resolve_name(&mut tracker, pid, p);
pid: p.pid().as_u32(), procs.push(ProcessInfo {
name: p.name().to_string_lossy().into_owned(), pid,
name,
cpu_usage: 0.0, cpu_usage: 0.0,
mem_bytes: p.memory(), mem_bytes: p.memory(),
}) });
.collect(); }
// Stash the just-collected jiffies for next call's delta, then prune
// dead pids from the name cache. Borrowing dance: retain reads
// `tracker.last_per_pid` through the closure, which conflicts with
// the mutable borrow of `tracker.names.retain`. Split via split-borrow:
tracker.last_per_pid = current;
let crate::state::ProcCpuTracker {
ref last_per_pid,
ref mut names,
..
} = *tracker;
names.retain(|pid, _| last_per_pid.contains_key(pid));
return ProcessesPayload { return ProcessesPayload {
process_count: total_count, process_count: total_count,
top_processes: procs, top_processes: procs,
@@ -637,23 +727,31 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let dt = total_now.saturating_sub(last_total).max(1) as f32; let dt = total_now.saturating_sub(last_total).max(1) as f32;
let procs: Vec<ProcessInfo> = sys let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
.processes() for p in sys.processes().values() {
.values() let pid = p.pid().as_u32();
.map(|p| { let now = current.get(&pid).copied().unwrap_or(0);
let pid = p.pid().as_u32(); let prev = last_map.remove(&pid).unwrap_or(0);
let now = current.get(&pid).copied().unwrap_or(0); let du = now.saturating_sub(prev) as f32;
let prev = last_map.remove(&pid).unwrap_or(0); let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
let du = now.saturating_sub(prev) as f32; let name = resolve_name(&mut tracker, pid, p);
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0); procs.push(ProcessInfo {
ProcessInfo { pid,
pid, name,
name: p.name().to_string_lossy().into_owned(), cpu_usage: cpu,
cpu_usage: cpu, mem_bytes: p.memory(),
mem_bytes: p.memory(), });
} }
}) // Save current jiffies map for next call and prune dead pids from the
.collect(); // name cache. `current` is moved here (no clone — that's also #19).
tracker.last_per_pid = current;
let crate::state::ProcCpuTracker {
ref last_per_pid,
ref mut names,
..
} = *tracker;
names.retain(|pid, _| last_per_pid.contains_key(pid));
drop(tracker);
let payload = ProcessesPayload { let payload = ProcessesPayload {
process_count: total_count, process_count: total_count,
@@ -741,17 +839,22 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// .unwrap_or(std::cmp::Ordering::Equal) // .unwrap_or(std::cmp::Ordering::Equal)
// }); // });
// Clean up old process names cache when it grows too large // Clean up old process names cache when it grows too large.
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD") let cache_cleanup_threshold = name_cache_cleanup_threshold();
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
if total_count > proc_cache.names.len() + cache_cleanup_threshold { if total_count > proc_cache.names.len() + cache_cleanup_threshold {
// `now` is only consumed by the `tracing::debug!` below, so gate
// the binding with the same cfg as its consumer. Without this,
// a non-logging build (the default) emits an unused-variable
// warning. The Linux CI doesn't catch it because this block lives
// in the `#[cfg(not(target_os = "linux"))]` collect_processes_all —
// the warning only surfaces on the Windows build matrix.
#[cfg(feature = "logging")]
let now = std::time::Instant::now(); let now = std::time::Instant::now();
proc_cache proc_cache
.names .names
.retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid))); .retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid)));
#[cfg(feature = "logging")]
tracing::debug!( tracing::debug!(
"Cleaned up {} stale process names in {}ms", "Cleaned up {} stale process names in {}ms",
proc_cache.names.capacity() - proc_cache.names.len(), proc_cache.names.capacity() - proc_cache.names.len(),
@@ -802,17 +905,94 @@ fn enumerate_child_processes_lightweight(
children children
} }
/// Single-read extraction of the /proc/{pid}/status fields the detail
/// endpoint cares about. Callers used to open this file twice per
/// detail-process record (once for VmRSS/VmSize, once for Uid/Gid/Threads/
/// State); now it's one read + one scan.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct ProcStatus {
rss_kb: u64,
vsize_kb: u64,
uid: u32,
gid: u32,
threads: u32,
/// Raw status letter from `State:` (e.g. 'R', 'S'). '?' if missing.
state_ch: char,
}
#[cfg(target_os = "linux")]
fn read_proc_status(pid: u32) -> Option<ProcStatus> {
let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut out = ProcStatus {
state_ch: '?',
..Default::default()
};
for line in content.lines() {
if let Some(v) = line.strip_prefix("VmRSS:") {
out.rss_kb = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("VmSize:") {
out.vsize_kb = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Uid:") {
out.uid = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Gid:") {
out.gid = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Threads:") {
out.threads = v.trim().parse().unwrap_or(0);
} else if let Some(v) = line.strip_prefix("State:") {
out.state_ch = v.trim().chars().next().unwrap_or('?');
}
}
Some(out)
}
#[cfg(target_os = "linux")]
fn proc_state_label(c: char) -> &'static str {
match c {
'R' => "Running",
'S' => "Sleeping",
'D' => "Disk Sleep",
'Z' => "Zombie",
'T' => "Stopped",
't' => "Tracing Stop",
'X' | 'x' => "Dead",
'K' => "Wakekill",
'W' => "Waking",
'P' => "Parked",
'I' => "Idle",
_ => "Unknown",
}
}
/// Read parent PID from /proc/{pid}/stat /// Read parent PID from /proc/{pid}/stat
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> { fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// Format: pid (comm) state ppid ... // Format: pid (comm) state ppid ... — comm can contain spaces/parens,
// We need to handle process names with spaces/parentheses // so we step past the closing paren first.
let ppid_start = stat.rfind(')')?; let ppid_start = stat.rfind(')')?;
let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect(); // After ") ": state, ppid, ... — ppid is the second field.
// After the closing paren: state ppid ... stat[ppid_start + 1..]
// Field 1 (0-indexed) is ppid .split_whitespace()
fields.get(1)?.parse::<u32>().ok() .nth(1)?
.parse::<u32>()
.ok()
} }
/// Collect process information from /proc files /// Collect process information from /proc files
@@ -821,8 +1001,11 @@ fn collect_process_info_from_proc(
pid: u32, pid: u32,
system: &sysinfo::System, system: &sysinfo::System,
) -> Option<DetailedProcessInfo> { ) -> Option<DetailedProcessInfo> {
// Try to get basic info from sysinfo if it's already loaded (cheap lookup) // One read of /proc/{pid}/status gets us everything the detail endpoint
// Otherwise read from /proc directly // needs from it: memory (when not in sysinfo cache), Uid/Gid, Threads,
// and State. The previous code opened this file twice per process record.
let st = read_proc_status(pid)?;
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) = let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) { if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
( (
@@ -832,30 +1015,13 @@ fn collect_process_info_from_proc(
proc.virtual_memory(), proc.virtual_memory(),
) )
} else { } else {
// Process not in sysinfo cache, read minimal info from /proc // Process not in sysinfo cache — derive name from /proc/{pid}/comm
// and memory from the status read above.
let name = fs::read_to_string(format!("/proc/{pid}/comm")) let name = fs::read_to_string(format!("/proc/{pid}/comm"))
.ok()? .ok()?
.trim() .trim()
.to_string(); .to_string();
(name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024)
// Read memory from /proc/{pid}/status
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut mem_bytes = 0u64;
let mut virtual_mem_bytes = 0u64;
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("VmRSS:") {
if let Some(kb) = value.split_whitespace().next() {
mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
}
} else if let Some(value) = line.strip_prefix("VmSize:")
&& let Some(kb) = value.split_whitespace().next()
{
virtual_mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
}
}
(name, 0.0, mem_bytes, virtual_mem_bytes)
}; };
// Read command line // Read command line
@@ -864,54 +1030,21 @@ fn collect_process_info_from_proc(
.map(|s| s.replace('\0', " ").trim().to_string()) .map(|s| s.replace('\0', " ").trim().to_string())
.unwrap_or_default(); .unwrap_or_default();
// Read status information let uid = st.uid;
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; let gid = st.gid;
let mut uid = 0u32; let thread_count = st.threads;
let mut gid = 0u32; let status = proc_state_label(st.state_ch).to_string();
let mut thread_count = 0u32;
let mut status = "Unknown".to_string();
for line in status_content.lines() { // Read start time from stat — comm-safe via rfind(')').
if let Some(value) = line.strip_prefix("Uid:") {
if let Some(uid_str) = value.split_whitespace().next() {
uid = uid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Gid:") {
if let Some(gid_str) = value.split_whitespace().next() {
gid = gid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Threads:") {
thread_count = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("State:") {
status = value
.trim()
.chars()
.next()
.map(|c| match c {
'R' => "Running",
'S' => "Sleeping",
'D' => "Disk Sleep",
'Z' => "Zombie",
'T' => "Stopped",
't' => "Tracing Stop",
'X' | 'x' => "Dead",
'K' => "Wakekill",
'W' => "Waking",
'P' => "Parked",
'I' => "Idle",
_ => "Unknown",
})
.unwrap_or("Unknown")
.to_string();
}
}
// Read start time from stat
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let stat_end = stat.rfind(')')?; let stat_end = stat.rfind(')')?;
let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect(); // After ") ": state, ppid, ..., starttime — starttime is the 20th
// Field 19 (0-indexed) is starttime in clock ticks since boot // post-comm field (index 19).
fields.get(19)?.parse::<u64>().ok()? stat[stat_end + 1..]
.split_whitespace()
.nth(19)?
.parse::<u64>()
.ok()?
} else { } else {
0 0
}; };
@@ -945,6 +1078,9 @@ fn collect_process_info_from_proc(
.ok() .ok()
.map(|p| p.to_string_lossy().to_string()); .map(|p| p.to_string_lossy().to_string());
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
Some(DetailedProcessInfo { Some(DetailedProcessInfo {
pid, pid,
name, name,
@@ -960,8 +1096,8 @@ fn collect_process_info_from_proc(
user_id: uid, user_id: uid,
group_id: gid, group_id: gid,
start_time, start_time,
cpu_time_user: get_cpu_time_user(pid), cpu_time_user,
cpu_time_system: get_cpu_time_system(pid), cpu_time_system,
read_bytes, read_bytes,
write_bytes, write_bytes,
working_directory, working_directory,
@@ -1050,22 +1186,24 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.trim() .trim()
.to_string(); .to_string();
// Read thread stat for CPU times and status // Read thread stat for CPU times and status.
let stat_path = format!("/proc/{pid}/task/{tid}/stat"); let stat_path = format!("/proc/{pid}/task/{tid}/stat");
let Ok(stat_content) = fs::read_to_string(&stat_path) else { let Ok(stat_content) = fs::read_to_string(&stat_path) else {
continue; continue;
}; };
// Parse stat file (similar format to process stat) // Thread/comm names can contain spaces or parens, so step past the
// Fields: pid comm state ... utime stime ... // last ')' before parsing post-comm fields. Post-comm offsets:
let fields: Vec<&str> = stat_content.split_whitespace().collect(); // 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime
if fields.len() < 15 { let Some(rpar) = stat_content.rfind(')') else {
continue; continue;
} };
let Some(after) = stat_content.get(rpar + 1..) else {
// Field 2 is state (R, S, D, Z, T, etc.) continue;
let status = fields };
.get(2) let mut it = after.split_whitespace();
let status = it
.next()
.and_then(|s| s.chars().next()) .and_then(|s| s.chars().next())
.map(|c| match c { .map(|c| match c {
'R' => "Running", 'R' => "Running",
@@ -1080,16 +1218,9 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
// Field 13 is utime (user CPU time in clock ticks) // 10 fields between state and utime (ppid..cmajflt).
// Field 14 is stime (system CPU time in clock ticks) let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let utime = fields let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
.get(13)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let stime = fields
.get(14)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
// Convert clock ticks to microseconds (assuming 100 Hz) // Convert clock ticks to microseconds (assuming 100 Hz)
// 1 tick = 10ms = 10,000 microseconds // 1 tick = 10ms = 10,000 microseconds
@@ -1158,34 +1289,13 @@ pub async fn collect_process_metrics(
let parent_pid = process.parent().map(|p| p.as_u32()); let parent_pid = process.parent().map(|p| p.as_u32());
let start_time = process.start_time(); let start_time = process.start_time();
// Read UID and GID directly from /proc/{pid}/status for accuracy // Read UID and GID directly from /proc/{pid}/status for accuracy.
// Uses the shared single-read helper (also extracts memory, threads,
// state — we discard those here since sysinfo already provided them).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let (user_id, group_id) = let (user_id, group_id) = read_proc_status(pid)
if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) { .map(|s| (s.uid, s.gid))
let mut uid = 0u32; .unwrap_or((0, 0));
let mut gid = 0u32;
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("Uid:") {
// Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
// We want the real UID (first value)
if let Some(uid_str) = value.split_whitespace().next() {
uid = uid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Gid:") {
// Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
// We want the real GID (first value)
if let Some(gid_str) = value.split_whitespace().next() {
gid = gid_str.parse().unwrap_or(0);
}
}
}
(uid, gid)
} else {
// Fallback if /proc read fails (permission issue)
(0, 0)
};
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let (user_id, group_id) = (0, 0); let (user_id, group_id) = (0, 0);
@@ -1239,6 +1349,9 @@ pub async fn collect_process_metrics(
// Collect thread information (Linux only) // Collect thread information (Linux only)
let threads = collect_thread_info(pid); let threads = collect_thread_info(pid);
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
// Now construct the detailed info without holding the lock // Now construct the detailed info without holding the lock
let detailed_info = DetailedProcessInfo { let detailed_info = DetailedProcessInfo {
pid, pid,
@@ -1255,8 +1368,8 @@ pub async fn collect_process_metrics(
user_id, user_id,
group_id, group_id,
start_time, start_time,
cpu_time_user: get_cpu_time_user(pid), cpu_time_user,
cpu_time_system: get_cpu_time_system(pid), cpu_time_system,
read_bytes, read_bytes,
write_bytes, write_bytes,
working_directory, working_directory,
+4
View File
@@ -17,6 +17,10 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
pub struct ProcCpuTracker { pub struct ProcCpuTracker {
pub last_total: u64, pub last_total: u64,
pub last_per_pid: HashMap<u32, u64>, pub last_per_pid: HashMap<u32, u64>,
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
/// the same `String`s on every processes poll (~once per 1.5s).
pub names: HashMap<u32, String>,
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
+28 -19
View File
@@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
Message::Text(ref text) if text == "get_processes" => { Message::Text(ref text) if text == "get_processes" => {
let payload = collect_processes_all(&state).await; let payload = collect_processes_all(&state).await;
// Map to protobuf message // Get cached buffers. The Vec capacity is preserved across
// Get cached buffers // calls (with_capacity(512) seeds it, then we swap-back after
// encode so the allocation outlives any single request).
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new())); let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
let mut cache = cache.lock().await; let mut cache = cache.lock().await;
// Reuse process vector to build the list
cache.processes_vec.clear(); cache.processes_vec.clear();
cache cache
.processes_vec .processes_vec
@@ -85,29 +85,38 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
mem_bytes: p.mem_bytes, mem_bytes: p.mem_bytes,
})); }));
let pb = pb::Processes { // Move the populated Vec into the proto, encode, then move it
// BACK into the cache so the next call reuses the same heap
// allocation. The previous code did `mem::take(...)` here but
// then dropped `pb` (and the Vec along with it), leaving the
// cache holding an empty zero-capacity Vec — defeating the
// whole point of `with_capacity(512)`.
let mut pb = pb::Processes {
process_count: payload.process_count as u64, process_count: payload.process_count as u64,
rows: std::mem::take(&mut cache.processes_vec), rows: std::mem::take(&mut cache.processes_vec),
}; };
let mut buf = Vec::with_capacity(8 * 1024); let mut buf = Vec::with_capacity(8 * 1024);
if prost::Message::encode(&pb, &mut buf).is_err() { let encode_result = prost::Message::encode(&pb, &mut buf);
// Restore the (now-encoded-from) Vec to the cache before pb is
// dropped. We `take` it out of pb to leave that field empty,
// and the next request will `.clear()` before refilling.
cache.processes_vec = std::mem::take(&mut pb.rows);
if encode_result.is_err() {
let _ = socket.send(Message::Close(None)).await; let _ = socket.send(Message::Close(None)).await;
} else if buf.len() <= COMPRESSION_THRESHOLD {
let _ = socket.send(Message::Binary(buf)).await;
} else { } else {
// compress if large // Create a new encoder for each message to ensure proper gzip headers
if buf.len() <= COMPRESSION_THRESHOLD { let mut encoder =
let _ = socket.send(Message::Binary(buf)).await; GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
} else { match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
// Create a new encoder for each message to ensure proper gzip headers Ok(compressed) => {
let mut encoder = let _ = socket.send(Message::Binary(compressed)).await;
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast()); }
match encoder.write_all(&buf).and_then(|_| encoder.finish()) { Err(_) => {
Ok(compressed) => { let _ = socket.send(Message::Binary(buf)).await;
let _ = socket.send(Message::Binary(compressed)).await;
}
Err(_) => {
let _ = socket.send(Message::Binary(buf)).await;
}
} }
} }
} }
+1 -2
View File
@@ -1,4 +1,3 @@
use assert_cmd::prelude::*;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@@ -17,7 +16,7 @@ fn generates_self_signed_cert_and_key_in_xdg_path() {
let xdg = tmpdir.path().to_path_buf(); let xdg = tmpdir.path().to_path_buf();
// Run the agent once with --enableSSL, short timeout so it exits quickly when killed // Run the agent once with --enableSSL, short timeout so it exits quickly when killed
let mut cmd = Command::cargo_bin("socktop_agent").expect("binary exists"); let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("socktop_agent"));
// Bind to an ephemeral port (-p 0) to avoid conflicts/flakes // Bind to an ephemeral port (-p 0) to avoid conflicts/flakes
cmd.env("XDG_CONFIG_HOME", &xdg) cmd.env("XDG_CONFIG_HOME", &xdg)
.arg("--enableSSL") .arg("--enableSSL")
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "socktop_connector" name = "socktop_connector"
version = "0.1.6" version = "1.50.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
description = "WebSocket connector library for socktop agent communication" description = "WebSocket connector library for socktop agent communication"
+2 -2
View File
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"