Compare commits

...

10 Commits

Author SHA1 Message Date
jasonwitty a6518a79a9 minor bump to cargo version
Build Debian Packages / Build .deb for x86_64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for aarch64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for armv7-unknown-linux-gnueabihf (push) Has been cancelled
Build Debian Packages / Build .deb for riscv64gc-unknown-linux-gnu (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2026-08-24 07:22:51 -07:00
jasonwitty bedbe0a2ec Add flag to override logic and supress terminate option. (--no-kill)
Flag specifically used to block feature on socktop.io. Will remain
undocumented for standard usage.
2026-08-24 07:19:37 -07:00
jasonwitty a9cb4b732d Update socktop preview image to version 1.60 2026-08-23 20:47:41 -07:00
jason 40a0133aeb Update copyright year in LICENSE file
Updated copyright year from 2025 to 2026.
2026-08-23 18:33:08 -07:00
jason 407532ea1c Fix grammar and update LICENSE reference in README
Corrected grammatical errors and updated license reference format.
2026-08-23 18:32:37 -07:00
jason b9d10d2c90 Refactor README for improved clarity and formatting
Updated README to improve formatting and clarity, including adjustments to the features and platform support sections.
2026-08-23 18:24:57 -07:00
jason db34a142a3 Update README with resource links and modify intro
Removed the phrase 'inspired by top/btop' from the introduction and added new resource links for Auth Setup, TLS Setup, and Monitoring Multiple Hosts.
2026-08-23 17:48:53 -07:00
jason f3f616b0a2 Enhance README with resource links and description
Updated README to enhance description and add resources table.
2026-08-23 17:37:02 -07:00
jason fe3ef7f25e fix(ci): pin deb builds to ubuntu-22.04 and enforce the fleet glibc floor (#41)
Build Debian Packages / Build .deb for x86_64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for aarch64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for armv7-unknown-linux-gnueabihf (push) Has been cancelled
Build Debian Packages / Build .deb for riscv64gc-unknown-linux-gnu (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
* fix(ci): pin deb builds to ubuntu-22.04 and enforce the fleet glibc floor

The v1.60.0 debs failed to install on Raspberry Pi OS bookworm:

  socktop : Depends: libc6 (>= 2.39) but 2.36-9+rpt2+deb12u14 is to be installed

Cross-compiled binaries link against the RUNNER's (multiarch) glibc, so
the runner picks the minimum glibc the packages demand. ubuntu-latest
migrated from 22.04 (glibc 2.35) to 24.04 (glibc 2.39) between the
1.50.x releases and now, silently raising the requirement past the
Debian-12 fleet.

Pin the build job to ubuntu-22.04 (2.35 — satisfied by bookworm's 2.36)
and add a post-build gate that reads each .deb's computed libc6
requirement and fails the run if it exceeds the fleet floor, so the next
runner migration turns into a red build instead of a fleet-wide apt
error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): extract the libc6 version, not the 6 in 'libc6'

The floor gate's second grep matched the trailing digit of the package
name before the version ('libc6 (>= 2.34)' -> '6'), failing every
target. sed capture group instead; verified against realistic Depends
strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump all crates to 1.60.1

The 1.60.0 debs were built against glibc 2.39 and never installed on the
bookworm fleet; rather than force-moving the tag, the rebuilt release
ships as 1.60.1. Nothing was published to crates.io at 1.60.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 09:42:39 -07:00
jason 4c59716610 Kill a local process from the TUI, and stop the agent reporting dead ones (#40)
Build Debian Packages / Build .deb for x86_64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for aarch64-unknown-linux-gnu (push) Has been cancelled
Build Debian Packages / Build .deb for armv7-unknown-linux-gnueabihf (push) Has been cancelled
Build Debian Packages / Build .deb for riscv64gc-unknown-linux-gnu (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
* fix(agent): drop processes that no longer exist

`refresh_processes_specifics` was called with remove_dead_processes = false
against a long-lived System, so the agent accumulated every process it had
ever seen and went on reporting them. Measured on a Pi 5 after a few hours of
build churn: 21,648 processes reported, 289 actually running, growing a few
every poll.

Three consequences, in ascending order of how confusing they are:

  * unbounded memory growth, and every poll iterating ~75x more entries than
    it should
  * process_count — the client's "Top Processes (N total)" — is meaningless
  * a process you kill keeps its row forever, because the agent keeps sending
    it. Killing it again reports "no longer exists", since the kernel is
    telling the truth and the agent is not.

The third is how this was found: no amount of client-side reconciliation could
fix a list whose producer never forgets anything.

Passing `true` is only correct because these two sites use
ProcessesToUpdate::All. With `Some(pids)` sysinfo treats every process outside
the list as dead and removes it, so the per-PID refresh in
collect_process_metrics must keep `false` — noted in a comment there.

Verified by driving the TUI against a rebuilt agent: reported count 292 vs 290
real, and a killed row is removed once and never reappears.

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

* feat(socktop): kill a local process from the TUI

btop-style process termination, for local agents only. The signal is sent by
socktop itself through a direct sysinfo call — nothing is transmitted to the
agent, and the agent and connector have no kill capability at all.

Why local-only: the PIDs on screen are reported by the agent, and the signal is
sent with socktop's own OS privileges. A PID is therefore only meaningful, and
only safe to act on, when the agent lives on this machine; acting on a remote
agent's PIDs would signal whatever unrelated local process happened to share
that number. local::agent_is_local treats an address as local when it is
loopback or when an ephemeral bind succeeds (which only works for an address on
one of our own interfaces, so it also covers reaching our own agent by LAN IP),
requires every address a hostname resolves to to be local, and fails closed.

  * `t` on the selected process, and `t` inside Process Details. One key for
    both: `k` scrolls the thread table in the modal, so it could not be reused
    there.
  * The confirmation offers Terminate (focused first, so a reflexive Enter is
    the safe one), Force kill, and Cancel. Keeping SIGKILL behind a second
    button rather than a second keybinding means the destructive option has to
    be chosen deliberately.
  * The selection hint gained the key, but only for a local agent — advertising
    a key that deliberately does nothing is worse than no hint. Same for the
    details modal's help line.

The list is reconciled after a signal rather than left to the next poll. A
signalled PID goes on a watch list re-checked each metrics tick, because SIGTERM
is a request: the process is usually still alive at signal time, and its row
should go when it actually exits (or stay, if it ignores the signal). PIDs
confirmed gone are remembered briefly, since the agent serves Processes from a
1500ms cache and would otherwise hand back a pre-kill snapshot. A selection
whose process has left the list is dropped, and the details view closes for a
process that no longer exists — including when it dies on its own, which
previously flipped that modal to "Agent Update Required" because the wire cannot
distinguish "no such PID" from "endpoint unsupported".

Also fixes two pre-existing UI faults found on the way:

  * The selection hint was sized from its full label including the process name
    and skipped entirely when that exceeded the pane width — so it vanished
    exactly when a long-named process was selected. The name is now the elastic
    part, and widths are measured in columns rather than bytes.
  * Confirmation and Info dialogs laid their content out over the whole modal
    rect instead of the block's inner rect, putting the first line of text on
    the border row, and fell into the catch-all 70%x50% sizing arm, so a
    one-line question got half the screen. They now size to their content and
    use the theme's colors like the connection-error modal does.

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

* fix(kill): close stacked details view, guard PID reuse, scale settle with interval

Review fixes for the process-kill feature:

1. Killing from INSIDE the details view left it open forever, frozen on
   the dead process (reproduced live): the 'Signal sent' Info modal sits
   on top when the death is confirmed on the next tick, the old top-only
   close_process_details missed it, gone-PIDs are processed once, and the
   details-poll fallback was gated on the selection the kill had just
   cleared. The close now removes the dead PID's view wherever it sits in
   the stack — which also retires a dead parent's view from under a child
   in a navigation chain, so backing out lands on the process list rather
   than a frozen corpse view. Tests updated to the new semantics, plus a
   regression test for the Info-stacked case.

2. PID-reuse guard: the PID comes from an agent snapshot and the
   confirmation can sit open indefinitely, so by signal time the kernel
   may have recycled the number. kill_local_process now takes the name
   the user confirmed and refuses to signal a PID whose current owner
   does not match ('PID N now belongs to X, not Y').

3. A transient request error no longer closes the details view: with
   process_details_answered set, any Err was read as 'process gone',
   including socket blips. The view now closes only when the PID is also
   absent from the agent's own process list.

4. PROC_CACHE_SETTLE scales with the user's processes interval (floor at
   the old 1.6s default-TTL value), and the tombstone lifetime rides on
   top of it — users who raise the agent's Processes TTL raise the client
   interval to match, so the interval is the best client-side signal for
   how stale an agent snapshot can be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(kill): resume polling for the details view that resurfaces from a chain

Reported: kill still orphaned a window via Enter (child details) -> P
(parent details) -> t (terminate parent). The parent's view closed
correctly, but the child's view underneath resurfaced with no selection
(forget_process_row had cleared it — it pointed at the parent) and wiped
data; the selection-gated details poll never refilled it.

close_details_for_gone_process now retargets the selection to the
uppermost remaining ProcessDetails view (looking through stacked
Info/Confirmation modals) and makes the poll due immediately — the same
retarget SwitchToParentProcess performs on the way down the chain.

Same class of hole in plain navigation, fixed alongside: Esc-ing back
from a parent view left the selection on the PARENT, so the resurfaced
child-titled view refilled with the parent's data. The dismiss arm now
retargets the selection to whatever details view it lands on.

Both verified live: child -> P -> kill parent -> child view resurfaces
populated and updating; child -> P -> Esc -> child view shows the child.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: changelog section for the process-kill feature and agent dead-process fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 08:11:17 -07:00
19 changed files with 1999 additions and 675 deletions
+25 -1
View File
@@ -18,7 +18,15 @@ env:
jobs: jobs:
build-deb: build-deb:
name: Build .deb for ${{ matrix.target }} name: Build .deb for ${{ matrix.target }}
runs-on: ubuntu-latest # PINNED, not ubuntu-latest: the binaries link against this runner's
# (multiarch) glibc, so the runner sets the MINIMUM glibc the .debs demand
# at install time. ubuntu-latest moved to 24.04/glibc 2.39 and the packages
# stopped installing on Debian 12/RPi OS bookworm (glibc 2.36). 22.04 links
# 2.35, which bookworm satisfies. The "enforce glibc floor" step below
# turns any future violation into a red build instead of a fleet-wide apt
# failure — if this pin ever has to move past bookworm's glibc, that step
# is the contract to renegotiate first.
runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
include: include:
@@ -159,6 +167,22 @@ jobs:
mkdir -p debs mkdir -p debs
cp target/${{ matrix.target }}/debian/*.deb debs/ cp target/${{ matrix.target }}/debian/*.deb debs/
- name: Enforce glibc floor (Debian 12 / RPi OS bookworm fleet)
run: |
# The fleet's oldest supported glibc. A .deb that demands newer libc6
# than this will not install on the Pis — fail HERE, not at apt time.
FLOOR="2.36"
fail=0
for deb in debs/*.deb; do
req=$(dpkg-deb -f "$deb" Depends | sed -n 's/.*libc6 (>= \([0-9.]*\)).*/\1/p' | head -1)
echo "$deb -> libc6 >= ${req:-none}"
if [ -n "$req" ] && [ "$(printf '%s\n' "$req" "$FLOOR" | sort -V | tail -1)" != "$FLOOR" ]; then
echo "::error::$deb requires libc6 >= $req, exceeding the fleet floor $FLOOR (bookworm). The build runner's glibc is too new — see the runs-on pin comment."
fail=1
fi
done
exit $fail
- name: List generated packages - name: List generated packages
run: ls -lh debs/ run: ls -lh debs/
+27 -2
View File
@@ -1,8 +1,26 @@
# Changelog # Changelog
## 1.60.0 — unreleased ## Unreleased
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.0 together. ### TUI
- **`--no-kill` flag and `SOCKTOP_NO_KILL` env var** disable the local
process-kill feature regardless of agent locality, for shared terminals and
public demos (e.g. the socktop.io webterm). Either one forces the feature
off and suppresses the `t` kill hints; the env var covers every socktop
invocation under a deployment without touching command lines. `App`'s
builder renamed `with_local``with_kill_enabled` to match what it now
means (locality fact AND policy).
## 1.60.1 — unreleased
Identical to 1.60.0 plus rebuilt Debian packages: the 1.60.0 debs were linked
against glibc 2.39 (a GitHub runner migration) and would not install on
Debian 12 / Raspberry Pi OS bookworm. CI now pins the build environment and
gates every package against the fleet's glibc floor. 1.60.0 was never
published to crates.io.
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.1 together.
### Security ### Security
@@ -51,6 +69,13 @@ Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_age
- `socktop` consumes `socktop_connector` via a path+version dep — connector changes are testable in-repo before publishing. - `socktop` consumes `socktop_connector` via a path+version dep — connector changes are testable in-repo before publishing.
- wasm examples build against the in-repo connector; note `zellij_socktop_plugin` has pre-existing compile errors and needs its own rework. - wasm examples build against the in-repo connector; note `zellij_socktop_plugin` has pre-existing compile errors and needs its own rework.
### Process kill (PR #40)
- **Kill a local process from the TUI** (`t` on a selected process, or inside Process Details): btop-style Terminate/Force-kill confirmation. Local agents only — the signal is sent by socktop itself with its own privileges, never over the wire; remote agents never show the option. PID-reuse guarded (the confirmed name must still own the PID at signal time).
- **Agent no longer reports dead processes**: a long-lived sysinfo `System` accumulated every process ever seen (21k+ entries on a 289-process host), inflating memory, per-poll work, and the process count — and keeping killed processes on screen forever. Update agent and client together on machines where the kill feature will be used.
- Killed rows leave the list when the process actually exits and cannot be resurrected by cached agent snapshots; details views for dead processes close themselves, including through parent-navigation chains.
- Selection hint no longer vanishes for long process names; confirmation/info dialogs size to their content.
### Upgrade notes ### Upgrade notes
- **Release/publish order**: `socktop_connector``socktop` → agent packages. - **Release/publish order**: `socktop_connector``socktop` → agent packages.
Generated
+4 -3
View File
@@ -2412,7 +2412,7 @@ dependencies = [
[[package]] [[package]]
name = "socktop" name = "socktop"
version = "1.60.0" version = "1.60.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
@@ -2423,6 +2423,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"socktop_connector", "socktop_connector",
"sysinfo",
"tempfile", "tempfile",
"tokio", "tokio",
"unicode-width", "unicode-width",
@@ -2431,7 +2432,7 @@ dependencies = [
[[package]] [[package]]
name = "socktop_agent" name = "socktop_agent"
version = "1.60.0" version = "1.60.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
@@ -2463,7 +2464,7 @@ dependencies = [
[[package]] [[package]]
name = "socktop_connector" name = "socktop_connector"
version = "1.60.0" version = "1.60.1"
dependencies = [ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Witty One Off Copyright (c) 2026 Witty One Off
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+20 -556
View File
@@ -1,548 +1,36 @@
# socktop # socktop
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets. _socktop_ is a remote system monitor with a rich TUI, talking to an ultra lightweight agent over WebSockets.
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler) <img src="./docs/socktop_demo_1_60.apng" width="100%">
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
[socktop.io](https://www.socktop.io) ## Resources
<img src="./docs/socktop_demo.apng" width="100%"> | Resource | Location |
| -------- | -------- |
| Website and online demo (yes it's real) | [socktop.io](https://www.socktop.io) |
| Quick Start guide | [https://socktop.io/assets/docs/installation/quick-start.html](https://socktop.io/assets/docs/installation/quick-start.html) |
| Prereqs | [https://socktop.io/assets/docs/installation/prerequisites.html](https://socktop.io/assets/docs/installation/prerequisites.html) |
| APT Install | [https://socktop.io/assets/docs/installation/apt.html](https://socktop.io/assets/docs/installation/apt.html) |
| Cargo Install | [https://socktop.io/assets/docs/installation/cargo.html](https://socktop.io/assets/docs/installation/cargo.html)
| Usage | [https://socktop.io/assets/docs/usage/general.html](https://socktop.io/assets/docs/usage/general.html)
| Auth Setup | [https://socktop.io/assets/docs/security/token.html](https://socktop.io/assets/docs/security/token.html) |
| TLS Setup | [https://socktop.io/assets/docs/security/tls.html](https://socktop.io/assets/docs/security/tls.html) |
| Monitoring Multiple Hosts | [tmux](https://socktop.io/assets/docs/advanced/tmux.html) / [zellij](https://socktop.io/assets/docs/advanced/zellij.html) |
--- ---
## Features ## Platform Support
- Remote monitoring via WebSocket (JSON over WS) Linux (all flavors), ARM/Raspberry Pi (32b/64b), MacOS, Windows, RISC-V (experimental)
- Optional WSS (TLS): agent autogenerates a selfsigned cert on first run; client pins the cert via --tls-ca/-t
- TUI built with ratatui
- CPU
- Overall sparkline + per-core mini bars
- Accurate per-process CPU% (Linux /proc deltas), normalized to 0100%
- Memory/Swap gauges with human units
- Disks: per-device usage
- Network: per-interface throughput with sparklines and peak markers
- Temperatures: CPU (optional)
- Top processes (top 50)
- PID, name, CPU%, memory, and memory%
- Click-to-sort by CPU% or Mem (descending)
- Scrollbar and mouse/keyboard scrolling
- Total process count shown in the header
- Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled)
- Optional auth token for the agent
- 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))
--- ---
## Prerequisites: Install Rust (rustup) ## Contributing
Rust is fast, safe, and crossplatform. Installing it will make your machine better. Consider yourself privileged. Contributions are welcome and you have the freedom to use whatever development tools you would like, as long as there is a human in the loop and all the clippy and unit tests pass you are good to submit a PR. Defects / Bugs just go ahead and fix and file a PR. New features, please create a issue in advance and let me know you are offering to build it. I don't want to be in a position where you worked for a couple of weeks on something and I don't want to merge it.
Linux/macOS: ### Development
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# load cargo for this shell
source "$HOME/.cargo/env"
# ensure stable is up to date
rustup update stable
rustc --version
cargo --version
# after install you may need to reload your shell, e.g.:
exec bash # or: exec zsh / exec fish
```
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 for GPU support)
**Note:** GPU monitoring is only supported on x86_64 and aarch64 (64-bit ARM) platforms. ARMv7 (32-bit) and RISC-V builds do not include GPU support.
For 64-bit systems with GPU support:
```bash
sudo apt-get update
sudo apt-get install libdrm-dev libdrm-amdgpu1
```
For ARMv7 (32-bit Raspberry Pi), build with `--no-default-features` to disable GPU support:
```bash
cargo build --release -p socktop_agent --no-default-features
```
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
---
## Architecture
Two components:
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
---
## Quick start
- Build both binaries:
```bash
git clone https://github.com/jasonwitty/socktop.git
cd socktop
cargo build --release
```
- Start the agent on the target machine (default port 3000):
```bash
./target/release/socktop_agent --port 3000
```
- Connect with the TUI from your local machine:
```bash
./target/release/socktop ws://REMOTE_HOST:3000/ws
```
### Cross-compiling for Raspberry Pi
For Raspberry Pi and other ARM devices, you can cross-compile the agent from a more powerful machine:
- [Cross-compilation guide](./docs/cross-compiling.md) - Instructions for cross-compiling from Linux, macOS, or Windows hosts
### Quick demo (no agent setup)
Spin up a temporary local agent on port 3231 and connect automatically:
```bash
socktop --demo
```
Or just run `socktop` with no arguments and pick the builtin `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
- Runs locally (`ws://127.0.0.1:3231/ws`)
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
---
## Install (from crates.io)
You dont need to clone this repo to use socktop. Install the published binaries with cargo:
```bash
# TUI (client)
cargo install socktop
# Agent (server)
cargo install socktop_agent
```
This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
Notes:
- After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
- Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldnt. Be brave.
System-wide agent (Linux)
```bash
# If you installed with cargo, binaries are in ~/.cargo/bin
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
# Install and enable the systemd service (example unit in docs/)
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now socktop-agent
```
```bash
# Enable SSL
# Stop service
sudo systemctl stop socktop-agent
# Edit service to append SSL option and port
sudo micro /etc/systemd/system/socktop-agent.service
--
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
--
# Reload
sudo systemctl daemon-reload
# Restart
sudo systemctl start socktop-agent
# check logs for certificate location
sudo journalctl -u socktop-agent -f
--
Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
--
```
---
## Usage
Agent (server):
```bash
socktop_agent --port 3000
# or env: SOCKTOP_PORT=3000 socktop_agent
# optional auth: SOCKTOP_TOKEN=changeme socktop_agent
# enable TLS (selfsigned cert, default port 8443; you can also use -p):
socktop_agent --enableSSL --port 8443
```
Client (TUI):
```bash
socktop ws://HOST:3000/ws
# with token:
socktop "ws://HOST:3000/ws?token=changeme"
# TLS with pinned server certificate (recommended over the internet):
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# (By default hostname/SAN verification is skipped for ease on home networks. To enforce it add --verify-hostname)
socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
# force the small-window layout at any terminal size (normally automatic):
socktop --compact ws://HOST:3000/ws
```
Intervals (client-driven):
- Fast metrics: ~500 ms
- Processes: ~2 s
- Disks: ~5 s
The agent stays idle unless queried. When queried, it collects just whats needed.
---
## Compact mode
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
are the first things to collapse — exactly the panes you are most likely watching. Once
the window is too short for the Disks pane to show even one disk, socktop switches to a
compact layout:
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
- **Memory and Swap move side by side** into the row Disks vacated.
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
with no GPU the pane disappears entirely.
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
below the size where they used to vanish.
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
layout at any window size:
```bash
socktop --compact ws://HOST:3000/ws
```
---
## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
Config file location:
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
### Creating a profile
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
```bash
socktop --profile prod ws://prod-host:3000/ws
# With TLS pinning:
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
You can also set custom intervals (milliseconds):
```bash
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
```
```
If a profile already exists you will be prompted before overwriting:
```
$ socktop --profile prod ws://new-host:3000/ws
Overwrite existing profile 'prod'? [y/N]: y
```
To overwrite without an interactive prompt pass `--save`:
```bash
socktop --profile prod --save ws://new-host:3000/ws
```
### Using a saved profile
Just pass the profile name (no URL needed):
```bash
socktop --profile prod
socktop -P prod-tls # short flag
```
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
### Interactive selection (no args)
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
```
$ socktop
Select profile:
1. prod
2. prod-tls
Enter number (or blank to abort): 2
```
Choosing a number starts the TUI with that profile. A builtin `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
### JSON format
An example `profiles.json` (prettyprinted):
```json
{
"profiles": {
"prod": { "url": "ws://prod-host:3000/ws" },
"prod-tls": {
"url": "wss://prod-host:8443/ws",
"tls_ca": "/home/user/certs/prod-cert.pem",
"metrics_interval_ms": 500,
"processes_interval_ms": 2000
}
},
"version": 0
}
```
Notes:
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
- Profiles are client-side convenience only; they do not affect the agent.
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
---
## Updating
Update the agent (systemd):
```bash
# on the server running the agent
cargo install socktop_agent --force
sudo systemctl stop socktop-agent
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
# if you changed the unit file:
# sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
# sudo systemctl daemon-reload
sudo systemctl start socktop-agent
sudo systemctl status socktop-agent --no-pager
# logs:
# journalctl -u socktop-agent -f
```
Update the TUI (client):
```bash
cargo install socktop --force
socktop ws://HOST:3000/ws
```
Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
---
## Configuration (agent)
- Port:
- Flag: --port 8080 or -p 8080
- Positional: socktop_agent 8080
- Env: SOCKTOP_PORT=8080
- TLS (selfsigned):
- Enable: --enableSSL
- Default TLS port: 8443 (override with --port/-p)
- Certificate/Key location (created on first TLS run):
- Linux (XDG): $XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem} (defaults to ~/.config)
- The agent prints these paths on creation.
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
- Additional SANs: set `SOCKTOP_AGENT_EXTRA_SANS` (commaseparated) before first TLS start to include extra IPs/DNS names in the cert. Example:
```bash
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL
```
This prevents client errors like `NotValidForName` when connecting via an IP not present in the default cert SAN list.
- Expiry / rotation: the generated cert is valid for ~397 days from creation. If the agent fails to start with an "ExpiredCertificate" error (or your client reports expiry), simply delete the existing cert and key:
```bash
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
# (adjust path if XDG_CONFIG_HOME is set or different user)
systemctl restart socktop-agent # if running under systemd
```
On next TLS start the agent will generate a fresh pair. Only distribute the new cert.pem to clients (never the key).
- Auth token (optional): SOCKTOP_TOKEN=changeme
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
---
## Keyboard & Mouse
- Quit: q or Esc
- Processes pane:
- Click “CPU %” to sort by CPU descending
- Click “Mem” to sort by memory descending
- Mouse wheel: scroll
- Drag scrollbar: scroll
- Arrow/PageUp/PageDown/Home/End: scroll
---
## Example agent JSON
```json
{
"sampled_at_ms": 1786752000123,
"cpu_total": 12.4,
"cpu_per_core": [11.2, 15.7],
"mem_total": 33554432,
"mem_used": 18321408,
"swap_total": 0,
"swap_used": 0,
"process_count": 127,
"hostname": "myserver",
"cpu_temp_c": 42.5,
"disks": [{"name":"nvme0n1p2","total":512000000000,"available":320000000000}],
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
"top_processes": [
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
],
"gpus": null
}
```
Notes:
- process_count is merged into the main metrics on the client when processes are polled.
- top_processes are the current top 50 (sorting in the TUI is client-side).
---
## Security
Set a token on the agent and pass it as a query param from the client:
Server:
```bash
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
```
Client:
```bash
socktop "ws://HOST:3000/ws?token=changeme"
```
### TLS / WSS
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
Server (generates selfsigned cert and key on first run):
```bash
socktop_agent --enableSSL --port 8443
```
Client (trust/pin the server cert; copy cert.pem from the agent):
```bash
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
```
Notes:
- Do not copy the private key off the server; only the cert.pem is needed by clients.
- When --tls-ca/-t is supplied, the client autoupgrades ws:// to wss:// to avoid protocol mismatch.
- Hostname (SAN) verification is DISABLED by default; instead the client PINS the certificate: the agent must present a cert byte-identical to one in your `--tls-ca` file (expiry is ignored in this mode — you pinned that exact cert). Use `--verify-hostname` to switch to strict chain + SAN validation instead.
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
---
## Using tmux to monitor multiple hosts
You can use tmux to show multiple socktop instances in a single terminal.
![socktop screenshot](./docs/tmux_4_rpis_v3.jpg)
monitoring 4 Raspberry Pis using Tmux
Prerequisites:
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
Key bindings (defaults):
- Split left/right: Ctrl-b %
- Split top/bottom: Ctrl-b "
- Move between panes: Ctrl-b + Arrow keys
- Show pane numbers: Ctrl-b q
- Close a pane: Ctrl-b x
- Detach from session: Ctrl-b d
Two panes (left/right)
- This creates a session named "socktop", splits it horizontally, and starts two socktops.
```bash
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
split-window -h 'socktop ws://HOST2:3000/ws' \; \
select-layout even-horizontal \; \
attach
```
Four panes (top-left, top-right, bottom-left, bottom-right)
- This creates a 2x2 grid with one socktop per pane.
```bash
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
split-window -h 'socktop ws://HOST2:3000/ws' \; \
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
select-layout tiled \; \
attach
```
Tips:
- Replace HOST1..HOST4 (and ports) with your targets.
- Reattach later: `tmux attach -t socktop`
---
## Platform notes
- Linux: fully supported (agent and client).
- Raspberry Pi:
- 64-bit: aarch64-unknown-linux-gnu
- 32-bit: armv7-unknown-linux-gnueabihf
- Windows:
- TUI + agent can build with stable Rust; bring your own MSVC. Youre on Windows; you know the drill.
- CPU temperature may be unavailable.
- binary exe for both available in build artifacts under actions.
- macOS:
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
---
## Development
```bash ```bash
cargo fmt cargo fmt
@@ -557,35 +45,11 @@ cargo run -p socktop_agent -- --enableSSL --port 8443
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`. A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
Enable it (one-time): Enable it (one-time):
```bash
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
```
Every commit will then format Rust sources and restage them automatically.
---
## Roadmap
- [x] Agent authentication (token)
- [x] Hide per-thread entries; only show processes
- [x] Sort top processes in the TUI
- [x] Configurable refresh intervals (client)
- [ ] Export metrics to file
- [x] TLS / WSS support (selfsigned server cert + client pinning)
- [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
- [ ] Outage notifications and reconnect.
- [ ] Per process detailed statistics pane
- [ ] cleanup of Disks section, properly display physical disks / partitions, remove duplicate entries
--- ---
## License ## License
MIT — see LICENSE. MIT — see [LICENSE](LICENSE).
---
## Acknowledgements ## Acknowledgements
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 MiB

+5 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "socktop" name = "socktop"
version = "1.60.0" version = "1.60.2"
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"
@@ -11,7 +11,7 @@ repository = "https://github.com/jasonwitty/socktop"
[dependencies] [dependencies]
# socktop connector for agent communication # socktop connector for agent communication
socktop_connector = { version = "1.60.0", path = "../socktop_connector" } socktop_connector = { version = "1.60.1", path = "../socktop_connector" }
tokio = { workspace = true } tokio = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
@@ -22,6 +22,9 @@ ratatui = { workspace = true }
crossterm = { workspace = true } crossterm = { workspace = true }
unicode-width = { workspace = true } unicode-width = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
# Local process signalling only (src/proc_kill.rs). The TUI never gathers its
# own metrics — everything on screen comes from the agent over the connector.
sysinfo = { workspace = true }
dirs-next = { workspace = true } dirs-next = { workspace = true }
[dev-dependencies] [dev-dependencies]
+794 -10
View File
@@ -20,6 +20,7 @@ use ratatui::{
use tokio::time::{sleep, timeout}; use tokio::time::{sleep, timeout};
use crate::history::{PerCoreHistory, push_capped}; use crate::history::{PerCoreHistory, push_capped};
use crate::proc_kill::{KillSignal, kill_local_process};
use crate::retry::{RetryTiming, compute_retry_timing}; use crate::retry::{RetryTiming, compute_retry_timing};
use crate::types::Metrics; use crate::types::Metrics;
use crate::ui::cpu::{ use crate::ui::cpu::{
@@ -51,6 +52,21 @@ 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;
/// Floor for the post-kill forced refresh delay: just past the agent's
/// DEFAULT `Processes` cache TTL of 1500ms, so the answer reflects the kill
/// instead of the cached snapshot taken before it. The effective delay scales
/// with the user's processes interval — see [`App::proc_refresh_settle`].
const PROC_CACHE_SETTLE_FLOOR: Duration = Duration::from_millis(1_600);
/// Margin a tombstone outlives the settle window by. With default intervals
/// this reproduces the original fixed 5s tombstone (1.6s + 3.4s).
const TOMBSTONE_MARGIN: Duration = Duration::from_millis(3_400);
/// How long to keep re-checking a signalled process for its exit. Long enough
/// to cover a slow shutdown, short enough that a process which plainly ignored
/// the signal keeps its row.
const KILL_WATCH_FOR: Duration = Duration::from_secs(5);
/// Budget for one request/response round trip. Replies are matched to /// Budget for one request/response round trip. Replies are matched to
/// requests by order, so a request that never answers would otherwise hang /// requests by order, so a request that never answers would otherwise hang
/// `ws.next()` forever and freeze the TUI (raw mode even eats Ctrl+C). /// `ws.next()` forever and freeze the TUI (raw mode even eats Ctrl+C).
@@ -136,6 +152,15 @@ pub struct App {
procs_row_peak_cpu: f32, procs_row_peak_cpu: f32,
last_procs_poll: Instant, last_procs_poll: Instant,
/// When set, the next metrics tick polls processes regardless of the
/// regular cadence. Used after a kill — see refresh_after_kill.
procs_refresh_due_at: Option<Instant>,
/// PIDs we have signalled, with the instant we stop watching for their
/// exit. Re-checked each metrics tick — see poll_kill_watch.
kill_watch: Vec<(u32, Instant)>,
/// PIDs confirmed gone after a signal, kept briefly so the agent's cached
/// process list cannot put them back on screen.
killed_gone: Vec<(u32, Instant)>,
last_disks_poll: Instant, last_disks_poll: Instant,
procs_interval: Duration, procs_interval: Duration,
disks_interval: Duration, disks_interval: Duration,
@@ -153,6 +178,10 @@ pub struct App {
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 max_process_mem_bytes: u64, // Maximum memory usage observed for current process 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 pub process_details_unsupported: bool, // Track if agent doesn't support process details
/// The agent has successfully answered at least one details request this
/// session. Distinguishes "this agent is too old" from "that process is
/// gone", which arrive over the wire as the same error.
process_details_answered: bool,
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,
@@ -165,6 +194,14 @@ pub struct App {
// Security / status flags // Security / status flags
pub is_tls: bool, pub is_tls: bool,
pub has_token: bool, pub has_token: bool,
// Whether the local process-kill feature (t = SIGTERM, k = SIGKILL) is
// available: the connected agent is on this machine AND no policy override
// (--no-kill / SOCKTOP_NO_KILL) has disabled it.
pub kill_enabled: bool,
// Pending kill awaiting confirmation: (pid, process name). Which signal is
// sent depends on the button chosen in the confirmation modal, so it isn't
// decided until then.
pending_kill: Option<(u32, String)>,
// --compact: pin the compact layout regardless of window size. Without it the // --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. // layout switches on its own once the window is too short for the Disks pane.
@@ -222,6 +259,9 @@ impl App {
procs_filter_dirty: true, procs_filter_dirty: true,
procs_row_cache: Vec::new(), procs_row_cache: Vec::new(),
procs_row_peak_cpu: 0.0, procs_row_peak_cpu: 0.0,
procs_refresh_due_at: None,
kill_watch: Vec::new(),
killed_gone: Vec::new(),
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
@@ -242,6 +282,7 @@ impl App {
last_io_write_bytes: None, last_io_write_bytes: None,
max_process_mem_bytes: 0, max_process_mem_bytes: 0,
process_details_unsupported: false, process_details_unsupported: false,
process_details_answered: 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))
.unwrap_or_else(Instant::now), .unwrap_or_else(Instant::now),
@@ -255,6 +296,8 @@ impl App {
verify_hostname: false, verify_hostname: false,
is_tls: false, is_tls: false,
has_token: false, has_token: false,
kill_enabled: false,
pending_kill: None,
force_compact: false, force_compact: false,
header_title: String::new(), header_title: String::new(),
header_intervals_text: String::new(), header_intervals_text: String::new(),
@@ -306,6 +349,233 @@ impl App {
self self
} }
/// Enable the local process-kill feature. Only set true when the agent has
/// been verified to be on this machine (see [`crate::local`]) and no
/// policy override (`--no-kill`, `SOCKTOP_NO_KILL`) forbids it.
pub fn with_kill_enabled(mut self, kill_enabled: bool) -> Self {
self.kill_enabled = kill_enabled;
self
}
/// Look up the display name of a process by PID. Prefers the details
/// payload, which is the only source that has a name for a process not in
/// the top-N list — e.g. after walking up to a parent from the details
/// modal.
fn process_name_for_pid(&self, pid: u32) -> Option<String> {
if let Some(details) = self
.process_details
.as_ref()
.filter(|d| d.process.pid == pid)
{
return Some(details.process.name.clone());
}
self.last_metrics
.as_ref()?
.top_processes
.iter()
.find(|p| p.pid == pid)
.map(|p| p.name.clone())
}
/// Raise the kill confirmation for `pid`. No-op unless the kill feature is
/// enabled — the same gate the keybinding uses, repeated here because this
/// is also reachable from the details modal.
fn prompt_kill(&mut self, pid: u32) {
if !self.kill_enabled {
return;
}
let name = self
.process_name_for_pid(pid)
.unwrap_or_else(|| "process".to_string());
self.modal_manager.push_modal(ModalType::Confirmation {
title: "Confirm signal".to_string(),
message: format!("Send a signal to {name} (PID {pid})?"),
confirm_text: "Terminate".to_string(),
cancel_text: "Cancel".to_string(),
});
self.pending_kill = Some((pid, name));
}
/// Signal the process the confirmation was raised for, then report the
/// outcome. Pops the confirmation first so the result lands on top of
/// whatever was underneath it (the process list, or the details modal).
fn run_pending_kill(&mut self, signal: KillSignal) {
let Some((pid, name)) = self.pending_kill.take() else {
return;
};
self.modal_manager.pop_modal();
// The name shown in the confirmation doubles as the reuse guard: if
// the PID has been recycled since, the kill is refused. The "process"
// fallback from prompt_kill means "name unknown" — no guard possible.
let expected = (name != "process").then_some(name.as_str());
let (title, message) = match kill_local_process(pid, expected, signal) {
Ok(()) => {
self.refresh_after_kill(pid);
(
"Signal sent".to_string(),
format!("Sent {} to {name} (PID {pid}).", signal.label()),
)
}
Err(e) => ("Signal failed".to_string(), e),
};
self.modal_manager
.push_modal(ModalType::Info { title, message });
}
/// Bring the process list back in step with reality after a signal.
///
/// A single check at signal time is not enough, which is what the first
/// version got wrong: SIGTERM is a *request*, so the process is usually
/// still alive for the few hundred milliseconds it takes to wind down. The
/// row therefore stayed put, and the list looked like the kill had done
/// nothing.
///
/// So the PID goes on a watch list, re-checked every metrics tick until it
/// exits (or the watch expires). Confirmed-gone PIDs are also remembered
/// briefly — see `killed_gone` — because the agent serves `Processes` from
/// a 1500ms cache and would otherwise hand back a snapshot taken before
/// the kill and put the row straight back.
fn refresh_after_kill(&mut self, pid: u32) {
self.kill_watch.retain(|(p, _)| *p != pid);
self.kill_watch.push((pid, Instant::now() + KILL_WATCH_FOR));
// Check once right now: SIGKILL, and anything already exiting, is gone
// by the time the confirmation is dismissed.
self.poll_kill_watch();
self.procs_refresh_due_at = Some(Instant::now() + self.proc_refresh_settle());
}
/// How long the post-kill forced refresh waits, and the base of the
/// tombstone lifetime. Scales with the user's processes interval: someone
/// who raised the agent's Processes TTL will have raised their client
/// interval to match (there is no point polling faster than the cache),
/// so the interval is the best client-side signal for how stale an agent
/// snapshot can be. Never below the default-TTL floor.
fn proc_refresh_settle(&self) -> Duration {
PROC_CACHE_SETTLE_FLOOR.max(self.procs_interval)
}
/// How long a confirmed-dead PID is remembered, so a cached agent snapshot
/// taken before the kill cannot resurrect its row. Must outlive the settle
/// window plus one round trip, hence settle + margin.
fn kill_tombstone_for(&self) -> Duration {
self.proc_refresh_settle() + TOMBSTONE_MARGIN
}
/// Re-check the processes we have signalled and retire the rows of any that
/// have since exited. Cheap: one `/proc` lookup per watched PID, and the
/// list is almost always empty.
fn poll_kill_watch(&mut self) {
if self.kill_watch.is_empty() {
return;
}
let now = Instant::now();
let mut gone = Vec::new();
self.kill_watch.retain(|(pid, deadline)| {
if !crate::proc_kill::process_exists(*pid) {
gone.push(*pid);
return false;
}
// Still alive. Keep watching until the deadline — a process that
// ignores the signal outright should keep its row.
now < *deadline
});
for pid in gone {
self.forget_process_row(pid);
// Nothing left to show details for. Also matters mechanically: the
// details poll keys off the selection, which forget_process_row
// just cleared, so leaving the modal open would freeze it on the
// dead process's last sample.
self.close_details_for_gone_process(pid);
self.killed_gone.push((pid, now));
}
let tombstone_for = self.kill_tombstone_for();
self.killed_gone
.retain(|(_, at)| now.duration_since(*at) < tombstone_for);
}
/// Drop rows for processes we have confirmed dead. Applied to every process
/// list the agent sends, because its cached snapshot can predate the kill.
fn drop_tombstoned_rows(&mut self) {
if self.killed_gone.is_empty() {
return;
}
let now = Instant::now();
let tombstone_for = self.kill_tombstone_for();
self.killed_gone
.retain(|(_, at)| now.duration_since(*at) < tombstone_for);
let pids: Vec<u32> = self.killed_gone.iter().map(|(p, _)| *p).collect();
for pid in pids {
self.forget_process_row(pid);
}
}
/// Give up a selection whose process is no longer in the list. `Processes`
/// carries every process, not a top-N window, so a PID that is absent has
/// genuinely gone — and a selection pointing at it means the hint offers to
/// kill a corpse and `t` reports "no longer exists".
fn drop_vanished_selection(&mut self) {
let Some(pid) = self.selected_process_pid else {
return;
};
let present = self
.last_metrics
.as_ref()
.is_some_and(|m| m.top_processes.iter().any(|p| p.pid == pid));
if !present {
self.selected_process_pid = None;
self.selected_process_index = None;
}
}
/// Close the details view for a process that no longer exists, and drop the
/// data collected for it.
///
/// A parent-navigation chain can leave another details view underneath
/// (child → P → parent killed): the resurfacing view must resume polling,
/// so retarget the selection to it — the same thing SwitchToParentProcess
/// does on the way down. Without this the child view came back with no
/// selection (forget_process_row had just cleared it) and wiped data, and
/// the selection-gated details poll never refilled it: a frozen, orphaned
/// window.
fn close_details_for_gone_process(&mut self, pid: u32) {
if self.modal_manager.close_process_details(pid) {
self.clear_process_details();
if let Some(next_pid) = self.modal_manager.topmost_process_details() {
self.selected_process_pid = Some(next_pid);
// Fire the details poll on the next tick rather than waiting
// out the interval.
self.last_process_details_poll = Instant::now()
.checked_sub(self.process_details_interval)
.unwrap_or_else(Instant::now);
}
}
}
/// Drop a process from the cached view without waiting for the agent, and
/// give up any selection pointing at it — a hint offering to kill a process
/// that no longer exists is worse than no hint.
fn forget_process_row(&mut self, pid: u32) {
let Some(m) = self.last_metrics.as_mut() else {
return;
};
let before = m.top_processes.len();
m.top_processes.retain(|p| p.pid != pid);
if m.top_processes.len() == before {
return; // wasn't on screen; nothing to reconcile
}
// Keep the header's "(N total)" honest until the next real poll.
m.process_count = m.process_count.map(|c| c.saturating_sub(1));
if self.selected_process_pid == Some(pid) {
self.selected_process_pid = None;
self.selected_process_index = None;
}
self.invalidate_procs_filter();
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);
}
}
/// Show a connection error modal /// Show a connection error modal
pub fn show_connection_error(&mut self, message: String) { pub fn show_connection_error(&mut self, message: String) {
if !self.modal_manager.is_active() { if !self.modal_manager.is_active() {
@@ -757,18 +1027,47 @@ impl App {
continue; // Skip normal key processing continue; // Skip normal key processing
} }
ModalAction::Cancel | ModalAction::Dismiss => { ModalAction::Cancel | ModalAction::Dismiss => {
// If ProcessDetails modal was dismissed, clear the data to save resources // If a ProcessDetails view is what we landed on,
if let Some(crate::ui::modal::ModalType::ProcessDetails { // clear the stale data AND point the poll at it —
.. // Esc-ing back from a parent view otherwise left
}) = self.modal_manager.current_modal() // the selection on the parent, refilling the
// child-titled view with the parent's data.
if let Some(crate::ui::modal::ModalType::ProcessDetails { pid }) =
self.modal_manager.current_modal()
{ {
let pid = *pid;
self.clear_process_details(); self.clear_process_details();
self.selected_process_pid = Some(pid);
self.last_process_details_poll = Instant::now()
.checked_sub(self.process_details_interval)
.unwrap_or_else(Instant::now);
} }
// Abandon any pending kill the user backed out of.
self.pending_kill = None;
// Modal was dismissed, skip normal key processing // Modal was dismissed, skip normal key processing
continue; continue;
} }
ModalAction::Confirm => { ModalAction::Confirm => {
// Handle confirmation action here if needed in the future // The only confirmation in the app is the
// process-kill prompt; Confirm is the polite
// signal, ConfirmForce the forceful one.
if self.pending_kill.is_some() {
self.run_pending_kill(KillSignal::Term);
continue;
}
}
ModalAction::ConfirmForce => {
if self.pending_kill.is_some() {
self.run_pending_kill(KillSignal::Kill);
continue;
}
}
ModalAction::KillSelected(pid) => {
// `t` from inside the details modal. The
// confirmation stacks on top of it, so
// cancelling returns to the details view.
self.prompt_kill(pid);
continue;
} }
ModalAction::SwitchToParentProcess(_current_pid) => { ModalAction::SwitchToParentProcess(_current_pid) => {
// Get parent PID from current process details // Get parent PID from current process details
@@ -880,6 +1179,20 @@ impl App {
self.modal_manager.push_modal(ModalType::Help); self.modal_manager.push_modal(ModalType::Help);
} }
// Kill the selected process — local agents only. `t` is the
// one kill key everywhere: `k` scrolls the thread table in
// the details modal so it could not be reused there, and one
// key for both entry points is one thing to remember.
// SIGTERM vs SIGKILL is chosen in the confirmation modal.
if self.kill_enabled
&& !self.modal_manager.is_active()
&& matches!(k.code, KeyCode::Char('t') | KeyCode::Char('T'))
&& let Some(pid) = self.selected_process_pid
{
self.prompt_kill(pid);
continue;
}
// 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);
@@ -1117,8 +1430,21 @@ impl App {
self.consecutive_request_timeouts = 0; self.consecutive_request_timeouts = 0;
self.update_with_metrics(m); self.update_with_metrics(m);
// Only poll processes every 2s // A process signalled a moment ago may have exited
if self.last_procs_poll.elapsed() >= self.procs_interval { // since. Checked here, on every tick, so its row goes
// as soon as it is actually gone rather than at the
// next full process poll.
self.poll_kill_watch();
// Only poll processes every 2s — unless a kill asked for
// a refresh, which jumps the queue.
let forced = self
.procs_refresh_due_at
.is_some_and(|due| Instant::now() >= due);
if forced || self.last_procs_poll.elapsed() >= self.procs_interval {
if forced {
self.procs_refresh_due_at = None;
}
let mut updated = false; let mut updated = false;
match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Processes)) match timeout(REQUEST_TIMEOUT, ws.request(AgentRequest::Processes))
.await .await
@@ -1148,6 +1474,14 @@ impl App {
&mut self.procs_row_cache, &mut self.procs_row_cache,
); );
} }
// The agent's snapshot can predate a kill by up
// to its cache TTL, so strip anything we already
// know is gone before it reaches the screen.
self.drop_tombstoned_rows();
// And a selection whose process is no longer in
// the list would otherwise still be the target
// of `t`.
self.drop_vanished_selection();
} }
self.last_procs_poll = Instant::now(); self.last_procs_poll = Instant::now();
} }
@@ -1252,11 +1586,44 @@ impl App {
self.process_details = Some(details); self.process_details = Some(details);
self.process_details_unsupported = false; self.process_details_unsupported = false;
// This agent demonstrably answers
// details requests, which is what
// lets the error arm below read a
// later failure as "that process is
// gone" rather than "old agent".
self.process_details_answered = true;
} }
Ok(Err(_)) => { Ok(Err(_)) => {
// Agent responded with an error: endpoint // An error reply means one of two very
// not supported. // different things, and the wire cannot
self.process_details_unsupported = true; // tell them apart: the agent lacks the
// endpoint, or this PID is gone (the
// agent sends {"error":"Process N not
// found"}, which fails to deserialize
// and arrives here identically).
//
// If the agent has already answered a
// details request this session, the
// endpoint plainly works, so the PID is
// the problem — close the view instead
// of claiming the agent needs updating.
//
// Unless the process is still in the
// agent's own list: then this error is a
// transient (socket blip, torn frame),
// not a death — keep the view and let
// the next poll retry.
if self.process_details_answered {
let still_listed =
self.last_metrics.as_ref().is_some_and(|m| {
m.top_processes.iter().any(|p| p.pid == pid)
});
if !still_listed {
self.close_details_for_gone_process(pid);
}
} else {
self.process_details_unsupported = true;
}
} }
Err(_) => { Err(_) => {
// No reply at all: old agents IGNORE // No reply at all: old agents IGNORE
@@ -1583,6 +1950,7 @@ impl App {
filtered_indices: &self.procs_filtered, filtered_indices: &self.procs_filtered,
cached_rows: &self.procs_row_cache, cached_rows: &self.procs_row_cache,
peak_cpu: self.procs_row_peak_cpu, peak_cpu: self.procs_row_peak_cpu,
kill_enabled: self.kill_enabled,
}, },
); );
@@ -1603,6 +1971,7 @@ impl App {
}, },
max_mem_bytes: self.max_process_mem_bytes, max_mem_bytes: self.max_process_mem_bytes,
unsupported: self.process_details_unsupported, unsupported: self.process_details_unsupported,
kill_enabled: self.kill_enabled,
}, },
); );
} }
@@ -1614,3 +1983,418 @@ impl Default for App {
Self::new() Self::new()
} }
} }
#[cfg(test)]
mod kill_refresh_tests {
use super::*;
use socktop_connector::{Metrics, ProcessInfo};
fn proc(pid: u32, name: &str) -> ProcessInfo {
ProcessInfo {
pid,
name: name.into(),
cpu_usage: 1.0,
mem_bytes: 1_000,
}
}
fn app_with(pids: &[u32]) -> App {
let mut app = App::new();
app.last_metrics = Some(Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: pids.iter().map(|p| proc(*p, "victim")).collect(),
gpus: None,
process_count: Some(pids.len()),
});
app
}
#[test]
fn dropping_a_row_updates_the_list_count_and_selection() {
let mut app = app_with(&[1, 2, 3]);
app.selected_process_pid = Some(2);
app.selected_process_index = Some(1);
app.forget_process_row(2);
let m = app.last_metrics.as_ref().unwrap();
assert_eq!(
m.top_processes.iter().map(|p| p.pid).collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(m.process_count, Some(2), "header count went stale");
assert_eq!(
app.selected_process_pid, None,
"selection still points at a dead process"
);
assert_eq!(app.selected_process_index, None);
}
/// A process that was never on screen (outside the top-N) must not decrement
/// the total or disturb the selection.
#[test]
fn dropping_an_offscreen_row_changes_nothing() {
let mut app = app_with(&[1, 2, 3]);
app.selected_process_pid = Some(1);
app.forget_process_row(999);
let m = app.last_metrics.as_ref().unwrap();
assert_eq!(m.top_processes.len(), 3);
assert_eq!(m.process_count, Some(3));
assert_eq!(app.selected_process_pid, Some(1));
}
/// A dead process disappears immediately, and a refresh is still scheduled
/// so the agent's own view catches up past its cache TTL.
#[test]
fn a_confirmed_dead_process_leaves_at_once() {
let mut child = std::process::Command::new("true").spawn().expect("spawn");
let pid = child.id();
child.wait().expect("reap");
let mut app = app_with(&[pid, 4242]);
app.refresh_after_kill(pid);
let m = app.last_metrics.as_ref().unwrap();
assert_eq!(
m.top_processes.iter().map(|p| p.pid).collect::<Vec<_>>(),
vec![4242],
"a process known to be gone should not still be listed"
);
assert!(app.procs_refresh_due_at.is_some(), "no refresh scheduled");
}
/// A process that survived the signal keeps its row — better a row that is
/// still true than one that vanishes and comes back.
#[test]
fn a_surviving_process_keeps_its_row_until_the_refresh() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn");
let pid = child.id();
let mut app = app_with(&[pid]);
app.refresh_after_kill(pid);
let listed = app
.last_metrics
.as_ref()
.unwrap()
.top_processes
.iter()
.any(|p| p.pid == pid);
let _ = child.kill();
let _ = child.wait();
assert!(listed, "row for a live process was removed optimistically");
assert!(app.procs_refresh_due_at.is_some());
}
/// The scheduled refresh must land after the agent's process cache TTL,
/// or it just re-reads the pre-kill snapshot.
#[test]
fn the_forced_refresh_waits_out_the_agent_cache() {
assert!(
PROC_CACHE_SETTLE_FLOOR >= Duration::from_millis(1_500),
"agent serves Processes from a 1500ms cache by default"
);
}
/// Users who raise the agent's Processes TTL raise the client interval to
/// match, so the settle window (and the tombstone that must outlive it)
/// scales with the interval instead of assuming the default TTL.
#[test]
fn settle_and_tombstone_scale_with_the_processes_interval() {
// The default processes interval is 2s, which already exceeds the
// 1.6s floor — so the default settle is the interval itself.
let mut app = App::new();
assert_eq!(app.proc_refresh_settle(), Duration::from_secs(2));
app = app.with_intervals(None, Some(10_000));
assert_eq!(app.proc_refresh_settle(), Duration::from_secs(10));
assert!(app.kill_tombstone_for() > app.proc_refresh_settle());
// A tiny interval never drops the settle below the default-TTL floor.
app = app.with_intervals(None, Some(200));
assert_eq!(app.proc_refresh_settle(), PROC_CACHE_SETTLE_FLOOR);
}
}
#[cfg(test)]
mod parent_chain_tests {
use super::*;
use crate::ui::modal::ModalType;
/// Kill a parent reached via P-navigation: the child's view resurfaces and
/// must resume polling. Reported as: "I can still see the orphaned window
/// if I open a process, hit P, then terminate that process with t".
#[test]
fn killing_a_navigated_to_parent_retargets_the_child_view() {
let mut app = App::new();
let (child, parent) = (200u32, 100u32);
app.modal_manager
.push_modal(ModalType::ProcessDetails { pid: child });
app.modal_manager
.push_modal(ModalType::ProcessDetails { pid: parent });
// The kill flow stacks the "Signal sent" Info on top, and the watch
// usually confirms the death while it is still up.
app.modal_manager.push_modal(ModalType::Info {
title: "Signal sent".into(),
message: "Sent SIGTERM".into(),
});
// forget_process_row has already cleared the selection by this point.
app.selected_process_pid = None;
app.close_details_for_gone_process(parent);
assert_eq!(
app.selected_process_pid,
Some(child),
"resurfaced child view has no selection: its poll never runs and \
the window sits frozen"
);
assert_eq!(app.modal_manager.topmost_process_details(), Some(child));
assert!(
app.last_process_details_poll.elapsed() >= app.process_details_interval,
"poll should be due immediately"
);
}
}
#[cfg(test)]
mod details_close_tests {
use super::*;
use crate::ui::modal::ModalType;
use socktop_connector::{Metrics, ProcessInfo};
fn app_viewing(pid: u32) -> App {
let mut app = App::new();
app.last_metrics = Some(Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid,
name: "victim".into(),
cpu_usage: 1.0,
mem_bytes: 1_000,
}],
gpus: None,
process_count: Some(1),
});
app.selected_process_pid = Some(pid);
app.selected_process_index = Some(0);
app.modal_manager
.push_modal(ModalType::ProcessDetails { pid });
app.max_process_mem_bytes = 12_345; // stand-in for collected history
app
}
/// Killing the process you are looking at should not leave you staring at
/// its details — especially since the details poll keys off the selection,
/// which is cleared at the same time.
#[test]
fn killing_the_viewed_process_closes_its_details() {
let mut child = std::process::Command::new("true").spawn().expect("spawn");
let pid = child.id();
child.wait().expect("reap");
let mut app = app_viewing(pid);
app.refresh_after_kill(pid);
assert!(
!app.modal_manager.is_active(),
"details modal stayed open for a dead process"
);
assert_eq!(
app.max_process_mem_bytes, 0,
"details state was not cleared"
);
assert!(app.last_metrics.as_ref().unwrap().top_processes.is_empty());
}
/// A process that survived the signal keeps both its row and its details.
#[test]
fn a_surviving_process_keeps_its_details_open() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn");
let pid = child.id();
let mut app = app_viewing(pid);
app.refresh_after_kill(pid);
let still_open = app.modal_manager.is_active();
let _ = child.kill();
let _ = child.wait();
assert!(still_open, "closed the details of a process still running");
}
}
#[cfg(test)]
mod kill_watch_tests {
use super::*;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics_with(pids: &[(u32, &str)]) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: pids
.iter()
.map(|(pid, name)| ProcessInfo {
pid: *pid,
name: (*name).into(),
cpu_usage: 1.0,
mem_bytes: 1_000,
})
.collect(),
gpus: None,
process_count: Some(pids.len()),
}
}
fn listed(app: &App, pid: u32) -> bool {
app.last_metrics
.as_ref()
.is_some_and(|m| m.top_processes.iter().any(|p| p.pid == pid))
}
/// The reported bug: SIGTERM is a request, so the process is normally still
/// alive at signal time. The row must go when it actually exits, not stay
/// until the next full poll.
#[test]
fn a_row_goes_as_soon_as_the_process_actually_exits() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn");
let pid = child.id();
let mut app = App::new();
app.last_metrics = Some(metrics_with(&[(pid, "sleep"), (1, "init")]));
app.selected_process_pid = Some(pid);
// Signalled, but it has not exited yet: the row stays.
app.refresh_after_kill(pid);
assert!(
listed(&app, pid),
"row vanished while the process was alive"
);
// It exits (as a SIGTERM'd process does, a moment later).
let _ = child.kill();
let _ = child.wait();
// The next tick notices.
app.poll_kill_watch();
assert!(!listed(&app, pid), "row survived the process exiting");
assert_eq!(app.selected_process_pid, None, "selection left on a corpse");
}
/// Also reported: after terminating, the row came back. The agent serves
/// `Processes` from a 1500ms cache, so its next answer can predate the kill.
#[test]
fn a_stale_agent_snapshot_cannot_resurrect_a_killed_process() {
let mut child = std::process::Command::new("true").spawn().expect("spawn");
let pid = child.id();
child.wait().expect("reap");
let mut app = App::new();
app.last_metrics = Some(metrics_with(&[(pid, "true"), (1, "init")]));
app.refresh_after_kill(pid);
assert!(!listed(&app, pid), "confirmed-dead row should be gone");
// The agent answers with a snapshot taken before the kill.
app.last_metrics = Some(metrics_with(&[(pid, "true"), (1, "init")]));
app.drop_tombstoned_rows();
assert!(!listed(&app, pid), "stale snapshot put the row back");
assert!(listed(&app, 1), "unrelated processes must survive");
}
/// His exact path: find the process with `/`, then kill it. The filtered
/// view is derived from the same list, so it must lose the row too.
#[test]
fn a_search_filtered_view_loses_the_row_as_well() {
let mut child = std::process::Command::new("true").spawn().expect("spawn");
let pid = child.id();
child.wait().expect("reap");
let mut app = App::new();
app.last_metrics = Some(metrics_with(&[(pid, "victim"), (1, "init")]));
app.process_search_query = "victim".into();
assert_eq!(
app.procs_filter().len(),
1,
"search should match the victim"
);
app.refresh_after_kill(pid);
assert!(
app.procs_filter().is_empty(),
"killed process still present in the filtered list"
);
}
#[test]
fn a_selection_that_leaves_the_list_is_dropped() {
let mut app = App::new();
app.last_metrics = Some(metrics_with(&[(1, "init")]));
app.selected_process_pid = Some(4242);
app.selected_process_index = Some(7);
app.drop_vanished_selection();
assert_eq!(app.selected_process_pid, None);
assert_eq!(app.selected_process_index, None);
}
#[test]
fn a_selection_still_in_the_list_is_kept() {
let mut app = App::new();
app.last_metrics = Some(metrics_with(&[(1, "init")]));
app.selected_process_pid = Some(1);
app.drop_vanished_selection();
assert_eq!(app.selected_process_pid, Some(1));
}
/// A process that ignores the signal must not be watched forever.
#[test]
fn the_watch_expires() {
let mut app = App::new();
app.last_metrics = Some(metrics_with(&[(1, "init")]));
// Already-expired deadline, for a PID that certainly exists (ourselves).
let me = std::process::id();
app.kill_watch.push((me, Instant::now()));
app.poll_kill_watch();
assert!(app.kill_watch.is_empty(), "expired watch was not dropped");
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Detection of whether the connected agent is running on this same machine.
//!
//! Process-kill is only offered for *local* agents. The reasoning is a
//! security one: the PIDs shown in the UI are reported by the agent, and when
//! the user asks to kill one, socktop sends the signal with its OWN local OS
//! privileges (a direct syscall — never over the network; see [`crate::proc_kill`]).
//! A PID is therefore only meaningful — and only safe to act on — when the
//! agent lives on this machine. If we acted on a remote agent's PIDs we would
//! be signalling whatever unrelated *local* process happened to share that
//! number.
//!
//! An address is considered local when it is loopback, or when we can bind an
//! ephemeral socket to it: a bind only succeeds for an address assigned to one
//! of this host's own network interfaces, so it also covers the case of an
//! agent reached over this machine's LAN IP. Detection fails closed — any
//! parse/resolution failure, or any resolved address that is not local,
//! disables the feature.
use std::net::{IpAddr, ToSocketAddrs, UdpSocket};
/// Returns true only if the agent reached at `ws_url` is on this machine.
pub fn agent_is_local(ws_url: &str) -> bool {
let Ok(parsed) = url::Url::parse(ws_url) else {
return false;
};
match parsed.host() {
// IP literals can be checked directly without any name resolution.
Some(url::Host::Ipv4(ip)) => ip_is_local(IpAddr::V4(ip)),
Some(url::Host::Ipv6(ip)) => ip_is_local(IpAddr::V6(ip)),
// A hostname (e.g. "localhost", or a LAN name) must resolve, and every
// address it resolves to must be local. ws=80, wss=443 are the known
// default ports; an explicit port in the URL is honored.
Some(url::Host::Domain(domain)) => {
let port = parsed.port_or_known_default().unwrap_or(0);
match (domain, port).to_socket_addrs() {
Ok(addrs) => {
let mut saw_any = false;
for addr in addrs {
saw_any = true;
if !ip_is_local(addr.ip()) {
return false;
}
}
saw_any
}
Err(_) => false,
}
}
None => false,
}
}
/// An address is local if it is loopback, or if we can bind an ephemeral
/// socket to it (only possible for an address on one of our own interfaces).
/// Port 0 requests an ephemeral port and sends no traffic.
fn ip_is_local(ip: IpAddr) -> bool {
ip.is_loopback() || UdpSocket::bind((ip, 0)).is_ok()
}
#[cfg(test)]
mod tests {
use super::agent_is_local;
#[test]
fn loopback_hosts_are_local() {
assert!(agent_is_local("ws://127.0.0.1:3000/ws"));
assert!(agent_is_local("ws://localhost:3000/ws"));
assert!(agent_is_local("ws://[::1]:3000/ws"));
assert!(agent_is_local("wss://127.0.0.1/ws"));
}
#[test]
fn public_addresses_are_not_local() {
// 8.8.8.8 is not assigned to any local interface.
assert!(!agent_is_local("ws://8.8.8.8:3000/ws"));
// Documentation-range address, guaranteed not bound locally.
assert!(!agent_is_local("ws://203.0.113.1:3000/ws"));
}
#[test]
fn garbage_fails_closed() {
assert!(!agent_is_local("not a url"));
assert!(!agent_is_local(""));
}
}
+43 -7
View File
@@ -2,6 +2,8 @@
mod app; mod app;
mod history; mod history;
mod local;
mod proc_kill;
mod profiles; mod profiles;
mod retry; mod retry;
mod types; mod types;
@@ -23,6 +25,18 @@ pub(crate) struct ParsedArgs {
processes_interval_ms: Option<u64>, processes_interval_ms: Option<u64>,
verify_hostname: bool, verify_hostname: bool,
compact: bool, compact: bool,
no_kill: bool,
}
/// True when the `SOCKTOP_NO_KILL` environment variable disables the process-kill
/// feature. Any value other than empty, `0`, or `false` (case-insensitive) counts
/// as set, so a deployment can export `SOCKTOP_NO_KILL=1` once and every socktop
/// launched under it — whatever its command line — has the feature off.
pub(crate) fn no_kill_from_env() -> bool {
match env::var("SOCKTOP_NO_KILL") {
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
Err(_) => false,
}
} }
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> {
@@ -38,11 +52,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
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; let mut compact = false;
let mut no_kill = 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] [--compact] [--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] [--no-kill] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
)); ));
} }
"--tls-ca" | "-t" => { "--tls-ca" | "-t" => {
@@ -68,6 +83,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
// layout switches on its own once the window gets too short. // layout switches on its own once the window gets too short.
compact = true; compact = true;
} }
"--no-kill" => {
// Disable the local process-kill feature even when the agent is
// local. For shared/kiosk deployments; SOCKTOP_NO_KILL=1 in the
// environment does the same without touching the command line.
no_kill = true;
}
"--dry-run" => { "--dry-run" => {
// intentionally undocumented // intentionally undocumented
dry_run = true; dry_run = true;
@@ -107,7 +128,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] [--compact] [ws://HOST:PORT/ws]" "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
)); ));
} }
} }
@@ -124,6 +145,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
processes_interval_ms, processes_interval_ms,
verify_hostname, verify_hostname,
compact, compact,
no_kill,
}) })
} }
@@ -144,7 +166,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(), parsed.compact).await; return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill).await;
} }
let profiles_file = load_profiles(); let profiles_file = load_profiles();
@@ -249,7 +271,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(), parsed.compact).await; return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill).await;
} }
if let Some(entry) = profiles_mut.profiles.get(name) { if let Some(entry) = profiles_mut.profiles.get(name) {
( (
@@ -309,7 +331,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(), parsed.compact).await; return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill).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(());
@@ -321,10 +343,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let is_tls = url.starts_with("wss://"); let is_tls = url.starts_with("wss://");
let has_token = url.contains("token="); let has_token = url.contains("token=");
// Only enable local process-kill when the agent is verified to be on this
// machine — otherwise on-screen PIDs refer to a remote host and acting on
// them locally would signal the wrong process (see local::agent_is_local) —
// AND neither --no-kill nor SOCKTOP_NO_KILL disables it as a matter of
// policy (shared terminals, public demos).
let kill_enabled = local::agent_is_local(&url) && !parsed.no_kill && !no_kill_from_env();
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); .with_compact(parsed.compact)
.with_kill_enabled(kill_enabled);
if parsed.dry_run { if parsed.dry_run {
return Ok(()); return Ok(());
} }
@@ -391,6 +420,7 @@ fn gather_intervals(
async fn run_demo_mode( async fn run_demo_mode(
_tls_ca: Option<&str>, _tls_ca: Option<&str>,
compact: bool, compact: bool,
no_kill: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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");
@@ -404,7 +434,13 @@ async fn run_demo_mode(
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
let mut app = App::new().with_compact(compact); // Demo mode runs the real agent on loopback, so its PIDs are real local
// processes — enable the local process-kill feature, gated the same way as
// the normal connect path (loopback resolves local, --no-kill and
// SOCKTOP_NO_KILL still override).
let mut app = App::new()
.with_compact(compact)
.with_kill_enabled(local::agent_is_local(&url) && !no_kill && !no_kill_from_env());
// 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(()) } }
} }
+181
View File
@@ -0,0 +1,181 @@
//! Local process termination.
//!
//! Signals are sent by socktop itself, using this process's own OS privileges,
//! via a direct `sysinfo` call. Nothing is transmitted to the agent — the
//! agent and connector have no kill capability at all. This code path is only
//! reachable once the agent has been verified to be local (see
//! [`crate::local`]), which guarantees the PID refers to a process on this
//! machine.
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, Signal, System};
/// The signals socktop can send. Deliberately limited to the two btop-style
/// primaries; no arbitrary-signal chooser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KillSignal {
/// SIGTERM — polite request to terminate.
Term,
/// SIGKILL — forceful, cannot be caught.
Kill,
}
impl KillSignal {
fn as_sysinfo(self) -> Signal {
match self {
KillSignal::Term => Signal::Term,
KillSignal::Kill => Signal::Kill,
}
}
/// Human-facing label for confirmation/result messages.
pub fn label(self) -> &'static str {
match self {
KillSignal::Term => "SIGTERM",
KillSignal::Kill => "SIGKILL",
}
}
}
/// Is `pid` still a live local process?
///
/// A zombie counts as gone: after a kill the entry can linger until the parent
/// reaps it, and showing a row for a process that no longer runs is exactly the
/// staleness this check exists to avoid.
pub fn process_exists(pid: u32) -> bool {
let spid = sysinfo::Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[spid]),
false,
ProcessRefreshKind::nothing(),
);
match sys.process(spid) {
Some(p) => p.status() != sysinfo::ProcessStatus::Zombie,
None => false,
}
}
/// Send `signal` to local process `pid`. Returns `Ok(())` on success, or an
/// `Err` with a human-readable reason (process gone, PID reused, permission
/// denied, signal unsupported on this platform).
///
/// `expected_name`, when given, is compared against the process that owns the
/// PID **right now**: the PID came from an agent snapshot and the confirmation
/// dialog can sit open indefinitely, so by signal time the kernel may have
/// recycled the number for an unrelated process. Both names come from the
/// same sysinfo source, so a live, unchanged target compares equal.
pub fn kill_local_process(
pid: u32,
expected_name: Option<&str>,
signal: KillSignal,
) -> Result<(), String> {
let spid = sysinfo::Pid::from_u32(pid);
// Refresh just this one PID — we don't need a full process scan to signal it.
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[spid]),
false,
ProcessRefreshKind::nothing(),
);
let Some(proc_) = sys.process(spid) else {
return Err(format!("Process {pid} no longer exists"));
};
if let Some(expected) = expected_name {
let current = proc_.name().to_string_lossy();
if current != expected {
return Err(format!(
"PID {pid} now belongs to \"{current}\", not \"{expected}\"\
not signalling. Reselect the process and try again."
));
}
}
match proc_.kill_with(signal.as_sysinfo()) {
Some(true) => Ok(()),
Some(false) => Err(format!(
"Could not send {} to PID {pid} (permission denied?)",
signal.label()
)),
None => Err(format!(
"{} is not supported on this platform",
signal.label()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::time::{Duration, Instant};
/// The path that matters: a real, live, local process must actually receive
/// the signal. Exercises the `refresh_processes_specifics` lookup as well —
/// if that call does not populate the process map, `sys.process()` returns
/// None and a live PID is reported as "no longer exists".
#[test]
fn signals_a_real_child_process() {
let mut child = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep for the test");
let pid = child.id();
let result = kill_local_process(pid, Some("sleep"), KillSignal::Term);
// Reap on every path before asserting, so a failing assert cannot leak a
// 30s sleep and cannot trip clippy's zombie_processes lint.
let deadline = Instant::now() + Duration::from_secs(5);
let mut exited = false;
while Instant::now() < deadline {
if matches!(child.try_wait(), Ok(Some(_))) {
exited = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if !exited {
let _ = child.kill();
}
let _ = child.wait();
assert!(result.is_ok(), "kill_local_process returned {result:?}");
assert!(
exited,
"SIGTERM was reported sent but the child never exited"
);
}
/// The reuse guard: a live PID whose owner does not match the name the
/// user confirmed must NOT be signalled. This also proves the name is
/// populated under ProcessRefreshKind::nothing() — if it weren't, the
/// matching-name test above would fail instead.
#[test]
fn refuses_a_pid_owned_by_a_different_process() {
let mut child = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
let result = kill_local_process(pid, Some("firefox"), KillSignal::Term);
let _ = child.kill();
let _ = child.wait();
let err = result.expect_err("signalled a process under the wrong name");
assert!(err.contains("firefox") && err.contains("sleep"), "{err}");
}
#[test]
fn reports_a_pid_that_is_gone() {
let mut child = Command::new("true").spawn().expect("spawn true");
let pid = child.id();
child.wait().expect("reap");
// The PID is now free; signalling it must fail cleanly, not panic.
assert!(kill_local_process(pid, None, KillSignal::Term).is_err());
}
}
+547 -74
View File
@@ -1,6 +1,10 @@
//! Modal window system for socktop TUI application //! Modal window system for socktop TUI application
use super::theme::MODAL_DIM_BG; use super::fit;
use super::theme::{
BTN_EXIT_BG_ACTIVE, BTN_RETRY_BG_ACTIVE, MODAL_BG, MODAL_BORDER_FG, MODAL_DIM_BG, MODAL_FG,
MODAL_TITLE_FG,
};
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -26,6 +30,10 @@ pub struct ModalManager {
pub help_scroll_offset: usize, pub help_scroll_offset: usize,
} }
/// Key hints shown under the confirmation buttons. Also sets the minimum
/// width of that dialog — sizing from the question alone clipped this line.
const CONFIRM_HINT: &str = "Tab ← → choose · Enter run · Esc cancel";
impl ModalManager { impl ModalManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -83,6 +91,52 @@ impl ModalManager {
} }
m m
} }
/// Close the details view for `pid` WHEREVER it sits in the stack.
/// Returns whether anything was closed.
///
/// Not just the top: killing from inside the details view stacks the
/// "Signal sent" Info modal on top of it, and a SIGKILL victim is usually
/// confirmed dead on the very next tick — while that Info is still up. A
/// top-only check missed the close, and since a gone PID is processed
/// once, the details view stayed open (frozen on the dead process's last
/// sample) with nothing left to ever close it.
///
/// Per-PID matching keeps the parent-navigation property: only the dead
/// process's view goes; parent views underneath are other processes that
/// may still be alive and close themselves the same way.
pub fn close_process_details(&mut self, pid: u32) -> bool {
let was_top =
matches!(self.stack.last(), Some(ModalType::ProcessDetails { pid: p }) if *p == pid);
let before = self.stack.len();
self.stack
.retain(|m| !matches!(m, ModalType::ProcessDetails { pid: p } if *p == pid));
if self.stack.len() == before {
return false;
}
// Mirror pop_modal's focus bookkeeping when the top changed.
if was_top && let Some(next) = self.stack.last() {
self.active_button = match next {
ModalType::ConnectionError { .. } => ModalButton::Retry,
ModalType::ProcessDetails { .. } => ModalButton::Ok,
ModalType::About => ModalButton::Ok,
ModalType::Help => ModalButton::Ok,
ModalType::Confirmation { .. } => ModalButton::Confirm,
ModalType::Info { .. } => ModalButton::Ok,
};
}
true
}
/// PID of the uppermost ProcessDetails view, looking through any
/// Info/Confirmation stacked above it. What the user will land on when
/// transient modals are dismissed.
pub fn topmost_process_details(&self) -> Option<u32> {
self.stack.iter().rev().find_map(|m| match m {
ModalType::ProcessDetails { pid } => Some(*pid),
_ => None,
})
}
pub fn update_connection_error_countdown(&mut self, new_countdown: Option<u64>) { pub fn update_connection_error_countdown(&mut self, new_countdown: Option<u64>) {
if let Some(ModalType::ConnectionError { if let Some(ModalType::ConnectionError {
auto_retry_countdown, auto_retry_countdown,
@@ -110,6 +164,16 @@ impl ModalManager {
self.prev_button(); self.prev_button();
ModalAction::None ModalAction::None
} }
// Kill the process being viewed. `t` rather than `k` because `k`
// scrolls the thread table in this modal — and using the same key
// here as on the processes pane means one thing to remember.
KeyCode::Char('t') | KeyCode::Char('T') => {
if let Some(ModalType::ProcessDetails { pid }) = self.stack.last() {
ModalAction::KillSelected(*pid)
} else {
ModalAction::None
}
}
KeyCode::Char('r') | KeyCode::Char('R') => { KeyCode::Char('r') | KeyCode::Char('R') => {
if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) { if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) {
ModalAction::RetryConnection ModalAction::RetryConnection
@@ -241,7 +305,16 @@ impl ModalManager {
ModalAction::Dismiss 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::ConfirmForce) => {
ModalAction::ConfirmForce
}
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => {
// Pop here so Enter-on-Cancel behaves like Esc (which pops in
// handle_key); the app's Cancel handler can then assume the
// modal is already gone.
self.pop_modal();
ModalAction::Cancel
}
(Some(ModalType::Info { .. }), ModalButton::Ok) => { (Some(ModalType::Info { .. }), ModalButton::Ok) => {
self.pop_modal(); self.pop_modal();
ModalAction::Dismiss ModalAction::Dismiss
@@ -253,12 +326,29 @@ impl ModalManager {
self.active_button = match (&self.stack.last(), &self.active_button) { self.active_button = match (&self.stack.last(), &self.active_button) {
(Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit, (Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit,
(Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry, (Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry,
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalButton::Cancel, // Confirmation cycles through three: the safe affirmative, the
// escalated one, then cancel.
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => {
ModalButton::ConfirmForce
}
(Some(ModalType::Confirmation { .. }), ModalButton::ConfirmForce) => {
ModalButton::Cancel
}
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm,
_ => self.active_button.clone(), _ => self.active_button.clone(),
}; };
} }
fn prev_button(&mut self) { fn prev_button(&mut self) {
// Confirmation has three buttons, so stepping back is not the same as
// stepping forward; everything else is a two-way toggle.
if let Some(ModalType::Confirmation { .. }) = self.stack.last() {
self.active_button = match self.active_button {
ModalButton::Confirm => ModalButton::Cancel,
ModalButton::ConfirmForce => ModalButton::Confirm,
_ => ModalButton::ConfirmForce,
};
return;
}
self.next_button(); self.next_button();
} }
@@ -280,6 +370,150 @@ impl ModalManager {
); );
} }
/// Wrap `text` to at most `width` columns on word boundaries, so a dialog
/// can be sized from its content instead of guessing.
fn wrap_cols(text: &str, width: u16) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() {
word.to_string()
} else {
format!("{current} {word}")
};
if fit::cols(&candidate) <= width || current.is_empty() {
current = candidate;
} else {
lines.push(std::mem::take(&mut current));
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
/// A centered box just big enough for `message` plus `footer_rows` of
/// buttons/hints. Never exceeds the screen, and never gets so narrow that
/// the title is clipped.
///
/// `min_content_w` is the width the footer needs. Sizing from the message
/// alone clipped the key-hint line, which is longer than most questions.
fn dialog_rect(area: Rect, message: &str, footer_rows: u16, min_content_w: u16) -> Rect {
// 2 border columns + 2 columns of breathing room on each side.
const CHROME_W: u16 = 6;
const MAX_TEXT_W: u16 = 64;
const MIN_TEXT_W: u16 = 24;
let avail_text = area.width.saturating_sub(CHROME_W).max(1);
let text_w = fit::cols(message)
.min(MAX_TEXT_W)
.min(avail_text)
.max(MIN_TEXT_W.min(avail_text));
let lines = Self::wrap_cols(message, text_w);
let widest = lines
.iter()
.map(|l| fit::cols(l))
.max()
.unwrap_or(text_w)
.max(min_content_w.min(avail_text));
let width = (widest + CHROME_W).min(area.width);
// borders + blank + message + blank + footer
let height = (lines.len() as u16 + footer_rows + 4).min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
}
}
/// Shared chrome for the small dialogs: themed border, centered message
/// with real padding, and the footer row(s) returned for the caller to
/// fill with buttons.
///
/// The old versions laid their content out over `area` rather than the
/// block's inner rect, which put the first line of text on top of the
/// border and pushed the buttons against the frame.
fn render_dialog_frame(
f: &mut Frame,
area: Rect,
title: &str,
message: &str,
footer_rows: u16,
) -> Rect {
let block = Block::default()
.title(
Line::from(format!(" {title} ")).style(
Style::default()
.fg(MODAL_TITLE_FG)
.add_modifier(Modifier::BOLD),
),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER_FG))
.style(Style::default().bg(MODAL_BG));
let inner = block.inner(area);
f.render_widget(block, area);
// Pad one column each side so text never touches the border.
let padded = Rect {
x: inner.x + 1,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // breathing room under the title
Constraint::Min(1), // message
Constraint::Length(1), // gap above the footer
Constraint::Length(footer_rows), // buttons / hints
])
.split(padded);
f.render_widget(
Paragraph::new(message)
.style(Style::default().fg(MODAL_FG))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
rows[1],
);
rows[3]
}
/// One button, sized to its label and centered in `area`.
fn render_button(f: &mut Frame, area: Rect, label: &str, active: bool, accent: Color) {
let style = if active {
Style::default()
.bg(accent)
.fg(MODAL_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(accent)
};
let text = format!(" {label} ");
let w = fit::cols(&text).min(area.width);
let btn = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y,
width: w,
height: 1,
};
f.render_widget(
Paragraph::new(text)
.style(style)
.alignment(Alignment::Center),
btn,
);
}
fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) { fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) {
let area = f.area(); let area = f.area();
// Different sizes for different modal types // Different sizes for different modal types
@@ -296,6 +530,13 @@ impl ModalManager {
// Help modal uses medium size // Help modal uses medium size
self.centered_rect(70, 80, area) self.centered_rect(70, 80, area)
} }
// Confirmation and Info are one-question dialogs. A fixed 70%x50%
// box left a short question floating in a mostly-empty pane, so
// these size themselves to their content instead.
ModalType::Confirmation { message, .. } => {
Self::dialog_rect(area, message, 3, fit::cols(CONFIRM_HINT))
}
ModalType::Info { message, .. } => Self::dialog_rect(area, message, 1, 16),
_ => { _ => {
// Other modals use smaller size // Other modals use smaller size
self.centered_rect(70, 50, area) self.centered_rect(70, 50, area)
@@ -340,86 +581,64 @@ impl ModalManager {
confirm_text: &str, confirm_text: &str,
cancel_text: &str, cancel_text: &str,
) { ) {
let chunks = Layout::default() // Three buttons + a key hint line.
let footer = Self::render_dialog_frame(f, area, title, message, 3);
let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3)]) .constraints([
.split(area); Constraint::Length(1), // buttons
let block = Block::default() Constraint::Length(1), // spacer
.title(format!(" {title} ")) Constraint::Length(1), // key hints
.borders(Borders::ALL) ])
.style(Style::default().bg(Color::Black)); .split(footer);
f.render_widget(block, area);
f.render_widget( let cols = Layout::default()
Paragraph::new(message)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
chunks[0],
);
let buttons = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([
.split(chunks[1]); Constraint::Ratio(1, 3),
let confirm_style = if self.active_button == ModalButton::Confirm { Constraint::Ratio(1, 3),
Style::default() Constraint::Ratio(1, 3),
.bg(Color::Green) ])
.fg(Color::Black) .split(rows[0]);
.add_modifier(Modifier::BOLD)
} else { Self::render_button(
Style::default().fg(Color::Green) f,
}; cols[0],
let cancel_style = if self.active_button == ModalButton::Cancel { confirm_text,
Style::default() self.active_button == ModalButton::Confirm,
.bg(Color::Red) BTN_RETRY_BG_ACTIVE,
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
};
f.render_widget(
Paragraph::new(confirm_text)
.style(confirm_style)
.alignment(Alignment::Center),
buttons[0],
); );
Self::render_button(
f,
cols[1],
"Force kill",
self.active_button == ModalButton::ConfirmForce,
MODAL_TITLE_FG,
);
Self::render_button(
f,
cols[2],
cancel_text,
self.active_button == ModalButton::Cancel,
BTN_EXIT_BG_ACTIVE,
);
f.render_widget( f.render_widget(
Paragraph::new(cancel_text) Paragraph::new(CONFIRM_HINT)
.style(cancel_style) .style(Style::default().fg(MODAL_FG).add_modifier(Modifier::DIM))
.alignment(Alignment::Center), .alignment(Alignment::Center),
buttons[1], rows[2],
); );
} }
fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) { fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) {
let chunks = Layout::default() let footer = Self::render_dialog_frame(f, area, title, message, 1);
.direction(Direction::Vertical) Self::render_button(
.constraints([Constraint::Min(1), Constraint::Length(3)]) f,
.split(area); footer,
let block = Block::default() "Enter — OK",
.title(format!(" {title} ")) self.active_button == ModalButton::Ok,
.borders(Borders::ALL) BTN_RETRY_BG_ACTIVE,
.style(Style::default().bg(Color::Black));
f.render_widget(block, area);
f.render_widget(
Paragraph::new(message)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
chunks[0],
);
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)
};
f.render_widget(
Paragraph::new("[ Enter ] OK")
.style(ok_style)
.alignment(Alignment::Center),
chunks[1],
); );
} }
@@ -505,6 +724,9 @@ impl ModalManager {
" ↑/↓ ............ Select/navigate processes", " ↑/↓ ............ Select/navigate processes",
" Enter .......... Open Process Details", " Enter .......... Open Process Details",
" x/X ............ Clear selection", " x/X ............ Clear selection",
" t .............. Signal selected process — local agent only",
" (also works inside Process Details; the prompt",
" offers Terminate/SIGTERM or Force kill/SIGKILL)",
" Click header ... Sort by column (CPU/Mem)", " Click header ... Sort by column (CPU/Mem)",
" Click row ...... Select process", " Click row ...... Select process",
"", "",
@@ -632,3 +854,254 @@ impl ModalManager {
.split(vert[1])[1] .split(vert[1])[1]
} }
} }
#[cfg(test)]
mod confirm_tests {
use super::*;
fn confirm_modal() -> ModalManager {
let mut m = ModalManager::new();
m.push_modal(ModalType::Confirmation {
title: "Confirm signal".into(),
message: "Send a signal to bash (PID 42)?".into(),
confirm_text: "Terminate".into(),
cancel_text: "Cancel".into(),
});
m
}
/// The safe option is focused first, so a reflexive Enter terminates rather
/// than force-kills.
#[test]
fn opens_on_the_safe_option() {
let mut m = confirm_modal();
assert_eq!(m.active_button, ModalButton::Confirm);
assert_eq!(m.handle_key(KeyCode::Enter), ModalAction::Confirm);
}
#[test]
fn tab_cycles_all_three_buttons_forward() {
let mut m = confirm_modal();
m.handle_key(KeyCode::Tab);
assert_eq!(m.active_button, ModalButton::ConfirmForce);
m.handle_key(KeyCode::Tab);
assert_eq!(m.active_button, ModalButton::Cancel);
m.handle_key(KeyCode::Tab);
assert_eq!(m.active_button, ModalButton::Confirm);
}
/// With three buttons, back is not the same as forward — the old
/// prev_button just called next_button, which only worked for two.
#[test]
fn shift_tab_cycles_backward() {
let mut m = confirm_modal();
m.handle_key(KeyCode::BackTab);
assert_eq!(m.active_button, ModalButton::Cancel);
m.handle_key(KeyCode::BackTab);
assert_eq!(m.active_button, ModalButton::ConfirmForce);
m.handle_key(KeyCode::BackTab);
assert_eq!(m.active_button, ModalButton::Confirm);
}
#[test]
fn force_kill_reports_its_own_action() {
let mut m = confirm_modal();
m.handle_key(KeyCode::Tab);
assert_eq!(m.handle_key(KeyCode::Enter), ModalAction::ConfirmForce);
}
#[test]
fn escape_cancels_and_closes() {
let mut m = confirm_modal();
assert_eq!(m.handle_key(KeyCode::Esc), ModalAction::Cancel);
assert!(!m.is_active());
}
/// Enter on Cancel must behave like Esc, including closing the modal.
#[test]
fn enter_on_cancel_closes_too() {
let mut m = confirm_modal();
m.handle_key(KeyCode::Tab);
m.handle_key(KeyCode::Tab);
assert_eq!(m.handle_key(KeyCode::Enter), ModalAction::Cancel);
assert!(!m.is_active());
}
/// `t` inside process details asks the app to raise the kill prompt for the
/// process being viewed — not for whatever is selected in the list behind it.
#[test]
fn t_in_process_details_targets_that_pid() {
let mut m = ModalManager::new();
m.push_modal(ModalType::ProcessDetails { pid: 4242 });
assert_eq!(
m.handle_key(KeyCode::Char('t')),
ModalAction::KillSelected(4242)
);
}
/// `k` still scrolls the thread table, which is why `t` is the kill key.
#[test]
fn k_in_process_details_still_scrolls() {
let mut m = ModalManager::new();
m.push_modal(ModalType::ProcessDetails { pid: 1 });
m.thread_scroll_max = 5;
m.handle_key(KeyCode::Char('j'));
assert_eq!(m.thread_scroll_offset, 1);
assert_eq!(m.handle_key(KeyCode::Char('k')), ModalAction::Handled);
assert_eq!(m.thread_scroll_offset, 0);
}
#[test]
fn t_elsewhere_is_not_a_kill() {
let mut m = ModalManager::new();
m.push_modal(ModalType::Help);
assert_eq!(m.handle_key(KeyCode::Char('t')), ModalAction::None);
}
/// A one-line question must not be handed a half-screen box.
#[test]
fn dialog_is_sized_to_its_content() {
let screen = Rect::new(0, 0, 120, 40);
let r = ModalManager::dialog_rect(
screen,
"Send a signal to bash (PID 42)?",
3,
fit::cols(CONFIRM_HINT),
);
assert!(r.width < screen.width, "dialog took the full width");
assert!(r.height <= 12, "dialog was {} rows tall", r.height);
assert!(r.height >= 7, "dialog too short to hold its own footer");
// Centered to within the rounding of integer division.
let center_delta = (r.x + r.width / 2) as i32 - (screen.width / 2) as i32;
assert!(center_delta.abs() <= 1, "off-center by {center_delta}");
}
#[test]
fn dialog_never_exceeds_a_small_screen() {
let screen = Rect::new(0, 0, 20, 8);
let long = "a".repeat(400);
let r = ModalManager::dialog_rect(screen, &long, 3, fit::cols(CONFIRM_HINT));
assert!(r.width <= screen.width && r.height <= screen.height);
}
}
#[cfg(test)]
mod button_style_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
/// The focused button must be the highlighted one — the whole point of the
/// three-button layout is that you can see which action Enter will run.
#[test]
fn focus_moves_the_highlight() {
let msg = "Send a signal to bash (PID 42)?";
let mut m = ModalManager::new();
m.push_modal(ModalType::Confirmation {
title: "Confirm signal".into(),
message: msg.into(),
confirm_text: "Terminate".into(),
cancel_text: "Cancel".into(),
});
// Background colors present on the button row, per focused button.
let bgs = |m: &ModalManager| -> Vec<Color> {
let screen = Rect::new(0, 0, 100, 30);
let area = ModalManager::dialog_rect(screen, msg, 3, fit::cols(CONFIRM_HINT));
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
t.draw(|f| {
m.render_confirmation(f, area, "Confirm signal", msg, "Terminate", "Cancel")
})
.unwrap();
let buf = t.backend().buffer();
// Buttons sit on the first footer row: title, gap, message, gap.
let row = area.y + 4;
(area.x..area.x + area.width)
.map(|x| buf[(x, row)].bg)
.collect()
};
let terminate_focused = bgs(&m);
assert!(
terminate_focused.contains(&BTN_RETRY_BG_ACTIVE),
"Terminate should be highlighted when focused"
);
assert!(
!terminate_focused.contains(&BTN_EXIT_BG_ACTIVE),
"Cancel must not be highlighted while Terminate has focus"
);
m.handle_key(KeyCode::Tab);
m.handle_key(KeyCode::Tab);
let cancel_focused = bgs(&m);
assert!(
cancel_focused.contains(&BTN_EXIT_BG_ACTIVE),
"Cancel should be highlighted after two Tabs"
);
assert!(
!cancel_focused.contains(&BTN_RETRY_BG_ACTIVE),
"Terminate must not stay highlighted"
);
}
}
#[cfg(test)]
mod close_details_tests {
use super::*;
#[test]
fn closes_the_view_for_that_pid() {
let mut m = ModalManager::new();
m.push_modal(ModalType::ProcessDetails { pid: 4242 });
assert!(m.close_process_details(4242));
assert!(!m.is_active());
}
#[test]
fn leaves_a_different_pid_alone() {
let mut m = ModalManager::new();
m.push_modal(ModalType::ProcessDetails { pid: 4242 });
assert!(!m.close_process_details(1));
assert!(m.is_active());
}
/// Walking up to a parent stacks details views. Only the dead process's
/// view goes — whichever position it holds — and the survivor stays put.
#[test]
fn closes_only_the_dead_pids_view_in_a_parent_chain() {
let mut m = ModalManager::new();
m.push_modal(ModalType::ProcessDetails { pid: 100 }); // parent
m.push_modal(ModalType::ProcessDetails { pid: 200 }); // child, on top
// Parent dies while the child is viewed: its view is removed from
// UNDER the top, so closing the child later lands on the process list
// instead of a frozen corpse view.
assert!(m.close_process_details(100));
assert!(matches!(
m.current_modal(),
Some(ModalType::ProcessDetails { pid: 200 })
));
assert!(m.close_process_details(200));
assert!(!m.is_active());
}
/// The F1 regression: killing from inside the details view stacks the
/// "Signal sent" Info on top, and the death is usually confirmed while
/// that Info is still up. The details view must close anyway — a top-only
/// check left it open forever, frozen on the dead process.
#[test]
fn closes_details_beneath_a_stacked_info_modal() {
let mut m = ModalManager::new();
m.push_modal(ModalType::ProcessDetails { pid: 7 });
m.push_modal(ModalType::Info {
title: "Signal sent".into(),
message: "Sent SIGKILL".into(),
});
assert!(m.close_process_details(7));
// The Info survives on top; dismissing it lands on the process list.
assert!(matches!(m.current_modal(), Some(ModalType::Info { .. })));
m.pop_modal();
assert!(!m.is_active());
}
}
+18 -1
View File
@@ -95,7 +95,7 @@ impl ModalManager {
} }
// Help line // Help line
let help_text = vec![Line::from(vec![ let mut help_text = vec![Line::from(vec![
Span::styled( Span::styled(
"X ", "X ",
Style::default() Style::default()
@@ -129,6 +129,23 @@ impl ModalManager {
Span::styled("journal", Style::default().add_modifier(Modifier::DIM)), Span::styled("journal", Style::default().add_modifier(Modifier::DIM)),
])]; ])];
// Kill from here too — same key as the processes pane, and only shown
// when the kill feature is enabled (agent local, no policy override).
if data.kill_enabled
&& let Some(line) = help_text.first_mut()
{
line.spans.push(Span::styled(
" t ",
Style::default()
.fg(PROCESS_DETAILS_ACCENT)
.add_modifier(Modifier::BOLD),
));
line.spans.push(Span::styled(
"kill",
Style::default().add_modifier(Modifier::DIM),
));
}
let help = Paragraph::new(Text::from(help_text)) let help = Paragraph::new(Text::from(help_text))
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::default()); .style(Style::default());
+14
View File
@@ -19,6 +19,10 @@ pub struct ProcessModalData<'a> {
pub history: ProcessHistoryData<'a>, pub history: ProcessHistoryData<'a>,
pub max_mem_bytes: u64, pub max_mem_bytes: u64,
pub unsupported: bool, pub unsupported: bool,
/// Whether the process-kill feature is available (agent local, no policy
/// override). Only used to decide whether the `t` kill hint is shown —
/// the kill itself is gated in `App`.
pub kill_enabled: bool,
} }
/// Parameters for rendering scatter plot /// Parameters for rendering scatter plot
@@ -64,9 +68,15 @@ pub enum ModalAction {
RetryConnection, RetryConnection,
ExitApp, ExitApp,
Confirm, Confirm,
/// Confirmation modal's second affirmative: the same action, escalated.
/// Used by the kill prompt for SIGKILL, where `Confirm` means SIGTERM.
ConfirmForce,
Cancel, Cancel,
Dismiss, Dismiss,
SwitchToParentProcess(u32), // Switch to viewing parent process details SwitchToParentProcess(u32), // Switch to viewing parent process details
/// `t` pressed while viewing a process's details — the app decides whether
/// the agent is local and, if so, raises the kill confirmation.
KillSelected(u32),
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -74,6 +84,10 @@ pub enum ModalButton {
Retry, Retry,
Exit, Exit,
Confirm, Confirm,
/// Escalated affirmative on a Confirmation modal (SIGKILL for the kill
/// prompt). Separate button rather than a separate keybinding so the
/// destructive option has to be selected deliberately.
ConfirmForce,
Cancel, Cancel,
Ok, Ok,
} }
+169 -13
View File
@@ -5,13 +5,14 @@ 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::Span, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table}, widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
}; };
use std::cmp::Ordering; use std::cmp::Ordering;
use crate::types::Metrics; use crate::types::Metrics;
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse}; use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
use crate::ui::fit;
use crate::ui::theme::{ 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,
@@ -86,6 +87,10 @@ pub struct ProcessDisplayParams<'a> {
/// Peak cpu_usage from the most recent cache build; used to bold the /// Peak cpu_usage from the most recent cache build; used to bold the
/// busiest process. -1.0 if no cache. /// busiest process. -1.0 if no cache.
pub peak_cpu: f32, pub peak_cpu: f32,
/// The process-kill feature is available (agent local, no policy
/// override), so the `t` kill hint applies. Without it the hint would
/// advertise a key that deliberately does nothing.
pub kill_enabled: bool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -449,16 +454,60 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
format!("PID {selected_pid}") format!("PID {selected_pid}")
}; };
let tooltip_text = format!("{process_info} | Enter for details • X to unselect"); // Key hints, built as spans so the keys read as keys. `t` only appears
let tooltip_width = tooltip_text.len() as u16 + 2; // Add padding // for a local agent, since that is the only case where it does anything.
let tooltip_height = 3; let key = Style::default()
.fg(PROCESS_TOOLTIP_FG)
.add_modifier(Modifier::BOLD);
let label = Style::default().fg(PROCESS_TOOLTIP_FG);
let mut hints: Vec<Span> = vec![
Span::styled("", key),
Span::styled(" details", label),
Span::styled(" · ", label),
];
if params.kill_enabled {
hints.push(Span::styled("t", key));
hints.push(Span::styled(" kill", label));
hints.push(Span::styled(" · ", label));
}
hints.push(Span::styled("x", key));
hints.push(Span::styled(" unselect", label));
// Position tooltip at bottom-right of the processes area let hints_w: u16 = hints.iter().map(|s| fit::cols(&s.content)).sum();
if area.width > tooltip_width + 2 && area.height > tooltip_height + 1 { // One row, borders on both sides, a space of padding each side.
let tooltip_height = 3;
let max_w = area.width.saturating_sub(2);
if max_w > hints_w + 6 && area.height > tooltip_height + 1 {
// The process name is the elastic part: truncate it so the hint
// always fits. The old version sized the box from the full string
// and skipped rendering entirely when a long process name made it
// wider than the pane — so the hint silently vanished exactly when
// a long-named process was selected.
let room_for_info = max_w - hints_w - 6;
let info = fit::truncate_cols(&process_info, room_for_info);
let mut spans: Vec<Span> = vec![
Span::styled(" ", label),
Span::styled(
info.clone(),
Style::default()
.fg(PROCESS_TOOLTIP_FG)
.add_modifier(Modifier::BOLD),
),
Span::styled("", label),
];
spans.extend(hints);
spans.push(Span::styled(" ", label));
let width = spans
.iter()
.map(|s| fit::cols(&s.content))
.sum::<u16>()
.saturating_add(2)
.min(area.width);
let tooltip_area = Rect { let tooltip_area = Rect {
x: area.x + area.width.saturating_sub(tooltip_width + 1), x: area.x + area.width.saturating_sub(width + 1),
y: area.y + area.height.saturating_sub(tooltip_height + 1), y: area.y + area.height.saturating_sub(tooltip_height + 1),
width: tooltip_width, width,
height: tooltip_height, height: tooltip_height,
}; };
@@ -468,11 +517,10 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.fg(PROCESS_TOOLTIP_FG), .fg(PROCESS_TOOLTIP_FG),
); );
let tooltip_paragraph = Paragraph::new(tooltip_text) f.render_widget(
.block(tooltip_block) Paragraph::new(Line::from(spans)).block(tooltip_block),
.wrap(ratatui::widgets::Wrap { trim: true }); tooltip_area,
);
f.render_widget(tooltip_paragraph, tooltip_area);
} }
} }
@@ -905,6 +953,7 @@ mod click_tests {
filtered_indices: &idxs, filtered_indices: &idxs,
cached_rows: &cache, cached_rows: &cache,
peak_cpu: peak, peak_cpu: peak,
kill_enabled: false,
}, },
) )
}) })
@@ -984,3 +1033,110 @@ mod click_tests {
} }
} }
} }
#[cfg(test)]
mod tooltip_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics(name: &str) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 32_000_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid: 4242,
name: name.into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
/// Render the pane with a selection and return the whole buffer as text.
fn rendered(name: &str, width: u16, kill_enabled: bool) -> String {
let m = metrics(name);
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
let idxs = [0usize];
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal
.draw(|f| {
draw_top_processes(
f,
Rect::new(0, 0, width, 12),
ProcessDisplayParams {
metrics: Some(&m),
scroll_offset: 0,
sort_by: ProcSortBy::CpuDesc,
selected_process_pid: Some(4242),
selected_process_index: Some(0),
search_query: "",
search_active: false,
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
kill_enabled,
},
)
})
.unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..12 {
for x in 0..width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn hint_offers_kill_for_a_local_agent() {
let out = rendered("some-process", 80, true);
assert!(out.contains("details"), "no hint rendered at all:\n{out}");
assert!(
out.contains("kill"),
"local agent should offer kill:\n{out}"
);
assert!(out.contains("unselect"));
}
/// The key does nothing for a remote agent, so advertising it would be a lie.
#[test]
fn hint_omits_kill_for_a_remote_agent() {
let out = rendered("some-process", 80, false);
assert!(out.contains("details"), "no hint rendered at all:\n{out}");
assert!(
!out.contains("kill"),
"remote agent must not offer kill:\n{out}"
);
}
/// Regression: the hint used to be sized from the full label including the
/// process name, and was skipped entirely when that made it wider than the
/// pane — so it vanished exactly when a long-named process was selected.
#[test]
fn hint_survives_a_very_long_process_name() {
let long = "/usr/lib/firefox-esr/firefox-esr-with-a-really-long-suffix";
let out = rendered(long, 80, true);
assert!(
out.contains("details") && out.contains("kill"),
"hint disappeared for a long process name:\n{out}"
);
}
}
+50
View File
@@ -106,3 +106,53 @@ fn test_compact_flag_documented_and_accepted() {
String::from_utf8_lossy(&out2.stderr) String::from_utf8_lossy(&out2.stderr)
); );
} }
#[test]
fn test_no_kill_flag_documented_and_accepted() {
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--no-kill", "--help"])
.output()
.expect("run socktop --no-kill --help");
assert!(
out.status.success(),
"socktop --no-kill --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("--no-kill"),
"help text missing --no-kill\n{text}"
);
// The flag must not be mistaken for the positional URL argument.
let out2 = Command::new(exe)
.args(["--no-kill", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop --no-kill --dry-run");
assert!(
out2.status.success(),
"socktop --no-kill with a URL was rejected: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
#[test]
fn test_no_kill_env_var_accepted() {
// SOCKTOP_NO_KILL must not break startup — the env-only path is how the
// webterm deployment disables the kill feature for every invocation.
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.env("SOCKTOP_NO_KILL", "1")
.args(["--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop with SOCKTOP_NO_KILL=1");
assert!(
out.status.success(),
"socktop with SOCKTOP_NO_KILL=1 did not succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "socktop_agent" name = "socktop_agent"
version = "1.60.0" version = "1.60.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"
+14 -3
View File
@@ -588,9 +588,17 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// filter when downgrading to a minimal refresh spec. // 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;
// `true` = remove processes that no longer exist. With `false`, this
// long-lived System kept every process it had ever seen: the list grew
// without bound (21,648 entries on a machine with 289 processes after a
// few hours of build churn), process_count was meaningless, and — the
// reason this was found — a process you killed kept its row forever,
// because the agent went on reporting it. Safe here only because this is
// `ProcessesToUpdate::All`; with `Some(pids)` it would treat every process
// outside that list as dead and drop it.
sys.refresh_processes_specifics( sys.refresh_processes_specifics(
ProcessesToUpdate::All, ProcessesToUpdate::All,
false, true,
ProcessRefreshKind::nothing().with_memory().without_tasks(), ProcessRefreshKind::nothing().with_memory().without_tasks(),
); );
@@ -719,8 +727,11 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
//JW too complicated. simplify to remove strange behavior //JW too complicated. simplify to remove strange behavior
// For active systems, get accurate CPU metrics // For active systems, get accurate CPU metrics.
sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind.with_cpu()); // `true` = drop processes that have exited; see the Linux path above for
// what `false` cost us (an ever-growing list that kept reporting dead
// processes). Correct only because this is `ProcessesToUpdate::All`.
sys.refresh_processes_specifics(ProcessesToUpdate::All, true, kind.with_cpu());
// } else { // } else {
// // For idle systems, just get basic process info // // For idle systems, just get basic process info
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "socktop_connector" name = "socktop_connector"
version = "1.60.0" version = "1.60.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
description = "WebSocket connector library for socktop agent communication" description = "WebSocket connector library for socktop agent communication"