Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Witty e5310570d9 fix(socktop): explain how to install socktop_agent when demo mode can't find it
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Demo mode spawns a socktop_agent child process, but the agent is a separate
crate that `cargo install socktop` does not pull in. When it was missing, the
raw spawn error propagated to main and printed as
`Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }`,
which gives the user nothing to act on.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:12:34 -07:00
48 changed files with 4324 additions and 9683 deletions
+2 -3
View File
@@ -1,7 +1,6 @@
# Any crate's build directory, including standalone sub-crates
# (zellij_socktop_plugin, socktop_wasm_test) that live outside the workspace.
target/
/target
.vscode/
/socktop-wasm-test/target
/.cargo/
# Documentation files from development sessions (context-specific, not for public repo)
-64
View File
@@ -1,64 +0,0 @@
# Changelog
## 1.60.0 — unreleased
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.0 together.
### Security
- **Certificate pinning is now real.** With `--verify-hostname` off (the default), the client previously accepted *any* server certificate — the `--tls-ca` file was never consulted. The presented certificate must now be byte-identical to one in the pinned PEM (multi-cert files supported for rotation). If you use TLS, update the client: earlier versions are MITM-able despite the pinning documentation. (housekeeping-p2)
- `key.pem` is created with mode 0600 (was world-readable 0644); agents also tighten existing keys on startup. (housekeeping-p2)
- The agent's per-PID caches now evict (60s age / 64 entries); previously they grew without bound. (housekeeping-p2)
### Performance
- Agent CPU on GPU machines cut ~6× (measured 23.5 → 4.0 ms/s at default polling): GPU collection moved to a dedicated worker thread that keeps the NVML session open instead of re-initializing it every 1.5 s on the async runtime. (housekeeping-p2)
- `journalctl` no longer blocks the agent's async workers. (housekeeping-p2)
- Cached "no temp sensor / no GPU" results count as fresh — no more per-request rescans on hosts without them. (housekeeping-p2)
- Nagle disabled on all connection paths (small request/response frames). (housekeeping-p2)
### TUI
- **Compact layout for small windows**: when the window is too short for the Disks pane, Disks is dropped, Memory/Swap go side by side, GPU collapses to one line (omitted if absent), and the reclaimed rows keep the CPU graph and per-core bars visible. `--compact` pins it. (#37)
- **Width-aware text**: header, CPU title, and process table shed detail by priority as the terminal narrows instead of overwriting each other; process Name column is now the last to go, not the first. Fixed sort-header clicks landing up to 4 columns off. (#38)
- **Responsive input**: keys and mouse are handled within ~30 ms instead of queueing for a full metrics interval. (housekeeping-p2)
- **No more freezes**: all requests carry a 5 s timeout; a dead connection shows the reconnect modal (with working `q`) instead of hanging the UI. Consecutive timeouts surface a persistent "agent not responding" error. (housekeeping-p2)
- Old agents without the per-process endpoints once again show "Agent Update Required" instead of a reconnect loop. (housekeeping-p2)
- Journal pane distinguishes "no entries" from "no journal access" (e.g. user-run/demo agents) and shows journalctl's hint plus the fix. (housekeeping-p2)
- Scatter-plot axes align correctly for large CPU-time values. (housekeeping-p2)
- Demo mode explains how to install `socktop_agent` when the binary is missing. (#36)
### Correctness
- Process/child CPU times were sent as ms but displayed as µs — values rendered 1000× too small in the details modal. (housekeeping-p2)
- Non-Linux per-process CPU% no longer truncates multi-core usage (clamp after divide). (housekeeping-p2)
- Journal timestamps are real RFC 3339 UTC with numeric sorting (additive `timestamp_us`). (housekeeping-p2)
- Partition detection uses `/sys/block` on Linux — whole-disk filesystems (`nvme0n1`, `zram1`) are no longer misclassified as partitions. (housekeeping-p2)
- Network rates use agent-side sample timestamps (additive `sampled_at_ms`), eliminating rate sawtooth from TTL-cached snapshots; falls back to the client clock with older agents. (housekeeping-p2)
- The details modal's Command/exe/cwd fields are populated again (dropped by an earlier refresh optimization). (housekeeping-p2)
- Non-ASCII device names no longer panic the disk pane. (housekeeping-p2)
### Wire format (additive only — old/new client-agent pairs keep working)
- `Metrics.sampled_at_ms` (epoch ms of actual collection)
- `JournalEntry.timestamp_us` (epoch µs), `JournalEntry.timestamp` now RFC 3339
- `JournalResponse.notice` (journal-access hint)
### Internal / packaging
- ratatui 0.28 → 0.30 (#33); aws-lc-rs advisories patched (#34); Debian packaging for the agent (#25); assorted dependabot bumps.
- ~3,100 lines of dead code removed, including an orphaned pre-refactor copy of the connector.
- `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.
### 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
- **Release/publish order**: `socktop_connector``socktop` → agent packages.
- Clients older than 1.60 work against 1.60 agents and vice versa; the security fix is client-side, so prioritize client updates where TLS is used.
Generated
+25 -6
View File
@@ -2412,7 +2412,7 @@ dependencies = [
[[package]]
name = "socktop"
version = "1.60.0"
version = "1.50.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2422,17 +2422,16 @@ dependencies = [
"ratatui",
"serde",
"serde_json",
"socktop_connector",
"socktop_connector 1.50.0 (registry+https://github.com/rust-lang/crates.io-index)",
"sysinfo",
"tempfile",
"tokio",
"unicode-width",
"url",
]
[[package]]
name = "socktop_agent"
version = "1.60.0"
version = "1.50.2"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2442,7 +2441,6 @@ dependencies = [
"futures-util",
"gfxinfo",
"hostname",
"nvml-wrapper",
"once_cell",
"prost",
"prost-build",
@@ -2464,7 +2462,7 @@ dependencies = [
[[package]]
name = "socktop_connector"
version = "1.60.0"
version = "1.50.0"
dependencies = [
"flate2",
"futures-util",
@@ -2485,6 +2483,27 @@ dependencies = [
"web-sys",
]
[[package]]
name = "socktop_connector"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ea6a5733e71da6d5c94d23265b85f7041305bca51e6c33e7104464444047bc"
dependencies = [
"flate2",
"futures-util",
"prost",
"prost-build",
"protoc-bin-vendored",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite 0.24.0",
"url",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
-1
View File
@@ -26,7 +26,6 @@ sysinfo = "0.37"
# CLI UI
ratatui = "0.30"
crossterm = "0.29"
unicode-width = "0.2"
# web server (remote-agent)
axum = { version = "0.7", features = ["ws"] }
+1 -29
View File
@@ -31,8 +31,6 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
- Only top-level processes listed (threads hidden) — matches btop/top
- 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))
---
@@ -215,8 +213,6 @@ 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):
@@ -228,29 +224,6 @@ The agent stays idle unless queried. When queried, it collects just whats nee
---
## Compact mode
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
are the first things to collapse — exactly the panes you are most likely watching. Once
the window is too short for the Disks pane to show even one disk, socktop switches to a
compact layout:
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
- **Memory and Swap move side by side** into the row Disks vacated.
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
with no GPU the pane disappears entirely.
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
below the size where they used to vanish.
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
layout at any window size:
```bash
socktop --compact ws://HOST:3000/ws
```
---
## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
@@ -416,7 +389,6 @@ Tip: If only the binary changed, restart is enough. If the unit file changed, ru
```json
{
"sampled_at_ms": 1786752000123,
"cpu_total": 12.4,
"cpu_per_core": [11.2, 15.7],
"mem_total": 33554432,
@@ -476,7 +448,7 @@ 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.
- Hostname (SAN) verification is DISABLED by default (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking.
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
---
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env bash
# Build socktop + socktop_agent from source and install them.
#
# Works on Linux (x86_64, arm64/armv7, riscv64) and macOS. Handles fresh
# installs and upgrades; if a systemd socktop-agent service is present, its
# binary is replaced in place and the service restarted.
#
# ./scripts/install.sh # build HEAD of the repo you're in
# ./scripts/install.sh --ref v1.60.0 # build a tag/branch (clones if needed)
# ./scripts/install.sh --ref master # or any branch
# ./scripts/install.sh --prefix ~/.local/bin --no-service
#
set -euo pipefail
REPO_URL="https://github.com/jasonwitty/socktop.git"
REF=""
PREFIX=""
NO_SERVICE=0
SRC_DIR="${SOCKTOP_SRC_DIR:-$HOME/.cache/socktop-src}"
while [ $# -gt 0 ]; do
case "$1" in
--ref) REF="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--no-service) NO_SERVICE=1; shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# The entire remainder runs inside main(), invoked on the LAST line. This
# makes the script safe against being MODIFIED WHILE RUNNING: when executed
# from the clone it manages, the git checkout below replaces this very file,
# and bash reads scripts lazily by byte offset — without this wrapper it
# resumes parsing the NEW file at the OLD offset and executes an arbitrary
# tail of it (observed: the fresh-service path ran on a host whose unit
# already existed). With main(), the whole script is parsed before any of
# it executes.
main() {
OS="$(uname -s)"
ARCH="$(uname -m)"
# ---------- toolchain ----------
command -v git >/dev/null || die "git is required"
if ! command -v cargo >/dev/null; then
# rustup may be installed but not on PATH in this shell
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
if ! command -v cargo >/dev/null; then
say "Rust toolchain not found — installing via rustup (stable, default profile)"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
. "$HOME/.cargo/env"
fi
command -v cc >/dev/null || warn "no C compiler found (apt: build-essential / brew: xcode-select --install) — the build may fail"
case "$ARCH" in
riscv64*)
# protoc-bin-vendored ships no riscv64 binary; the build falls back to
# the system protoc (see build.rs).
command -v protoc >/dev/null || die "riscv64 needs a system protoc: sudo apt install protobuf-compiler"
;;
esac
# ---------- source ----------
# If run from inside a socktop checkout and no --ref given, build that tree
# as-is (whatever is checked out, including local changes).
if [ -z "$REF" ] && git rev-parse --show-toplevel >/dev/null 2>&1 \
&& grep -qs '^name = "socktop"' "$(git rev-parse --show-toplevel)/socktop/Cargo.toml" 2>/dev/null; then
SRC_DIR="$(git rev-parse --show-toplevel)"
say "Building the current checkout: $SRC_DIR ($(git -C "$SRC_DIR" describe --always --dirty 2>/dev/null))"
else
REF="${REF:-master}"
if [ ! -d "$SRC_DIR/.git" ]; then
say "Cloning $REPO_URL -> $SRC_DIR"
git clone "$REPO_URL" "$SRC_DIR"
fi
say "Checking out $REF"
git -C "$SRC_DIR" fetch --tags origin
git -C "$SRC_DIR" checkout -q "$REF"
# fast-forward when REF is a branch
git -C "$SRC_DIR" merge --ff-only "origin/$REF" >/dev/null 2>&1 || true
fi
# ---------- build ----------
say "Building release binaries (this can take a while on SBCs)"
( cd "$SRC_DIR" && cargo build --release -p socktop -p socktop_agent )
CLIENT="$SRC_DIR/target/release/socktop"
AGENT="$SRC_DIR/target/release/socktop_agent"
# ---------- install ----------
if [ -z "$PREFIX" ]; then
PREFIX="/usr/local/bin"
fi
SUDO=""
if [ ! -w "$PREFIX" ]; then
if command -v sudo >/dev/null; then SUDO="sudo"; else
PREFIX="$HOME/.local/bin"; mkdir -p "$PREFIX"
warn "no sudo — installing to $PREFIX (ensure it is on your PATH)"
fi
fi
say "Installing to $PREFIX"
$SUDO install -m 755 "$CLIENT" "$PREFIX/socktop"
$SUDO install -m 755 "$AGENT" "$PREFIX/socktop_agent"
# Update every other copy on PATH as well. A stale `cargo install` in
# ~/.cargo/bin would otherwise SHADOW the fresh binary (~/.cargo/bin
# usually precedes /usr/local/bin on PATH), leaving `socktop --version`
# stuck on the old release after a "successful" install.
update_path_copies() {
local name="$1" src="$2" copy dir
# type -ap lists every match on PATH (bash builtin, symlinks not resolved)
for copy in $(type -ap "$name" | sort -u); do
[ "$copy" = "$PREFIX/$name" ] && continue
dir="$(dirname "$copy")"
say "Updating additional copy on PATH: $copy"
if [ -w "$copy" ] || [ -w "$dir" ]; then
install -m 755 "$src" "$copy"
else
# Non-fatal: an un-updatable extra copy shouldn't kill the install,
# but the user must know it may shadow the fresh binary.
$SUDO install -m 755 "$src" "$copy" || warn "could not update $copy — it may shadow $PREFIX/$name"
fi
done
}
update_path_copies socktop "$CLIENT"
update_path_copies socktop_agent "$AGENT"
# ---------- systemd service (Linux only) ----------
# System-level operations (unit files, users, service control) need root no
# matter where the binaries were installed — decide independently of PREFIX.
SYS_SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null; then SYS_SUDO="sudo"; else SYS_SUDO="__none__"; fi
fi
if [ "$SYS_SUDO" = "__none__" ] && [ "$NO_SERVICE" -eq 0 ]; then
warn "no sudo available — skipping systemd service management"
NO_SERVICE=1
fi
if [ "$OS" = "Linux" ] && [ "$NO_SERVICE" -eq 0 ] && command -v systemctl >/dev/null; then
if systemctl cat socktop-agent.service >/dev/null 2>&1; then
# UPGRADE: the unit file is the operator's (SSL, tokens, ports may be
# configured there) — never overwrite it. Only the binary it points at
# is replaced, then the service is restarted.
say "Existing socktop-agent.service found — preserving unit file, refreshing binary"
UNIT_BIN="$(systemctl show -p ExecStart socktop-agent.service 2>/dev/null \
| sed -n 's/.*path=\([^ ;]*\).*/\1/p' | head -1)"
if [ -n "$UNIT_BIN" ] && [ "$UNIT_BIN" != "$PREFIX/socktop_agent" ]; then
$SYS_SUDO systemctl stop socktop-agent.service
$SYS_SUDO install -m 755 "$AGENT" "$UNIT_BIN"
$SYS_SUDO systemctl start socktop-agent.service
else
$SYS_SUDO systemctl restart socktop-agent.service
fi
else
# FRESH INSTALL: unit + the system user it runs as + its state dir,
# then enable and start. Mirrors the deb package's postinst and
# https://www.socktop.io/assets/docs/installation/agent-service.html
say "No socktop-agent.service found — installing and enabling it"
if ! getent group socktop >/dev/null; then
$SYS_SUDO groupadd --system socktop
fi
if ! getent passwd socktop >/dev/null; then
NOLOGIN="$(command -v nologin || echo /usr/sbin/nologin)"
$SYS_SUDO useradd --system -g socktop -d /var/lib/socktop -M -s "$NOLOGIN" socktop
fi
$SYS_SUDO mkdir -p /var/lib/socktop
$SYS_SUDO chown socktop:socktop /var/lib/socktop
$SYS_SUDO chmod 755 /var/lib/socktop
UNIT_TMP="$(mktemp)"
if [ -f "$SRC_DIR/docs/socktop-agent.service" ]; then
cp "$SRC_DIR/docs/socktop-agent.service" "$UNIT_TMP"
else
# Fallback for refs that predate docs/socktop-agent.service
cat > "$UNIT_TMP" <<'UNIT'
[Unit]
Description=Socktop agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/socktop_agent --port 3000
Environment=RUST_LOG=info
# Optional auth:
# Environment=SOCKTOP_TOKEN=changeme
# TLS (self-signed cert on first run, default port 8443):
# Environment=SOCKTOP_ENABLE_SSL=1
Restart=on-failure
User=socktop
Group=socktop
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
UNIT
fi
# Pick the agent port: 3000 by default, but NEVER bind onto a port that
# something else already holds (e.g. Gitea/Umami and friends love 3000)
# — that puts the fresh service straight into a crash-restart loop.
AGENT_PORT=""
for p in 3000 3001 3010 3231 3232; do
if ! ss -tln 2>/dev/null | awk '{print $4}' | grep -q ":${p}\$"; then
AGENT_PORT="$p"
break
fi
done
if [ -z "$AGENT_PORT" ]; then
AGENT_PORT=3000
warn "no free port among the defaults — using 3000; edit the unit if the service fails to start"
elif [ "$AGENT_PORT" != "3000" ]; then
warn "port 3000 is already in use by another service — configuring the agent on port $AGENT_PORT"
fi
# Point ExecStart at wherever this run installed the agent, on the chosen port.
sed -i.bak -e "s|^ExecStart=[^ ]*socktop_agent|ExecStart=$PREFIX/socktop_agent|" \
-e "s|--port [0-9]*|--port $AGENT_PORT|" "$UNIT_TMP"
rm -f "$UNIT_TMP.bak"
$SYS_SUDO install -o root -g root -m 0644 "$UNIT_TMP" /etc/systemd/system/socktop-agent.service
rm -f "$UNIT_TMP"
$SYS_SUDO systemctl daemon-reload
$SYS_SUDO systemctl enable --now socktop-agent.service
say "Service installed — agent URL: ws://$(hostname):$AGENT_PORT/ws"
say "To enable TLS or a token, edit /etc/systemd/system/socktop-agent.service, then: sudo systemctl daemon-reload && sudo systemctl restart socktop-agent"
fi
sleep 1
systemctl --no-pager -l status socktop-agent.service | head -5 || true
fi
say "Installed:"
"$PREFIX/socktop" --version
"$PREFIX/socktop_agent" --version
say "Active on PATH: $(type -p socktop || true) / $(type -p socktop_agent || true)"
socktop --version
}
# exit in the same parse unit as the call: after main returns, bash must not
# read another byte from this (possibly replaced) file.
main "$@"; exit $?
+3 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop"
version = "1.60.0"
version = "1.50.0"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top"
edition = "2024"
@@ -11,7 +11,7 @@ repository = "https://github.com/jasonwitty/socktop"
[dependencies]
# socktop connector for agent communication
socktop_connector = { version = "1.60.0", path = "../socktop_connector" }
socktop_connector = "1.50.0"
tokio = { workspace = true }
futures-util = { workspace = true }
@@ -20,12 +20,9 @@ serde_json = { workspace = true }
url = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
unicode-width = { 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 }
sysinfo = { workspace = true }
[dev-dependencies]
assert_cmd = "2.0"
+574 -1511
View File
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
//! 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(""));
}
}
+8 -32
View File
@@ -2,8 +2,6 @@
mod app;
mod history;
mod local;
mod proc_kill;
mod profiles;
mod retry;
mod types;
@@ -24,7 +22,6 @@ pub(crate) struct ParsedArgs {
metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>,
verify_hostname: bool,
compact: bool,
}
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
@@ -39,12 +36,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false;
let mut compact = false;
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
return Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--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] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
));
}
"--tls-ca" | "-t" => {
@@ -65,11 +61,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
"--demo" => {
demo = true;
}
"--compact" => {
// Force the small-window layout at any terminal size. Without it the
// layout switches on its own once the window gets too short.
compact = true;
}
"--dry-run" => {
// intentionally undocumented
dry_run = true;
@@ -109,7 +100,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
url = Some(arg);
} else {
return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
));
}
}
@@ -125,7 +116,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
metrics_interval_ms,
processes_interval_ms,
verify_hostname,
compact,
})
}
@@ -146,7 +136,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
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()).await;
}
let profiles_file = load_profiles();
@@ -251,7 +241,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if (1..=names.len()).contains(&idx) {
let name = &names[idx - 1];
if name == "demo" {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
return run_demo_mode(parsed.tls_ca.as_deref()).await;
}
if let Some(entry) = profiles_mut.profiles.get(name) {
(
@@ -311,7 +301,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.");
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()).await;
} else {
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(());
@@ -323,15 +313,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let is_tls = url.starts_with("wss://");
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.
let is_local = local::agent_is_local(&url);
let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms)
.with_status(is_tls, has_token)
.with_compact(parsed.compact)
.with_local(is_local);
.with_status(is_tls, has_token);
if parsed.dry_run {
return Ok(());
}
@@ -395,10 +379,7 @@ fn gather_intervals(
}
// Demo mode implementation
async fn run_demo_mode(
_tls_ca: Option<&str>,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws");
let child = match spawn_demo_agent(port) {
@@ -411,12 +392,7 @@ async fn run_demo_mode(
}
Err(e) => return Err(e.into()),
};
// 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).
let mut app = App::new()
.with_compact(compact)
.with_local(local::agent_is_local(&url));
let mut app = App::new();
// Demo mode connects to localhost, so disable hostname verification
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
}
-181
View File
@@ -1,181 +0,0 @@
//! 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());
}
}
File diff suppressed because it is too large Load Diff
+26 -179
View File
@@ -14,10 +14,6 @@ use ratatui::{
use crate::history::PerCoreHistory;
use crate::types::Metrics;
use crate::ui::fit::{cols, pick_pair};
/// Columns kept clear between the CPU title and the temperature readout.
const TITLE_GAP: u16 = 2;
/// State for dragging the scrollbar thumb
#[derive(Clone, Copy, Debug, Default)]
@@ -252,12 +248,29 @@ pub fn draw_cpu_avg_graph(
hist_sum as f64 / hist.len() as f64
};
let (title, top_right_info) = cpu_title_for_width(
m.map(|mm| mm.cpu_total),
avg_cpu,
m.and_then(|mm| mm.cpu_temp_c),
area.width,
);
let title = if let Some(mm) = m {
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
} else {
"CPU avg".into()
};
// Build the top-right info (CPU temp and polling intervals)
let top_right_info = if let Some(mm) = m {
mm.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into())
} else {
String::new()
};
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
@@ -273,14 +286,12 @@ pub fn draw_cpu_avg_graph(
.style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area);
// Temperature overlays the top border, right-aligned inside the corner. The title
// above is sized so the two cannot collide.
// Render the top-right info as text overlay in the top-right corner
if !top_right_info.is_empty() {
let w = cols(&top_right_info);
let info_area = Rect {
x: area.x + area.width.saturating_sub(w + 1),
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
y: area.y,
width: w,
width: top_right_info.len() as u16 + 1,
height: 1,
};
let info_line = Line::from(Span::raw(top_right_info));
@@ -288,67 +299,6 @@ pub fn draw_cpu_avg_graph(
}
}
/// Health glyph for a CPU temperature.
fn temp_icon(t: f32) -> &'static str {
if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
}
}
/// Chooses the CPU pane's title and its right-aligned temperature readout for a pane
/// `width` columns wide.
///
/// Both are painted onto the pane's top border, so without a shared budget the
/// temperature simply overwrites the tail of the title on a narrow pane. Detail is given
/// up in this order: the `CPU Temp:` label, then the `now:`/`avg:` labels, then the
/// average reading, then the decimal on the temperature, and only last the temperature
/// itself — the readings are what the pane is for, but a thermal warning is worth more
/// than a second decimal place.
fn cpu_title_for_width(
cpu_now: Option<f32>,
avg_cpu: f64,
temp_c: Option<f32>,
width: u16,
) -> (String, String) {
let Some(now) = cpu_now else {
return ("CPU avg".into(), String::new());
};
// Two borders, plus a column of breathing room at each end of the title.
let budget = width.saturating_sub(4);
let labelled = format!("CPU (now: {now:>5.1}% | avg: {avg_cpu:>5.1}%)");
let bare = format!("CPU ({now:.1}% | {avg_cpu:.1}%)");
let now_only = format!("CPU ({now:.1}%)");
let (temp_labelled, temp_plain, temp_coarse) = match temp_c {
Some(t) => {
let icon = temp_icon(t);
(
format!("CPU Temp: {t:.1}°C {icon}"),
format!("{t:.1}°C {icon}"),
format!("{t:.0}°C {icon}"),
)
}
None => ("CPU Temp: N/A".into(), "N/A".into(), "N/A".into()),
};
let ladder = [
(labelled.as_str(), temp_labelled.as_str()),
(labelled.as_str(), temp_plain.as_str()),
(bare.as_str(), temp_plain.as_str()),
(bare.as_str(), temp_coarse.as_str()),
(now_only.as_str(), temp_coarse.as_str()),
(now_only.as_str(), ""),
];
let (title, temp) = pick_pair(budget, TITLE_GAP, &ladder);
(title.to_string(), temp.to_string())
}
/// Draws the per-core CPU bars with sparklines and trends.
pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>,
@@ -478,108 +428,6 @@ pub fn draw_per_core_bars(
}
}
#[cfg(test)]
mod title_tests {
use super::*;
/// The defect this replaces: the temperature was painted over the title's tail on a
/// narrow pane. Whatever the width, the two must fit side by side on the border.
#[test]
fn title_and_temperature_never_overlap() {
for width in 0..=200u16 {
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
let budget = width.saturating_sub(4);
if temp.is_empty() {
continue;
}
assert!(
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
);
}
}
/// The current CPU reading is the one thing the pane must always show.
#[test]
fn the_current_reading_always_survives() {
for width in 20..=200u16 {
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
assert!(
title.contains("3.4"),
"width {width}: lost the reading ({title:?})"
);
}
}
/// The ladder from the design: temp label, then now/avg labels, then the average,
/// then the temperature's decimal, then the temperature.
#[test]
fn detail_is_dropped_in_priority_order() {
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
let (title, temp) = at(80);
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
// The "CPU Temp:" label goes first; the readings keep their labels.
let (title, temp) = at(50);
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
assert_eq!(temp, "43.0°C 😎");
// Then the now:/avg: labels.
let (title, temp) = at(40);
assert_eq!(title, "CPU (0.7% | 1.3%)");
assert_eq!(temp, "43.0°C 😎");
// Then the temperature's decimal.
let (title, temp) = at(31);
assert_eq!(title, "CPU (0.7% | 1.3%)");
assert_eq!(temp, "43°C 😎");
// Then the average reading.
let (title, temp) = at(26);
assert_eq!(title, "CPU (0.7%)");
assert_eq!(temp, "43°C 😎");
// Last of all, the temperature itself.
let (title, temp) = at(15);
assert_eq!(title, "CPU (0.7%)");
assert_eq!(temp, "");
}
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
/// reading at every tier that shows a temperature at all.
#[test]
fn the_thermal_glyph_tracks_the_temperature() {
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
for width in 26..=80u16 {
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
assert!(
temp.contains(icon),
"width {width} at {t}°C: expected {icon} in {temp:?}"
);
}
}
}
/// An agent that reports no temperature must not leave a stray label behind.
#[test]
fn a_missing_temperature_degrades_to_nothing() {
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
assert_eq!(temp, "CPU Temp: N/A");
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
assert_eq!(temp, "");
}
/// Before the first payload arrives there are no readings to show.
#[test]
fn no_metrics_yet_shows_the_placeholder() {
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
assert_eq!(title, "CPU avg");
assert!(temp.is_empty());
}
}
#[cfg(test)]
mod render_tests {
use super::*;
@@ -589,7 +437,6 @@ mod render_tests {
fn fake_metrics(cores: Vec<f32>) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: cores,
mem_total: 1024,
+2 -3
View File
@@ -1,8 +1,7 @@
//! Disk cards with per-device gauge and title line.
use crate::types::Metrics;
use crate::ui::fit::truncate_middle_cols;
use crate::ui::util::{disk_icon, human};
use crate::ui::util::{disk_icon, human, truncate_middle};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
@@ -70,7 +69,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
"{}{}{}{} {} / {} ({}%)",
indent,
disk_icon(&d.name),
truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2),
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
temp_str,
human(used),
human(d.total),
-190
View File
@@ -1,190 +0,0 @@
//! Fitting text to the columns actually available.
//!
//! Several panes paint two independent pieces of text onto one row — a left title and a
//! right-aligned readout. Nothing reserves space for the right piece, so on a narrow
//! terminal the right one is simply painted over the tail of the left one and the title
//! is clobbered mid-word. The helpers here let a caller measure in real terminal columns
//! and pick the richest wording that still fits, so the two never overlap.
//!
//! Note that `str::len()` is a byte count and must not be used for this: `⏱` is three
//! bytes wide but one column, and `🔒` is four bytes but two columns.
use unicode_width::UnicodeWidthStr;
/// Terminal columns `s` occupies, saturating at `u16::MAX`.
pub fn cols(s: &str) -> u16 {
UnicodeWidthStr::width(s).min(u16::MAX as usize) as u16
}
/// Shortens `s` to at most `max` columns, marking the cut with `…`.
///
/// Cuts on character boundaries and accounts for wide characters, so the result never
/// exceeds `max` columns and never splits a multi-byte character.
pub fn truncate_cols(s: &str, max: u16) -> String {
if cols(s) <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
// Reserve one column for the ellipsis.
let budget = max.saturating_sub(1);
let mut used = 0u16;
let mut out = String::new();
for ch in s.chars() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > budget {
break;
}
used += w;
out.push(ch);
}
out.push('…');
out
}
/// Shortens `s` to at most `max` columns by cutting the MIDDLE, marking the
/// cut with `…` — device names like `/dev/nvme0n1p1` keep their distinctive
/// prefix and suffix. Column- and char-boundary-safe; the byte-slicing
/// predecessor in `util.rs` panicked on non-ASCII names.
pub fn truncate_middle_cols(s: &str, max: u16) -> String {
if cols(s) <= max {
return s.to_string();
}
if max <= 1 {
return truncate_cols(s, max);
}
// Reserve one column for the ellipsis; split the rest left/right.
let left_budget = (max - 1) / 2;
let right_budget = max - 1 - left_budget;
let mut left_end = 0; // byte index
let mut used = 0u16;
for (i, ch) in s.char_indices() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > left_budget {
break;
}
used += w;
left_end = i + ch.len_utf8();
}
let mut right_start = s.len();
let mut used = 0u16;
for (i, ch) in s.char_indices().rev() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > right_budget || i < left_end {
break;
}
used += w;
right_start = i;
}
format!("{}{}", &s[..left_end], &s[right_start..])
}
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
/// with at least `gap` columns between them.
///
/// Candidates are ordered most- to least-detailed; the last one is the floor and is
/// returned even if it does not fit, so callers always get something to render.
pub fn pick_pair<'a>(
width: u16,
gap: u16,
candidates: &[(&'a str, &'a str)],
) -> (&'a str, &'a str) {
let fits = |left: &str, right: &str| {
let needed = cols(left)
.saturating_add(cols(right))
.saturating_add(if right.is_empty() { 0 } else { gap });
needed <= width
};
for &(left, right) in candidates {
if fits(left, right) {
return (left, right);
}
}
candidates.last().copied().unwrap_or(("", ""))
}
#[cfg(test)]
mod tests {
use super::*;
/// The bug these helpers exist to prevent: byte length overstates the width of the
/// glyphs socktop puts in its header, which is what pushed the right-hand text into
/// the title in the first place.
#[test]
fn cols_counts_columns_not_bytes() {
assert_eq!(cols("abc"), 3);
// Stopwatch: 3 bytes, 1 column.
assert_eq!("".len(), 3);
assert_eq!(cols(""), 1);
// Lock: 4 bytes, 2 columns.
assert_eq!("🔒".len(), 4);
assert_eq!(cols("🔒"), 2);
assert_eq!(cols("⏱ 500ms metrics | 2000ms procs"), 30);
}
#[test]
fn truncate_respects_the_column_budget() {
assert_eq!(truncate_cols("cachyos-gaming", 20), "cachyos-gaming");
assert_eq!(truncate_cols("cachyos-gaming", 14), "cachyos-gaming");
assert_eq!(truncate_cols("cachyos-gaming", 10), "cachyos-g…");
assert_eq!(cols(&truncate_cols("cachyos-gaming", 10)), 10);
assert_eq!(truncate_cols("cachyos-gaming", 1), "");
assert_eq!(truncate_cols("cachyos-gaming", 0), "");
}
/// Truncation must never land mid-character or overrun the budget on wide glyphs.
#[test]
fn truncate_handles_wide_and_multibyte_characters() {
for max in 0..12u16 {
let out = truncate_cols("🔒🔒🔒 TLS", max);
assert!(cols(&out) <= max, "{out:?} exceeds {max} columns");
assert!(out.chars().all(|c| c != '\u{fffd}'), "{out:?} split a char");
}
// A wide glyph that cannot fit beside the ellipsis is dropped whole.
assert_eq!(truncate_cols("🔒ab", 2), "");
}
/// Middle truncation keeps both ends — the parts that identify a device —
/// and must never exceed the budget or split a character.
#[test]
fn truncate_middle_keeps_both_ends_within_budget() {
assert_eq!(truncate_middle_cols("/dev/nvme0n1p1", 20), "/dev/nvme0n1p1");
let out = truncate_middle_cols("/dev/nvme0n1p1", 9);
assert_eq!(cols(&out), 9);
assert!(out.starts_with("/dev"), "{out}");
assert!(out.ends_with("1p1"), "{out}");
assert!(out.contains('…'), "{out}");
// Non-ASCII names must not panic (the old byte-slicing version did).
for max in 0..12u16 {
let out = truncate_middle_cols("диск-🗄️-данные", max);
assert!(cols(&out) <= max.max(1), "{out:?} exceeds {max}");
}
}
#[test]
fn pick_pair_takes_the_richest_that_fits() {
let candidates = [
("full left text", "full right text"),
("left text", "right text"),
("left", "right"),
];
assert_eq!(pick_pair(80, 2, &candidates), candidates[0]);
assert_eq!(pick_pair(24, 2, &candidates), candidates[1]);
assert_eq!(pick_pair(12, 2, &candidates), candidates[2]);
// Below the floor the last candidate is still returned.
assert_eq!(pick_pair(1, 2, &candidates), candidates[2]);
}
/// The gap is what keeps the two pieces from touching; it must not be charged when
/// there is no right-hand piece to separate.
#[test]
fn pick_pair_only_charges_the_gap_when_both_sides_are_present() {
let candidates = [("0123456789", "x"), ("0123456789", "")];
assert_eq!(pick_pair(11, 2, &candidates), candidates[1]);
assert_eq!(pick_pair(13, 2, &candidates), candidates[0]);
}
}
-207
View File
@@ -121,210 +121,3 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
);
}
}
/// One-line GPU strip for compact mode: no device name (it is the first thing to lose
/// value when rows are scarce), just utilisation and VRAM on the single content row
/// between the block borders. Only the first GPU fits; the title says so when there are
/// more.
pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let gpus = m.and_then(|mm| mm.gpus.as_ref());
let count = gpus.map(|g| g.len()).unwrap_or(0);
let title = if count > 1 {
format!("GPU (1/{count})")
} else {
"GPU".to_string()
};
f.render_widget(Block::default().borders(Borders::ALL).title(title), area);
if area.height < 3 || area.width <= 2 {
return;
}
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width - 2,
height: 1,
};
let Some(g) = gpus.and_then(|v| v.first()) else {
f.render_widget(Paragraph::new("No GPUs"), inner);
return;
};
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = if total > 0 {
(used as f64 / total as f64).clamp(0.0, 1.0)
} else {
0.0
};
let util_label = format!("util: {util}%");
let mem_label = format!(
"vram: {}/{} ({}%)",
fmt_bytes(used),
fmt_bytes(total),
(mem_ratio * 100.0).round() as u16
);
// Bars are sized explicitly rather than left to stretch: an idle bar renders as
// empty cells, so a full-width one turns into a long blank run between two labels.
const MIN_GAUGE_W: u16 = 6;
const MAX_GAUGE_W: u16 = 24;
let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side
let gauge_w = inner
.width
.saturating_sub(labels_w)
.min(2 * MAX_GAUGE_W)
.div_euclid(2);
// Too narrow for bars worth drawing: keep the numbers, drop the bars.
if gauge_w < MIN_GAUGE_W {
f.render_widget(
Paragraph::new(Span::raw(format!("{util_label} {mem_label}")))
.style(Style::default().fg(Color::Gray)),
inner,
);
return;
}
// Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous
// on a single line: with an idle bar rendering empty, the next pair's fill ends up
// flush against the previous pair's text and reads as belonging to it.
let mut x = inner.x;
let mut place = |w: u16| {
let r = Rect {
x,
y: inner.y,
width: w,
height: 1,
};
x += w;
r
};
let util_rect = place(util_label.len() as u16 + 2);
let util_bar = place(gauge_w);
let mem_rect = place(mem_label.len() as u16 + 2);
let mem_bar = place(gauge_w);
let label = |text: &str| {
Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray))
};
f.render_widget(label(&util_label), util_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
.ratio(util as f64 / 100.0),
util_bar,
);
f.render_widget(label(&mem_label), mem_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::LightMagenta))
.label(Span::raw(""))
.ratio(mem_ratio),
mem_bar,
);
}
#[cfg(test)]
mod render_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use socktop_connector::{GpuInfo, Metrics};
fn gpu(name: &str) -> GpuInfo {
GpuInfo {
name: Some(name.into()),
vendor: None,
utilization: Some(42.0),
mem_used: Some(4_724_464_025),
mem_total: Some(17_070_817_280),
temp: None,
}
}
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus,
process_count: Some(0),
}
}
fn render(width: u16, m: &Metrics) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
terminal
.draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m)))
.unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Compact mode drops the device name — the row is one line and the numbers are
/// what the space is for.
#[test]
fn compact_strip_omits_the_device_name() {
let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")]));
let out = render(80, &m);
assert!(
!out.contains("NVIDIA"),
"name leaked into compact strip:\n{out}"
);
assert!(out.contains("util: 42%"), "{out}");
assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}");
}
/// A second GPU cannot fit on one line, so the title has to say the strip is partial
/// rather than silently showing only the first card.
#[test]
fn multiple_gpus_are_flagged_in_the_title() {
let one = render(80, &metrics(Some(vec![gpu("a")])));
assert!(one.contains("GPU") && !one.contains("1/"), "{one}");
let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")])));
assert!(two.contains("GPU (1/2)"), "{two}");
}
/// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must
/// never drop the numbers.
#[test]
fn narrow_strip_keeps_the_numbers() {
let m = metrics(Some(vec![gpu("a")]));
for width in [20u16, 30, 40, 47, 48, 80, 200] {
let out = render(width, &m);
if width >= 40 {
assert!(out.contains("util: 42%"), "width {width}:\n{out}");
}
// No panic, and the block always closes on the last row.
assert_eq!(out.lines().count(), 3, "width {width}");
}
}
#[test]
fn missing_gpu_payload_does_not_panic() {
assert!(render(80, &metrics(None)).contains("No GPUs"));
assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs"));
}
}
+23 -211
View File
@@ -1,232 +1,44 @@
//! Top header with hostname, connection status and polling intervals.
//!
//! The row carries two pieces of text — session identity on the left, polling intervals
//! on the right — and both matter. Rather than let the right one overwrite the left when
//! they no longer both fit, the header drops detail in priority order: the hostname and
//! the intervals are what survive longest, because they are what tells you *which* host
//! you are looking at and how fresh the numbers are.
//! Top header with hostname and CPU temperature indicator.
use crate::ui::fit::{cols, pick_pair, truncate_cols};
use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Columns kept clear between the left and right halves.
const GAP: u16 = 2;
/// Never shorten the hostname below this before dropping the intervals instead.
const HOSTNAME_FLOOR: u16 = 8;
/// Session state the header renders.
#[derive(Clone, Copy)]
pub struct HeaderState<'a> {
pub hostname: Option<&'a str>,
pub is_tls: bool,
pub has_token: bool,
pub metrics_ms: u128,
pub procs_ms: u128,
/// Build the header's left-side title from session state. Callers cache the
/// returned String and only rebuild it when one of the inputs changes.
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
let base = match hostname {
Some(h) => format!("socktop — host: {h}"),
None => "socktop — connecting...".into(),
};
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
let mut parts = vec![base, tls_txt.into()];
if has_token {
parts.push("🔑 token".into());
}
parts.push("(a: about, h: help, q: quit)".into());
parts.join(" | ")
}
/// Builds the left and right halves of the header for a row `width` columns wide.
///
/// Detail is dropped in this order as the row narrows: the key hints, then the TLS/token
/// badges, then the `socktop — host:` prefix (leaving the bare hostname), then the
/// `metrics`/`procs` words, and only then is the hostname itself shortened. The two
/// halves are always sized to sit side by side, so neither can paint over the other.
///
/// Callers cache the result and rebuild it only when the state or the width changes.
pub fn build_header(state: HeaderState<'_>, width: u16) -> (String, String) {
let host = state.hostname.unwrap_or("connecting...");
let tls = if state.is_tls {
"🔒 TLS"
} else {
"🔒✗ TLS"
};
let badges = if state.has_token {
format!("{tls} | 🔑 token")
} else {
tls.to_string()
};
let named = format!("socktop — host: {host}");
let with_badges = format!("{named} | {badges}");
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
let intervals = format!(
"{}ms metrics | {}ms procs",
state.metrics_ms, state.procs_ms
);
let intervals_short = format!("{}ms | {}ms", state.metrics_ms, state.procs_ms);
// Richest first. The bare hostname is reached before the intervals lose their
// labels, and the hostname is only shortened once nothing else is left to give.
let ladder = [
(with_keys.as_str(), intervals.as_str()),
(with_badges.as_str(), intervals.as_str()),
(named.as_str(), intervals.as_str()),
(host, intervals.as_str()),
(host, intervals_short.as_str()),
];
let (left, right) = pick_pair(width, GAP, &ladder);
if cols(left) + cols(right) + GAP <= width {
return (left.to_string(), right.to_string());
}
// Past the floor of the ladder: shorten the hostname, and give up the intervals only
// if even a stub of a hostname will not fit beside them.
let room = width
.saturating_sub(cols(&intervals_short))
.saturating_sub(GAP);
if room >= HOSTNAME_FLOOR {
return (truncate_cols(host, room), intervals_short);
}
(truncate_cols(host, width), String::new())
/// Build the right-side polling interval text. Callers cache this string.
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
format!("{metrics_ms}ms metrics | {procs_ms}ms procs")
}
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
if intervals.is_empty() {
return;
}
let intervals_width = cols(intervals);
if area.width >= intervals_width {
let intervals_width = intervals.len() as u16;
if area.width > intervals_width + 2 {
let right_area = Rect {
x: area.x + area.width - intervals_width,
x: area.x + area.width.saturating_sub(intervals_width + 1),
y: area.y,
width: intervals_width,
height: 1,
};
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state(hostname: Option<&str>) -> HeaderState<'_> {
HeaderState {
hostname,
is_tls: false,
has_token: false,
metrics_ms: 500,
procs_ms: 2000,
}
}
/// The defect this replaces: the two halves were painted independently, so below
/// ~105 columns the right half landed on top of the title. Whatever the width, they
/// must now fit side by side.
#[test]
fn halves_never_overlap_at_any_width() {
for width in 0..=200u16 {
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
let used = cols(&left) + cols(&right);
if right.is_empty() {
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
} else {
assert!(
used + GAP <= width,
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
);
}
}
}
/// Hostname and intervals are the two things worth keeping; everything else is
/// context that can go.
#[test]
fn hostname_and_intervals_survive_longest() {
for width in 34..=200u16 {
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
assert!(
left.contains("cachyos-gaming"),
"width {width}: lost the hostname ({left:?})"
);
assert!(
right.contains("500ms") && right.contains("2000ms"),
"width {width}: lost the intervals ({right:?})"
);
}
}
/// The ladder from the design: key hints, then badges, then the prefix, then the
/// interval labels, then the hostname itself.
#[test]
fn detail_is_dropped_in_priority_order() {
let s = state(Some("cachyos-gaming"));
let (left, right) = build_header(s, 120);
assert_eq!(
left,
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
);
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
// Key hints go first.
let (left, _) = build_header(s, 80);
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
// Then the badges.
let (left, _) = build_header(s, 70);
assert_eq!(left, "socktop — host: cachyos-gaming");
// Then the prefix, leaving the bare hostname.
let (left, right) = build_header(s, 50);
assert_eq!(left, "cachyos-gaming");
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
// Then the interval labels.
let (left, right) = build_header(s, 34);
assert_eq!(left, "cachyos-gaming");
assert_eq!(right, "⏱ 500ms | 2000ms");
// Only then is the hostname itself shortened.
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
let (left, right) = build_header(s, 30);
assert_eq!(left, "cachyos-gam…");
assert_eq!(right, "⏱ 500ms | 2000ms");
}
/// A long hostname must not push the intervals off the row.
#[test]
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
for width in 30..=100u16 {
let (left, right) = build_header(state(Some(long)), width);
assert!(!right.is_empty(), "width {width}: intervals were dropped");
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
}
}
/// Widths too small for both: the hostname is the last thing standing.
#[test]
fn hostname_is_the_final_survivor() {
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
assert!(right.is_empty(), "intervals should have been dropped");
assert!(!left.is_empty());
assert!(cols(&left) <= 20);
}
#[test]
fn tls_and_token_badges_appear_when_there_is_room() {
let s = HeaderState {
hostname: Some("host"),
is_tls: true,
has_token: true,
metrics_ms: 500,
procs_ms: 2000,
};
let (left, _) = build_header(s, 200);
assert!(left.contains("🔒 TLS"), "{left}");
assert!(left.contains("🔑 token"), "{left}");
}
#[test]
fn a_missing_hostname_reads_as_connecting() {
let (left, _) = build_header(state(None), 120);
assert!(left.contains("connecting"), "{left}");
let intervals_line = Line::from(Span::raw(intervals));
f.render_widget(Paragraph::new(intervals_line), right_area);
}
}
-352
View File
@@ -1,352 +0,0 @@
//! Root layout computation, shared by the draw path and the input hit-testing paths.
//!
//! Two modes:
//!
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
//! network graphs next to the process table.
//!
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
//! by side into the space it vacated, the GPU collapses to a single full-width line
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
//! long before the rest of the panes stop being useful.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Which of the two layouts [`compute`] produced.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayoutMode {
Normal,
Compact,
}
impl LayoutMode {
pub fn is_compact(self) -> bool {
matches!(self, LayoutMode::Compact)
}
}
/// Rows the Disks pane needs before it can show one disk card: the card itself is
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
const DISKS_MIN_H: u16 = 5;
/// Header line.
const HEADER_H: u16 = 1;
/// Memory and Swap gauges: 1 content row between borders.
const GAUGE_H: u16 = 3;
/// A network graph at its preferred height.
const NET_H: u16 = 5;
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
// borders) before the network graphs are allowed to shrink, because restoring the CPU
// panes is the entire point of the mode.
const TOP_MIN_H: u16 = 5;
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
#[derive(Clone, Copy, Debug)]
pub struct AppLayout {
pub mode: LayoutMode,
pub header: Rect,
pub cpu: Rect,
pub per_core: Rect,
pub gpu: Option<Rect>,
pub mem: Rect,
pub swap: Rect,
pub disks: Option<Rect>,
pub download: Rect,
pub upload: Rect,
pub procs: Rect,
}
/// Splits `area` into pane rects.
///
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
if force_compact {
return compact(area, has_gpu);
}
let normal = normal(area);
match normal.disks {
Some(d) if d.height >= DISKS_MIN_H => normal,
_ => compact(area, has_gpu),
}
}
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(dir)
.constraints(constraints)
.split(area)
}
/// 66/34 split used by every full-width row in the normal layout.
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
split(
area,
Direction::Horizontal,
&[Constraint::Percentage(66), Constraint::Percentage(34)],
)
}
fn normal(area: Rect) -> AppLayout {
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H), // header
Constraint::Ratio(1, 3), // top row
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
],
);
let top = left_right(rows[1]);
let mem_lr = left_right(rows[2]);
let swap_lr = left_right(rows[3]);
// GPU spans the same vertical space as Memory + Swap.
let gpu = Rect {
x: mem_lr[1].x,
y: mem_lr[1].y,
width: mem_lr[1].width,
height: mem_lr[1].height + swap_lr[1].height,
};
let bottom = split(
rows[4],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Min(4), // disks absorbs the slack
Constraint::Length(NET_H), // download
Constraint::Length(NET_H), // upload
],
);
AppLayout {
mode: LayoutMode::Normal,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: Some(gpu),
mem: mem_lr[0],
swap: swap_lr[0],
disks: Some(left_stack[0]),
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
// Give the top row its floor first, then share any surplus with the bottom so the
// process table keeps growing with the window instead of staying pinned at 13 rows.
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
(top, avail - top)
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
(TOP_MIN_H, avail - TOP_MIN_H)
} else {
// Smaller than both floors: the network graphs are already at their minimum, so
// the top row takes what is left (panes clip below this point).
let bottom = BOTTOM_MIN_H.min(avail);
(avail - bottom, bottom)
};
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H),
Constraint::Length(top_h),
Constraint::Length(gpu_h),
Constraint::Length(bottom_h),
],
);
let top = left_right(rows[1]);
let bottom = split(
rows[3],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Length(GAUGE_H),
Constraint::Fill(1),
Constraint::Fill(1),
],
);
let gauges = split(
left_stack[0],
Direction::Horizontal,
&[Constraint::Percentage(50), Constraint::Percentage(50)],
);
AppLayout {
mode: LayoutMode::Compact,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: has_gpu.then_some(rows[2]),
mem: gauges[0],
swap: gauges[1],
disks: None,
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area(w: u16, h: u16) -> Rect {
Rect::new(0, 0, w, h)
}
/// The height where the normal layout still fits a full disk card. Below it the CPU
/// panes are the ones that collapse, which is what compact mode exists to prevent.
#[test]
fn tall_window_stays_normal() {
let l = compute(area(120, 40), false, true);
assert_eq!(l.mode, LayoutMode::Normal);
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
}
#[test]
fn short_window_switches_to_compact() {
let l = compute(area(120, 24), false, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
/// The switch happens exactly when Disks can no longer show one card, and never
/// oscillates: every height above the crossover is normal, every height below is
/// compact.
#[test]
fn mode_is_monotonic_in_height() {
let mut first_normal = None;
for h in 10..=60u16 {
let mode = compute(area(120, h), false, true).mode;
match (mode, first_normal) {
(LayoutMode::Normal, None) => first_normal = Some(h),
(LayoutMode::Compact, Some(prev)) => {
panic!("height {h} went back to compact after normal at {prev}")
}
_ => {}
}
}
assert!(first_normal.is_some(), "never reached the normal layout");
}
#[test]
fn force_compact_overrides_a_tall_window() {
let l = compute(area(200, 80), true, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
#[test]
fn compact_drops_the_gpu_row_without_a_gpu() {
let with = compute(area(120, 24), true, true);
let without = compute(area(120, 24), true, false);
assert!(with.gpu.is_some());
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
assert!(without.gpu.is_none());
// The rows a GPU-less host saves are shared between the CPU panes and the
// bottom half, and none of them are left as a gap.
assert!(without.cpu.height > with.cpu.height);
assert!(without.procs.height > with.procs.height);
assert_eq!(without.procs.y + without.procs.height, 24);
}
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
/// content rows inside their borders.
#[test]
fn compact_keeps_the_cpu_panes_drawable() {
for h in 18..=32u16 {
let l = compute(area(120, h), false, true);
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
assert!(
l.cpu.height >= TOP_MIN_H,
"height {h}: cpu pane only {} rows",
l.cpu.height
);
assert_eq!(l.per_core.height, l.cpu.height);
}
}
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
/// left the top row with no drawable interior at all.
#[test]
fn compact_beats_the_fixed_layout_at_18_rows() {
let compact = compute(area(120, 18), false, true);
let fixed = normal(area(120, 18));
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
assert!(compact.cpu.height > fixed.cpu.height);
}
#[test]
fn compact_panes_tile_the_area_without_gaps() {
for h in 16..=32u16 {
for has_gpu in [true, false] {
let l = compute(area(120, h), true, has_gpu);
assert_eq!(l.header.y, 0);
assert_eq!(l.cpu.y, l.header.y + l.header.height);
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
let after_cpu = l.cpu.y + l.cpu.height;
let bottom_y = match l.gpu {
Some(g) => {
assert_eq!(g.y, after_cpu);
assert_eq!(g.width, 120, "gpu strip spans the full width");
g.y + g.height
}
None => after_cpu,
};
assert_eq!(l.mem.y, bottom_y);
// Memory and Swap sit side by side on one row.
assert_eq!(l.swap.y, l.mem.y);
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
assert_eq!(l.mem.height, GAUGE_H);
assert_eq!(l.download.y, l.mem.y + l.mem.height);
assert_eq!(l.upload.y, l.download.y + l.download.height);
assert_eq!(l.procs.y, bottom_y);
}
}
}
/// A degenerate size must not panic or produce rects outside the frame.
#[test]
fn tiny_windows_stay_inside_the_frame() {
for h in 0..=16u16 {
for w in [0u16, 1, 20, 80] {
let l = compute(area(w, h), false, true);
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
}
}
}
}
}
-2
View File
@@ -2,10 +2,8 @@
pub mod cpu;
pub mod disks;
pub mod fit;
pub mod gpu;
pub mod header;
pub mod layout;
pub mod mem;
pub mod modal;
pub mod modal_connection;
+76 -549
View File
@@ -1,10 +1,6 @@
//! Modal window system for socktop TUI application
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 super::theme::MODAL_DIM_BG;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
@@ -30,10 +26,6 @@ pub struct ModalManager {
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 {
pub fn new() -> Self {
Self {
@@ -91,52 +83,6 @@ impl ModalManager {
}
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>) {
if let Some(ModalType::ConnectionError {
auto_retry_countdown,
@@ -164,16 +110,6 @@ impl ModalManager {
self.prev_button();
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') => {
if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) {
ModalAction::RetryConnection
@@ -305,16 +241,7 @@ impl ModalManager {
ModalAction::Dismiss
}
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
(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::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
@@ -326,29 +253,12 @@ impl ModalManager {
self.active_button = match (&self.stack.last(), &self.active_button) {
(Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => ModalButton::Exit,
(Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalButton::Retry,
// 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::Confirm) => ModalButton::Cancel,
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm,
_ => self.active_button.clone(),
};
}
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();
}
@@ -370,150 +280,6 @@ 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) {
let area = f.area();
// Different sizes for different modal types
@@ -530,13 +296,6 @@ impl ModalManager {
// Help modal uses medium size
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
self.centered_rect(70, 50, area)
@@ -581,64 +340,86 @@ impl ModalManager {
confirm_text: &str,
cancel_text: &str,
) {
// Three buttons + a key hint line.
let footer = Self::render_dialog_frame(f, area, title, message, 3);
let rows = Layout::default()
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // buttons
Constraint::Length(1), // spacer
Constraint::Length(1), // key hints
])
.split(footer);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(rows[0]);
Self::render_button(
f,
cols[0],
confirm_text,
self.active_button == ModalButton::Confirm,
BTN_RETRY_BG_ACTIVE,
);
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,
);
.constraints([Constraint::Min(1), Constraint::Length(3)])
.split(area);
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.style(Style::default().bg(Color::Black));
f.render_widget(block, area);
f.render_widget(
Paragraph::new(CONFIRM_HINT)
.style(Style::default().fg(MODAL_FG).add_modifier(Modifier::DIM))
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)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let confirm_style = if self.active_button == ModalButton::Confirm {
Style::default()
.bg(Color::Green)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let cancel_style = if self.active_button == ModalButton::Cancel {
Style::default()
.bg(Color::Red)
.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),
rows[2],
buttons[0],
);
f.render_widget(
Paragraph::new(cancel_text)
.style(cancel_style)
.alignment(Alignment::Center),
buttons[1],
);
}
fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) {
let footer = Self::render_dialog_frame(f, area, title, message, 1);
Self::render_button(
f,
footer,
"Enter — OK",
self.active_button == ModalButton::Ok,
BTN_RETRY_BG_ACTIVE,
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3)])
.split(area);
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.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],
);
}
@@ -724,9 +505,6 @@ impl ModalManager {
" ↑/↓ ............ Select/navigate processes",
" Enter .......... Open Process Details",
" 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 row ...... Select process",
"",
@@ -854,254 +632,3 @@ impl ModalManager {
.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());
}
}
+19 -65
View File
@@ -95,7 +95,7 @@ impl ModalManager {
}
// Help line
let mut help_text = vec![Line::from(vec![
let help_text = vec![Line::from(vec![
Span::styled(
"X ",
Style::default()
@@ -129,23 +129,6 @@ impl ModalManager {
Span::styled("journal", Style::default().add_modifier(Modifier::DIM)),
])];
// Kill from here too — same key as the processes pane, and only shown
// when the agent is local, since that is the only case where it works.
if data.is_local
&& 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))
.alignment(Alignment::Center)
.style(Style::default());
@@ -489,28 +472,13 @@ impl ModalManager {
.borders(Borders::ALL);
let content_lines: Vec<Line> = if journal.entries.is_empty() {
let mut lines = vec![
vec![
Line::from(""),
Line::from(Span::styled(
"No journal entries found for this process",
Style::default().add_modifier(Modifier::DIM),
)),
];
// Access limits, not absence of logs: show journalctl's own hint
// (typical when the agent runs as an unprivileged user, e.g. demo
// mode) plus the practical fix.
if let Some(notice) = &journal.notice {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{notice}"),
Style::default().fg(Color::Yellow),
)));
lines.push(Line::from(Span::styled(
" Run the agent as a service (or a user in the systemd-journal group) for full journal access.",
Style::default().add_modifier(Modifier::DIM),
)));
}
lines
]
} else {
journal
.entries
@@ -656,29 +624,16 @@ impl ModalManager {
// labels + axis title + (top) Y-axis title + legend + spacing.
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
// Format a CPU-time value: whole ms once past 100, one decimal below.
let fmt_ms = |v: f64| {
if v >= 100.0 {
format!("{v:.0}")
} else {
format!("{v:.1}")
}
};
// Y-axis labels, right-aligned to the widest value this frame so the
// axis stays a straight line. The old fixed 4-char field predates the
// CPU-time unit fix; honest millisecond values (e.g. 136114) blew
// through it and skewed the whole axis.
let y_values: Vec<String> = (0..plot_height)
.map(|y| {
fmt_ms(params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64)))
})
.collect();
let y_label_w = y_values.iter().map(|s| s.len()).max().unwrap_or(4).max(4);
// Y-axis labels and plot content
let mut row_buf = String::with_capacity(plot_width);
for (y, y_value) in y_values.iter().enumerate() {
let y_label = format!("{y_value:>y_label_w$}");
for y in 0..plot_height {
let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64));
// 4-char fixed-width label so the axis doesn't shift as digits change.
let y_label = if y_value >= 100.0 {
format!("{y_value:>4.0}")
} else {
format!("{y_value:>4.1}")
};
// Build the row's char slice into a reusable String buffer.
row_buf.clear();
@@ -695,8 +650,8 @@ impl ModalManager {
]));
}
// Add X-axis (padding = Y label width + the space before the bar)
let x_axis_padding = " ".repeat(y_label_w + 1);
// Add X-axis
let x_axis_padding = " ".to_string(); // Match Y-axis label width
let x_axis_line = "".repeat(plot_width + 1);
lines.push(Line::from(vec![
Span::styled(x_axis_padding, Style::default()),
@@ -704,14 +659,13 @@ impl ModalManager {
]));
// Add X-axis labels
let x_label_start = fmt_ms(0.0);
let x_label_mid = fmt_ms(params.max_user / 2.0);
let x_label_end = fmt_ms(params.max_user);
let x_label_start = "0.0".to_string();
let x_label_mid = format!("{:.1}", params.max_user / 2.0);
let x_label_end = format!("{:.1}", params.max_user);
let spacing = plot_width / 3;
let x_labels = format!(
"{}{}{}{}{}{}",
" ".repeat(y_label_w + 1),
" {}{}{}{}{}",
x_label_start,
" ".repeat(spacing.saturating_sub(x_label_start.len())),
x_label_mid,
@@ -723,7 +677,7 @@ impl ModalManager {
// Add axis titles with better visibility
lines.push(Line::from(vec![Span::styled(
format!("{}User CPU Time (ms) →", " ".repeat(y_label_w + 1)),
" User CPU Time (ms) →",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
-13
View File
@@ -19,9 +19,6 @@ pub struct ProcessModalData<'a> {
pub history: ProcessHistoryData<'a>,
pub max_mem_bytes: u64,
pub unsupported: bool,
/// Whether the agent is on this machine. Only used to decide whether the
/// `t` kill hint is shown — the kill itself is gated in `App`.
pub is_local: bool,
}
/// Parameters for rendering scatter plot
@@ -67,15 +64,9 @@ pub enum ModalAction {
RetryConnection,
ExitApp,
Confirm,
/// Confirmation modal's second affirmative: the same action, escalated.
/// Used by the kill prompt for SIGKILL, where `Confirm` means SIGTERM.
ConfirmForce,
Cancel,
Dismiss,
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)]
@@ -83,10 +74,6 @@ pub enum ModalButton {
Retry,
Exit,
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,
Ok,
}
+116 -564
View File
@@ -5,14 +5,13 @@ use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
text::Span,
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
};
use std::cmp::Ordering;
use crate::types::Metrics;
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
use crate::ui::fit;
use crate::ui::theme::{
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
SB_THUMB, SB_TRACK,
@@ -87,9 +86,6 @@ pub struct ProcessDisplayParams<'a> {
/// Peak cpu_usage from the most recent cache build; used to bold the
/// busiest process. -1.0 if no cache.
pub peak_cpu: f32,
/// Agent is on this machine, so the `t` kill hint applies. Without it the
/// hint would advertise a key that deliberately does nothing.
pub is_local: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -138,83 +134,14 @@ pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
peak
}
const PID_W: u16 = 8;
const CPU_W: u16 = 8;
const MEM_W: u16 = 12;
const MEM_PCT_W: u16 = 8;
/// Columns the Name field needs to identify anything. Every other column is only added
/// once Name already has this much, so Name can no longer be squeezed to nothing.
const NAME_MIN_W: u16 = 8;
/// `Table::column_spacing`.
const COL_SPACING: u16 = 1;
/// Which process columns fit in the pane, and where they sit.
///
/// The table used to hand the layout solver a fixed, over-constrained set, so on a narrow
/// pane the solver crushed the percentage-sized Name column to nothing while the fixed
/// PID and Mem % columns kept their full width — losing the one field that identifies the
/// process while keeping the ones that do not.
///
/// Columns are now added in priority order as the pane widens, so they are shed in
/// reverse as it narrows: Name is unconditional, then CPU %, then Mem, then PID, and
/// Mem % last (it is derivable from Mem, so it is the least costly to lose).
///
/// Both the draw path and the header-click hit-testing build this from the same width, so
/// a sort click always lands on the column the user can actually see.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcColumns {
pub pid: bool,
pub cpu: bool,
pub mem: bool,
pub mem_pct: bool,
}
impl ProcColumns {
pub fn for_width(width: u16) -> Self {
// Each tier is the previous one plus a column and the gap before it.
let with_cpu = NAME_MIN_W + COL_SPACING + CPU_W;
let with_mem = with_cpu + COL_SPACING + MEM_W;
let with_pid = with_mem + COL_SPACING + PID_W;
let with_mem_pct = with_pid + COL_SPACING + MEM_PCT_W;
Self {
cpu: width >= with_cpu,
mem: width >= with_mem,
pid: width >= with_pid,
mem_pct: width >= with_mem_pct,
}
}
/// Column constraints in render order. Name takes whatever the others leave.
pub fn constraints(&self) -> Vec<Constraint> {
let mut c = Vec::with_capacity(5);
if self.pid {
c.push(Constraint::Length(PID_W));
}
c.push(Constraint::Fill(1)); // Name
if self.cpu {
c.push(Constraint::Length(CPU_W));
}
if self.mem {
c.push(Constraint::Length(MEM_W));
}
if self.mem_pct {
c.push(Constraint::Length(MEM_PCT_W));
}
c
}
/// Position of the CPU % column, which is clickable to sort. `None` when too narrow
/// to render it.
pub fn cpu_index(&self) -> Option<usize> {
self.cpu.then(|| 1 + usize::from(self.pid))
}
/// Position of the Mem column, which is clickable to sort.
pub fn mem_index(&self) -> Option<usize> {
self.mem
.then(|| 1 + usize::from(self.pid) + usize::from(self.cpu))
}
}
// Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID
Constraint::Percentage(40), // Name
Constraint::Length(8), // CPU %
Constraint::Length(12), // Mem
Constraint::Length(8), // Mem %
];
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
// Draw outer block and title
@@ -306,8 +233,6 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.fold(0.0_f32, f32::max)
};
let columns = ProcColumns::for_width(content.width);
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix];
@@ -379,29 +304,16 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.add_modifier(Modifier::BOLD);
}
let mut cells = Vec::with_capacity(5);
if columns.pid {
cells.push(
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
);
}
cells.push(ratatui::widgets::Cell::from(name_span));
if columns.cpu {
cells.push(
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
.style(Style::default().fg(cpu_fg)),
);
}
if columns.mem {
cells.push(ratatui::widgets::Cell::from(Span::raw(mem_span_text)));
}
if columns.mem_pct {
cells.push(
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
);
}
ratatui::widgets::Row::new(cells).style(emphasis)
ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(name_span),
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
.style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
])
.style(emphasis)
});
// Header with sort indicator
@@ -413,30 +325,16 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
ProcSortBy::MemDesc => "Mem •",
_ => "Mem",
};
let mut header_cells = Vec::with_capacity(5);
if columns.pid {
header_cells.push("PID");
}
header_cells.push("Name");
if columns.cpu {
header_cells.push(cpu_hdr);
}
if columns.mem {
header_cells.push(mem_hdr);
}
if columns.mem_pct {
header_cells.push("Mem %");
}
let header = ratatui::widgets::Row::new(header_cells).style(
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
// Render table inside content area (no borders here; outer block already drawn)
let table = Table::new(rows_iter, columns.constraints())
let table = Table::new(rows_iter, COLS.to_vec())
.header(header)
.column_spacing(COL_SPACING);
.column_spacing(1);
f.render_widget(table, content);
// Draw tooltip if a process is selected
@@ -453,60 +351,16 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
format!("PID {selected_pid}")
};
// Key hints, built as spans so the keys read as keys. `t` only appears
// for a local agent, since that is the only case where it does anything.
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.is_local {
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));
let hints_w: u16 = hints.iter().map(|s| fit::cols(&s.content)).sum();
// One row, borders on both sides, a space of padding each side.
let tooltip_text = format!("{process_info} | Enter for details • X to unselect");
let tooltip_width = tooltip_text.len() as u16 + 2; // Add padding
let tooltip_height = 3;
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);
// Position tooltip at bottom-right of the processes area
if area.width > tooltip_width + 2 && area.height > tooltip_height + 1 {
let tooltip_area = Rect {
x: area.x + area.width.saturating_sub(width + 1),
x: area.x + area.width.saturating_sub(tooltip_width + 1),
y: area.y + area.height.saturating_sub(tooltip_height + 1),
width,
width: tooltip_width,
height: tooltip_height,
};
@@ -516,10 +370,11 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.fg(PROCESS_TOOLTIP_FG),
);
f.render_widget(
Paragraph::new(Line::from(spans)).block(tooltip_block),
tooltip_area,
);
let tooltip_paragraph = Paragraph::new(tooltip_text)
.block(tooltip_block)
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(tooltip_paragraph, tooltip_area);
}
}
@@ -546,6 +401,7 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
}
}
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
/// Parameters for process key event handling
pub struct ProcessKeyParams<'a> {
pub selected_process_pid: &'a mut Option<u32>,
@@ -555,6 +411,16 @@ pub struct ProcessKeyParams<'a> {
pub filtered_indices: &'a [usize],
}
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
#[allow(dead_code)]
pub fn processes_handle_key(
scroll_offset: &mut usize,
key: crossterm::event::KeyEvent,
page_size: usize,
) {
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
}
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
use crossterm::event::KeyCode;
@@ -634,6 +500,74 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
}
}
/// Handle mouse for content scrolling and scrollbar dragging.
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality
#[allow(dead_code)]
pub fn processes_handle_mouse(
scroll_offset: &mut usize,
drag: &mut Option<crate::ui::cpu::PerCoreScrollDrag>,
mouse: MouseEvent,
area: Rect,
total_rows: usize,
) -> Option<ProcSortBy> {
// Inner and content areas (match draw_top_processes)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height == 0 || inner.width <= 2 {
return None;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
// Scrollbar interactions (click arrows/page/drag)
per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows);
// Wheel scrolling when inside the content
crate::ui::cpu::per_core_handle_mouse(scroll_offset, mouse, content, content.height as usize);
// Header click to change sort
let header_area = Rect {
x: content.x,
y: content.y,
width: content.width,
height: 1,
};
let inside_header = mouse.row == header_area.y
&& mouse.column >= header_area.x
&& mouse.column < header_area.x + header_area.width;
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split header into the same columns
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(COLS.to_vec())
.split(header_area);
if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
return Some(ProcSortBy::CpuDesc);
}
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
return Some(ProcSortBy::MemDesc);
}
}
// Clamp to valid range
per_core_clamp(
scroll_offset,
total_rows,
(content.height.saturating_sub(1)) as usize,
);
None
}
/// Parameters for process mouse event handling
pub struct ProcessMouseParams<'a> {
pub scroll_offset: &'a mut usize,
@@ -712,24 +646,15 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
&& params.mouse.column < header_area.x + header_area.width;
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split the header the same way the draw path did, so a click lands on the
// column actually on screen even when PID has been dropped.
let columns = ProcColumns::for_width(header_area.width);
// Split header into the same columns
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(columns.constraints())
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
.constraints(COLS.to_vec())
.split(header_area);
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
&& params.mouse.column >= cpu.x
&& params.mouse.column < cpu.x + cpu.width
{
if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width {
return Some(ProcSortBy::CpuDesc);
}
if let Some(mem) = columns.mem_index().map(|i| cols[i])
&& params.mouse.column >= mem.x
&& params.mouse.column < mem.x + mem.width
{
if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width {
return Some(ProcSortBy::MemDesc);
}
}
@@ -766,376 +691,3 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
);
None
}
#[cfg(test)]
mod column_tests {
use super::*;
use ratatui::layout::{Direction, Layout, Rect};
fn name_width(w: u16) -> u16 {
let c = ProcColumns::for_width(w);
let rects = Layout::default()
.direction(Direction::Horizontal)
.constraints(c.constraints())
.spacing(COL_SPACING)
.split(Rect::new(0, 0, w, 1));
rects[usize::from(c.pid)].width
}
/// The complaint this fixes: on a narrow pane the Name column was the first thing to
/// disappear, leaving a table of numbers with nothing to identify the process. Name
/// must now be the last column standing, at every width that can render anything.
#[test]
fn name_is_never_the_column_that_gets_dropped() {
for width in NAME_MIN_W..=200u16 {
assert!(
name_width(width) >= 1,
"width {width}: Name was squeezed to nothing"
);
}
}
/// Columns are shed in reverse priority order, so a narrower pane can never show a
/// column that a wider one hid.
#[test]
fn columns_are_shed_in_priority_order() {
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
assert!(!c.mem_pct || c.pid, "width {width}: Mem % outlived PID");
assert!(!c.pid || c.mem, "width {width}: PID outlived Mem");
assert!(!c.mem || c.cpu, "width {width}: Mem outlived CPU %");
}
}
/// Columns come back as the pane widens and never flap.
#[test]
fn columns_are_monotonic_in_width() {
let mut prev = ProcColumns::for_width(0);
for width in 1..=200u16 {
let c = ProcColumns::for_width(width);
for (was, now, name) in [
(prev.cpu, c.cpu, "CPU %"),
(prev.mem, c.mem, "Mem"),
(prev.pid, c.pid, "PID"),
(prev.mem_pct, c.mem_pct, "Mem %"),
] {
assert!(!was || now, "width {width}: {name} vanished as it widened");
}
prev = c;
}
}
/// The tiers, from a comfortable pane down to a very narrow one.
#[test]
fn narrow_panes_shed_columns_in_order() {
let full = ProcColumns::for_width(48);
assert_eq!(full.constraints().len(), 5);
assert!(full.pid && full.cpu && full.mem && full.mem_pct);
// Mem % goes first.
let c = ProcColumns::for_width(45);
assert!(c.pid && c.mem && !c.mem_pct);
// Then PID.
let c = ProcColumns::for_width(35);
assert!(!c.pid && c.cpu && c.mem);
// Then Mem, leaving the name and its CPU load.
let c = ProcColumns::for_width(20);
assert!(!c.mem && c.cpu);
assert_eq!(c.constraints().len(), 2);
// At the floor, just the name.
let c = ProcColumns::for_width(10);
assert!(!c.cpu && !c.mem);
assert_eq!(c.constraints().len(), 1);
}
/// Regression guard for the old behaviour: a 130-column terminal gives the process
/// pane ~48 columns, and every column still fits there.
#[test]
fn a_wide_terminal_keeps_the_full_table() {
assert_eq!(ProcColumns::for_width(48).constraints().len(), 5);
}
/// Sort clicks are resolved by index, so those indices must track the columns that
/// are actually rendered — otherwise clicking "CPU %" would sort by Mem.
#[test]
fn sort_indices_follow_the_rendered_columns() {
let wide = ProcColumns::for_width(48);
assert_eq!(wide.cpu_index(), Some(2)); // PID, Name, CPU %
assert_eq!(wide.mem_index(), Some(3));
let narrow = ProcColumns::for_width(35);
assert_eq!(narrow.cpu_index(), Some(1)); // Name, CPU %
assert_eq!(narrow.mem_index(), Some(2));
// A column that is not rendered has no index to click.
let tiny = ProcColumns::for_width(10);
assert_eq!(tiny.cpu_index(), None);
assert_eq!(tiny.mem_index(), None);
// Whatever the width, any index returned is inside the rendered set.
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
let n = c.constraints().len();
for i in [c.cpu_index(), c.mem_index()].into_iter().flatten() {
assert!(i < n, "width {width}: index {i} outside {n} columns");
}
}
}
/// Name takes the slack, so it grows with the pane instead of being pinned to a
/// percentage that the fixed columns can crush.
#[test]
fn name_absorbs_the_leftover_width() {
assert!(
name_width(80) > name_width(60),
"Name did not grow with the pane"
);
}
}
#[cfg(test)]
mod click_tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics() -> 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: "some-process".into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
/// Renders the pane and returns its header row as text.
fn header_row(width: u16) -> String {
let m = metrics();
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
let idxs = [0usize];
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
terminal
.draw(|f| {
draw_top_processes(
f,
Rect::new(0, 0, width, 8),
ProcessDisplayParams {
metrics: Some(&m),
scroll_offset: 0,
sort_by: ProcSortBy::CpuDesc,
selected_process_pid: None,
selected_process_index: None,
search_query: "",
search_active: false,
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
is_local: false,
},
)
})
.unwrap();
let buf = terminal.backend().buffer();
(0..width)
.map(|x| buf[(x, 1)].symbol().to_string())
.collect()
}
fn click(width: u16, column: u16) -> Option<ProcSortBy> {
let m = metrics();
let mut scroll = 0usize;
let mut drag = None;
let mut sel_pid = None;
let mut sel_idx = None;
let idxs = [0usize];
processes_handle_mouse_with_selection(ProcessMouseParams {
scroll_offset: &mut scroll,
selected_process_pid: &mut sel_pid,
selected_process_index: &mut sel_idx,
drag: &mut drag,
mouse: MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: 1,
modifiers: KeyModifiers::NONE,
},
area: Rect::new(0, 0, width, 8),
total_rows: 1,
metrics: Some(&m),
search_box_visible: false,
filtered_indices: &idxs,
})
}
/// The hit-test rects are computed by a separate `Layout` call from the one `Table`
/// renders with. This walks the rendered header text and clicks each label where it
/// actually appears, which catches any drift between the two — including column
/// spacing, which the two APIs configure differently.
#[test]
fn clicking_a_rendered_sort_header_sorts_by_that_column() {
for width in [40u16, 50, 60, 80, 120] {
let row = header_row(width);
let cpu_at = row.find("CPU").map(|i| row[..i].chars().count() as u16);
let mem_at = row.find("Mem").map(|i| row[..i].chars().count() as u16);
if let Some(x) = cpu_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::CpuDesc),
"width {width}: clicking the rendered 'CPU %' header at column {x} \
did not sort by CPU (header row: {row:?})"
);
}
if let Some(x) = mem_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::MemDesc),
"width {width}: clicking the rendered 'Mem' header at column {x} \
did not sort by Mem (header row: {row:?})"
);
}
}
}
/// Name is what identifies the row, so it must be rendered at every width the pane
/// can draw anything at.
#[test]
fn the_name_column_is_rendered_even_when_narrow() {
for width in [30u16, 40, 60, 120] {
let row = header_row(width);
assert!(
row.contains("Name"),
"width {width}: no Name column in header {row:?}"
);
}
}
}
#[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, is_local: 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,
is_local,
},
)
})
.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}"
);
}
}
+13
View File
@@ -22,6 +22,19 @@ pub fn human(b: u64) -> String {
format!("{tb:.2}TB")
}
pub fn truncate_middle(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
if max <= 3 {
return "...".into();
}
let keep = max - 3;
let left = keep / 2;
let right = keep - left;
format!("{}...{}", &s[..left], &s[s.len() - right..])
}
pub fn disk_icon(name: &str) -> &'static str {
let n = name.to_ascii_lowercase();
if n.contains(':') {
View File
-33
View File
@@ -73,36 +73,3 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
);
assert!(text3.contains("Usage:"));
}
#[test]
fn test_compact_flag_documented_and_accepted() {
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--compact", "--help"])
.output()
.expect("run socktop --compact --help");
assert!(
out.status.success(),
"socktop --compact --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("--compact"),
"help text missing --compact\n{text}"
);
// The flag must not be mistaken for the positional URL argument.
let out2 = Command::new(exe)
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop --compact --dry-run");
assert!(
out2.status.success(),
"socktop --compact with a URL was rejected: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
-1
View File
@@ -8,7 +8,6 @@ static ENV_LOCK: Mutex<()> = Mutex::new(());
#[allow(dead_code)] // touch crate
fn touch() {
let _ = socktop::types::Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 0,
+7 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop_agent"
version = "1.60.0"
version = "1.50.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024"
@@ -10,10 +10,11 @@ homepage = "https://github.com/jasonwitty/socktop"
repository = "https://github.com/jasonwitty/socktop"
[dependencies]
# Tokio: minimal features instead of "full" to reduce binary size.
# rt-multi-thread (runtime), net (WebSocket), sync (Mutex/oneshot),
# macros (#[tokio::test]), process (async journalctl).
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "process"] }
# Tokio: Use minimal features instead of "full" to reduce binary size
# Only include: rt-multi-thread (async runtime), net (WebSocket), sync (Mutex/RwLock), macros (#[tokio::test])
# Excluded: io, fs, process, signal, time (not needed for this workload)
# Savings: ~200-300KB binary size, faster compile times
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] }
axum = { version = "0.7", features = ["ws", "macros"] }
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] }
@@ -23,10 +24,6 @@ futures-util = "0.3.31"
tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
gfxinfo = { version = "0.1.2", optional = true }
# Direct NVML fallback for distros that ship only libnvidia-ml.so.1 (Debian
# and derivatives) — gfxinfo's default init dlopens the unversioned name.
# Same version gfxinfo already pulls in, so this adds no new build cost.
nvml-wrapper = { version = "0.10", optional = true }
once_cell = "1.19"
axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = { version = "0.23", features = ["aws-lc-rs"] }
@@ -39,7 +36,7 @@ time = { version = "0.3", default-features = false, features = ["formatting", "m
[features]
default = ["gpu"]
gpu = ["gfxinfo", "nvml-wrapper"]
gpu = ["gfxinfo"]
logging = ["tracing", "tracing-subscriber"]
[build-dependencies]
+4 -6
View File
@@ -1,15 +1,13 @@
fn main() {
// Vendored protoc for reproducible builds
let protoc = protoc_bin_vendored::protoc_bin_path().expect("protoc");
println!("cargo:rerun-if-changed=proto/processes.proto");
// Compile protobuf definitions for processes
let mut cfg = prost_build::Config::new();
cfg.out_dir(std::env::var("OUT_DIR").unwrap());
// Vendored protoc for reproducible builds where available. It ships no
// riscv64 binary, so on such hosts fall through to $PROTOC / PATH
// (prost-build's default lookup) — apt: protobuf-compiler.
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
cfg.protoc_executable(protoc);
}
cfg.protoc_executable(protoc); // Use the vendored protoc directly
// Use local path (ensures file is inside published crate tarball)
cfg.compile_protos(&["proto/processes.proto"], &["proto"]) // relative to CARGO_MANIFEST_DIR
.expect("compile protos");
+17 -110
View File
@@ -1,4 +1,6 @@
// gpu.rs
#[cfg(feature = "gpu")]
use gfxinfo::active_gpu;
#[derive(Debug, Clone, serde::Serialize)]
pub struct GpuMetrics {
@@ -8,118 +10,23 @@ pub struct GpuMetrics {
pub mem_total_bytes: u64,
}
/// Collect metrics for the active GPU. `None` when there is no usable GPU.
///
/// Runs on a dedicated worker thread (see `worker`): gfxinfo's handle holds
/// an `Rc<Nvml>` (not `Send`), and *creating* it runs a full NVML library
/// init — ~20ms of blocking work that used to execute on the async runtime
/// for every collection. The worker owns one handle for the process lifetime,
/// so steady-state collection is just NVML queries. Measured on an RTX 5080
/// box, re-initing per collect was ~80% of the agent's entire active CPU.
#[cfg(feature = "gpu")]
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
worker::collect().await
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
let gpu = active_gpu()?; // Use ? to unwrap Result
let info = gpu.info();
let metrics = GpuMetrics {
name: gpu.model().to_string(),
utilization_gpu_pct: info.load_pct() as u32,
mem_used_bytes: info.used_vram(),
mem_total_bytes: info.total_vram(),
};
Ok(vec![metrics])
}
#[cfg(not(feature = "gpu"))]
pub async fn collect_all_gpus() -> Option<Vec<GpuMetrics>> {
None
}
#[cfg(feature = "gpu")]
mod worker {
use super::GpuMetrics;
use once_cell::sync::OnceCell;
use std::sync::mpsc;
type Reply = tokio::sync::oneshot::Sender<Option<Vec<GpuMetrics>>>;
static TX: OnceCell<mpsc::Sender<Reply>> = OnceCell::new();
pub async fn collect() -> Option<Vec<GpuMetrics>> {
let tx = TX.get_or_init(spawn);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
tx.send(reply_tx).ok()?;
reply_rx.await.ok().flatten()
}
fn spawn() -> mpsc::Sender<Reply> {
let (tx, rx) = mpsc::channel::<Reply>();
std::thread::Builder::new()
.name("socktop-gpu".into())
.spawn(move || run(rx))
.expect("spawn gpu worker thread");
tx
}
enum Handle {
/// gfxinfo's own detection (AMD sysfs, NVIDIA via unversioned NVML).
Gfx(Box<dyn gfxinfo::Gpu>),
/// Direct NVML with an explicit versioned soname. Debian & friends
/// ship only libnvidia-ml.so.1 (the unversioned symlink lives in the
/// dev package), so gfxinfo's default dlopen fails there even though
/// the driver is fully functional.
Nvml(Box<nvml_wrapper::Nvml>),
}
fn probe() -> Option<Handle> {
if let Ok(g) = gfxinfo::active_gpu() {
return Some(Handle::Gfx(g));
}
nvml_wrapper::Nvml::builder()
.lib_path(std::ffi::OsStr::new("libnvidia-ml.so.1"))
.init()
.ok()
.map(|nvml| Handle::Nvml(Box::new(nvml)))
}
fn collect_from(handle: &Handle) -> Option<Vec<GpuMetrics>> {
match handle {
Handle::Gfx(gpu) => {
let info = gpu.info();
Some(vec![GpuMetrics {
name: gpu.model().to_string(),
utilization_gpu_pct: info.load_pct().clamp(0, 100),
mem_used_bytes: info.used_vram(),
mem_total_bytes: info.total_vram(),
}])
}
Handle::Nvml(nvml) => {
let device = nvml.device_by_index(0).ok()?;
let mem = device.memory_info().ok()?;
Some(vec![GpuMetrics {
name: device.name().unwrap_or_else(|_| "NVIDIA GPU".into()),
utilization_gpu_pct: device
.utilization_rates()
.map(|u| u.gpu.clamp(0, 100))
.unwrap_or(0),
mem_used_bytes: mem.used,
mem_total_bytes: mem.total,
}])
}
}
}
fn run(rx: mpsc::Receiver<Reply>) {
let mut handle: Option<Handle> = None;
// Probing failed: remember and answer None without re-initing the GPU
// stack per request. The agent's negative cache stops asking anyway.
let mut probe_failed = false;
while let Ok(reply) = rx.recv() {
if handle.is_none() && !probe_failed {
handle = probe();
probe_failed = handle.is_none();
}
let out = handle.as_ref().and_then(collect_from);
// A live GPU cannot report 0 total VRAM; zeros mean the session
// died (e.g. driver reload). Drop the handle so the next request
// re-probes.
if let Some(v) = &out
&& !v.is_empty()
&& v.iter().all(|g| g.mem_total_bytes == 0)
{
handle = None;
}
let _ = reply.send(out.filter(|v| !v.is_empty()));
}
}
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
// GPU support not available on this platform
Ok(vec![])
}
+272 -283
View File
@@ -13,60 +13,49 @@ use std::collections::HashMap;
use std::fs;
#[cfg(target_os = "linux")]
use std::io;
use std::process::Command;
use std::sync::Mutex;
use std::time::Duration as StdDuration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
#[cfg(feature = "logging")]
use tracing::warn;
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
/// Shared parsing for `/proc/<pid>/stat` (and per-thread `task/<tid>/stat`).
///
/// The second field, `comm`, can contain arbitrary bytes including spaces and
/// parentheses, so naive whitespace splitting mis-parses such names. All
/// callers step past the LAST `')'` and index the remaining space-separated
/// fields from there: 0 = state, 1 = ppid, 11 = utime, 12 = stime,
/// 19 = starttime.
// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go.
// Returns (0, 0) if the file can't be read.
//
// We use `rfind(')')` to step past the `comm` field, which can contain
// arbitrary characters (including spaces and parens), then index the
// post-comm fields by position. This is the same trick `read_proc_jiffies`
// uses below — `split_whitespace().collect::<Vec<_>>()` from the start of
// the file would mis-parse process names with spaces, and also wastes an
// allocation per call. Two callers used to read this file twice (once for
// user, once for system); now it's one syscall per detailed-process record.
#[cfg(target_os = "linux")]
mod procstat {
/// Everything after `") "` — the post-comm fields.
pub fn after_comm(stat: &str) -> Option<&str> {
stat.get(stat.rfind(')')? + 2..)
}
pub fn field(stat: &str, n: usize) -> Option<&str> {
after_comm(stat)?.split_whitespace().nth(n)
}
/// (utime, stime) in clock ticks.
pub fn utime_stime(stat: &str) -> Option<(u64, u64)> {
let mut it = after_comm(stat)?.split_whitespace();
let utime = it.nth(11)?.parse().ok()?;
let stime = it.next()?.parse().ok()?;
Some((utime, stime))
}
/// One clock tick at USER_HZ=100 (universal on Linux) in microseconds.
pub const TICK_US: u64 = 10_000;
}
// Read (utime, stime) in MICROSECONDS from /proc/{pid}/stat in one syscall.
// Returns (0, 0) if the file can't be read. Units match the wire contract
// (`DetailedProcessInfo.cpu_time_user` is documented as µs) and the thread
// records — this used to return ms, making process/child CPU times render
// 1000x too small next to thread times.
#[cfg(target_os = "linux")]
fn get_cpu_times_us(pid: u32) -> (u64, u64) {
fn get_cpu_times_ms(pid: u32) -> (u64, u64) {
let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
return (0, 0);
};
let Some((utime, stime)) = procstat::utime_stime(&s) else {
let Some(rpar) = s.rfind(')') else {
return (0, 0);
};
(utime * procstat::TICK_US, stime * procstat::TICK_US)
let Some(after) = s.get(rpar + 2..) else {
return (0, 0);
};
let mut it = after.split_whitespace();
// Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid,
// flags, minflt, cminflt, majflt, cmajflt, utime, stime, ...
// utime is offset 11; stime follows.
let utime = it.nth(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
// 1 tick = 10ms at 100 Hz (USER_HZ).
(utime * 10, stime * 10)
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_times_us(_pid: u32) -> (u64, u64) {
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
(0, 0)
}
// Runtime toggles (read once)
@@ -128,32 +117,46 @@ fn name_cache_cleanup_threshold() -> usize {
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms.
//
// The cached type is Option<...>: a fresh `None` means "we looked recently
// and found nothing" — machines with no matching sensor/GPU no longer rescan
// on every request, only once per TTL.
// Tiny TTL caches to avoid rescanning sensors every 500ms
const TTL: Duration = Duration::from_millis(1500);
static TEMP: crate::state::TtlCell<Option<f32>> = crate::state::TtlCell::new();
static GPUS: crate::state::TtlCell<Option<Vec<crate::gpu::GpuMetrics>>> =
crate::state::TtlCell::new();
struct TempCache {
at: Option<Instant>,
v: Option<f32>,
}
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
// Gate on `state.components` refreshes (hwmon scans). Both
// Last time `state.components` was refreshed (by any caller). Both
// collect_fast_metrics and collect_disks need fresh sensor values; without
// this they each paid the hwmon syscall cost on their own cadence. 1s keeps
// disk temps accurate (they change slowly) while suppressing back-to-back
// refreshes from concurrent endpoints.
// this gate they were each doing their own `Components::refresh` on their
// own cadence, paying the hwmon syscall cost twice per polling cycle.
// 1s is short enough that disk temps stay accurate (they change slowly) and
// long enough to suppress back-to-back refreshes from concurrent endpoints.
const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000);
static COMPONENTS_STAMP: crate::state::TtlCell<()> = crate::state::TtlCell::new();
static COMPONENTS_LAST_REFRESH: OnceCell<Mutex<Option<Instant>>> = OnceCell::new();
/// Refresh `state.components` at most once per `COMPONENTS_REFRESH_TTL`.
/// Caller must already hold the components lock.
/// Refresh `state.components` only if the cached refresh timestamp is older
/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components
/// lock.
fn refresh_components_if_stale(components: &mut sysinfo::Components) {
if COMPONENTS_STAMP.claim_stale(COMPONENTS_REFRESH_TTL) {
let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None));
let mut last = match lock.lock() {
Ok(g) => g,
Err(_) => return, // Poisoned — skip; values stay as-is until next call
};
let now = Instant::now();
let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL);
if stale {
components.refresh(false);
*last = Some(now);
}
}
struct GpuCache {
at: Option<Instant>,
v: Option<Vec<crate::gpu::GpuMetrics>>,
}
static GPUC: OnceCell<Mutex<GpuCache>> = OnceCell::new();
// Static caches for unchanging data
static HOSTNAME: OnceCell<String> = OnceCell::new();
struct NetworkNameCache {
@@ -163,6 +166,54 @@ struct NetworkNameCache {
static NETWORK_CACHE: OnceCell<Mutex<NetworkNameCache>> = OnceCell::new();
static CPU_VEC: OnceCell<Mutex<Vec<f32>>> = OnceCell::new();
fn cached_temp() -> Option<f32> {
if !temp_enabled() {
return None;
}
let now = Instant::now();
let lock = TEMP.get_or_init(|| Mutex::new(TempCache { at: None, v: None }));
let mut c = lock.lock().ok()?;
if c.at.is_none_or(|t| now.duration_since(t) >= TTL) {
c.at = Some(now);
// caller will fill this; we just hold a slot
c.v = None;
}
c.v
}
fn set_temp(v: Option<f32>) {
if let Some(lock) = TEMP.get()
&& let Ok(mut c) = lock.lock()
{
c.v = v;
c.at = Some(Instant::now());
}
}
fn cached_gpus() -> Option<Vec<crate::gpu::GpuMetrics>> {
if !gpu_enabled() {
return None;
}
let now = Instant::now();
let lock = GPUC.get_or_init(|| Mutex::new(GpuCache { at: None, v: None }));
let mut c = lock.lock().ok()?;
if c.at.is_none_or(|t| now.duration_since(t) >= TTL) {
// mark stale; caller will refresh
c.at = Some(now);
c.v = None;
}
c.v.clone()
}
fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
if let Some(lock) = GPUC.get()
&& let Ok(mut c) = lock.lock()
{
c.v = v.clone();
c.at = Some(Instant::now());
}
}
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let ttl = StdDuration::from_millis(metrics_ttl_ms());
@@ -202,13 +253,10 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let swap_used = sys.used_swap();
drop(sys);
// CPU temperature: only rescan sensors when the cached result (even a
// cached "no sensor found") goes stale.
let cpu_temp_c = if !temp_enabled() {
None
} else if let Some(cached) = TEMP.get_fresh(TTL) {
cached
} else {
// CPU temperature: only refresh sensors if cache is stale
let cpu_temp_c = if cached_temp().is_some() {
cached_temp()
} else if temp_enabled() {
let val = {
let mut components = state.components.lock().await;
refresh_components_if_stale(&mut components);
@@ -225,8 +273,10 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
}
})
};
TEMP.set(val);
set_temp(val);
val
} else {
None
};
// Networks with reusable name cache
@@ -270,37 +320,47 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
cache.infos.clone()
};
// GPUs: negative-probe cache short-circuits GPU-less hosts; otherwise the
// TTL cache answers, and only a stale miss reaches the worker thread.
let gpus = if !gpu_enabled()
|| (state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed))
{
None
} else if let Some(cached) = GPUS.get_fresh(TTL) {
cached
} else {
let v = collect_all_gpus().await;
// First probe records presence; subsequent calls rely on the flags.
if !state
.gpu_checked
.swap(true, std::sync::atomic::Ordering::AcqRel)
// GPUs: if we already determined none exist, short-circuit (no repeated probing)
let gpus = if gpu_enabled() {
if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)
{
state
.gpu_present
.store(v.is_some(), std::sync::atomic::Ordering::Release);
None
} else if cached_gpus().is_some() {
cached_gpus()
} else {
let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None,
Err(_e) => {
#[cfg(feature = "logging")]
warn!("gpu collection failed: {_e}");
None
}
};
// First probe records presence; subsequent calls rely on cache flags.
if !state
.gpu_checked
.swap(true, std::sync::atomic::Ordering::AcqRel)
{
if v.is_some() {
state
.gpu_present
.store(true, std::sync::atomic::Ordering::Release);
} else {
state
.gpu_present
.store(false, std::sync::atomic::Ordering::Release);
}
}
set_gpus(v.clone());
v
}
GPUS.set(v.clone());
v
} else {
None
};
let sampled_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let metrics = Metrics {
sampled_at_ms,
cpu_total,
cpu_per_core,
mem_total,
@@ -321,48 +381,6 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
metrics
}
/// Best-effort parent-disk name for a partition device name:
/// "nvme0n1p1" -> "nvme0n1", "mmcblk0p2" -> "mmcblk0", "sda1" -> "sda".
/// Works with or without a "/dev/" prefix.
fn parent_disk_name(name: &str) -> &str {
if let Some(pos) = name.rfind('p') {
let suffix = &name[pos + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
return &name[..pos];
}
}
name.trim_end_matches(|c: char| c.is_ascii_digit())
}
/// Whether a device name refers to a partition rather than a whole disk.
///
/// On Linux, whole-disk devices are directories under /sys/block and
/// partitions are not, so "is a partition" = "not in /sys/block, but the
/// derived parent is". This gets right the cases the old name heuristic got
/// wrong: a whole-disk filesystem on nvme0n1 (ends in a digit but IS in
/// /sys/block) and zram1 (a whole device). Non-Linux keeps the heuristic.
fn is_partition_name(name: &str) -> bool {
let bare = name.strip_prefix("/dev/").unwrap_or(name);
#[cfg(target_os = "linux")]
{
let sys_block = std::path::Path::new("/sys/block");
if sys_block.is_dir() {
return !sys_block.join(bare).is_dir()
&& sys_block.join(parent_disk_name(bare)).is_dir();
}
}
is_partition_heuristic(bare)
}
/// Name-based fallback for platforms without /sys/block: a p<digits> marker
/// or a trailing non-zero digit.
fn is_partition_heuristic(bare: &str) -> bool {
bare.contains("p1")
|| bare.contains("p2")
|| bare.contains("p3")
|| bare.ends_with(|c: char| c.is_ascii_digit() && c != '0')
}
// Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let ttl = StdDuration::from_millis(disks_ttl_ms());
@@ -427,7 +445,19 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
return None;
}
let is_partition = is_partition_name(&name);
// Determine if this is a partition
let is_partition = name.contains("p1")
|| name.contains("p2")
|| name.contains("p3")
|| name.ends_with('1')
|| name.ends_with('2')
|| name.ends_with('3')
|| name.ends_with('4')
|| name.ends_with('5')
|| name.ends_with('6')
|| name.ends_with('7')
|| name.ends_with('8')
|| name.ends_with('9');
// Try to find temperature for this disk
let temperature = disk_temps.iter().find_map(|(key, &temp)| {
@@ -461,7 +491,25 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
for partition in &partitions {
if partition.is_partition {
let parent_name = parent_disk_name(&partition.name);
// Extract parent disk name
// nvme0n1p1 -> nvme0n1, sda1 -> sda, mmcblk0p1 -> mmcblk0
let parent_name = if let Some(pos) = partition.name.rfind('p') {
// Check if character after 'p' is a digit
if partition
.name
.chars()
.nth(pos + 1)
.is_some_and(|c| c.is_ascii_digit())
{
&partition.name[..pos]
} else {
// Handle sda1, sdb2, etc (just trim trailing digit)
partition.name.trim_end_matches(char::is_numeric)
}
} else {
// Handle sda1, sdb2, etc (just trim trailing digit)
partition.name.trim_end_matches(char::is_numeric)
};
// Look up temperature for the PARENT disk, not the partition
// Strip /dev/ prefix if present for matching
@@ -505,7 +553,21 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// Add partitions after their parent disk
for partition in partitions {
if partition.is_partition {
let parent_name = parent_disk_name(&partition.name);
// Find parent disk index
let parent_name = if let Some(pos) = partition.name.rfind('p') {
if partition
.name
.chars()
.nth(pos + 1)
.is_some_and(|c| c.is_ascii_digit())
{
&partition.name[..pos]
} else {
partition.name.trim_end_matches(char::is_numeric)
}
} else {
partition.name.trim_end_matches(char::is_numeric)
};
// Find where to insert this partition (after its parent)
if let Some(parent_idx) = disks.iter().position(|d| d.name == parent_name) {
@@ -557,8 +619,15 @@ fn read_total_jiffies() -> io::Result<u64> {
#[cfg(target_os = "linux")]
#[inline]
fn read_proc_jiffies(pid: u32) -> Option<u64> {
let s = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (utime, stime) = procstat::utime_stime(&s)?;
let path = format!("/proc/{pid}/stat");
let s = fs::read_to_string(path).ok()?;
// Find the right parenthesis that terminates comm; everything after is space-separated fields starting at "state"
let rpar = s.rfind(')')?;
let after = s.get(rpar + 2..)?; // skip ") "
let mut it = after.split_whitespace();
// utime (14th field) is offset 11 from "state", stime (15th) is next
let utime = it.nth(11)?.parse::<u64>().ok()?;
let stime = it.next()?.parse::<u64>().ok()?;
Some(utime.saturating_add(stime))
}
@@ -588,17 +657,9 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// filter when downgrading to a minimal refresh spec.
let mut sys_guard = state.sys.lock().await;
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(
ProcessesToUpdate::All,
true,
false,
ProcessRefreshKind::nothing().with_memory().without_tasks(),
);
@@ -727,11 +788,8 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
//JW too complicated. simplify to remove strange behavior
// For active systems, get accurate CPU metrics.
// `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());
// For active systems, get accurate CPU metrics
sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind.with_cpu());
// } else {
// // For idle systems, just get basic process info
@@ -760,12 +818,9 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
};
// Convert to percentage of total CPU capacity
// e.g., 100% on 2 cores of 8 core system = 25% total CPU.
// sysinfo reports per-core percentage which EXCEEDS 100 for
// multi-threaded processes, so clamp AFTER dividing — clamping
// first truncated e.g. 400%-on-8-cores to 12.5% instead of 50%.
let raw = p.cpu_usage();
let total_cpu = (raw / cpu_count.max(1.0)).clamp(0.0, 100.0);
// e.g., 100% on 2 cores of 8 core system = 25% total CPU
let raw = p.cpu_usage(); // This is per-core percentage
let total_cpu = raw.clamp(0.0, 100.0) / cpu_count;
proc_cache.reusable_vec.push(ProcessInfo {
pid,
@@ -929,8 +984,15 @@ fn proc_state_label(c: char) -> &'static str {
#[cfg(target_os = "linux")]
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// Post-comm field 1 is ppid.
procstat::field(&stat, 1)?.parse::<u32>().ok()
// Format: pid (comm) state ppid ... — comm can contain spaces/parens,
// so we step past the closing paren first.
let ppid_start = stat.rfind(')')?;
// After ") ": state, ppid, ... — ppid is the second field.
stat[ppid_start + 1..]
.split_whitespace()
.nth(1)?
.parse::<u32>()
.ok()
}
/// Collect process information from /proc files
@@ -973,9 +1035,16 @@ fn collect_process_info_from_proc(
let thread_count = st.threads;
let status = proc_state_label(st.state_ch).to_string();
// starttime is post-comm field 19.
// Read start time from stat — comm-safe via rfind(')').
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
procstat::field(&stat, 19)?.parse::<u64>().ok()?
let stat_end = stat.rfind(')')?;
// After ") ": state, ppid, ..., starttime — starttime is the 20th
// post-comm field (index 19).
stat[stat_end + 1..]
.split_whitespace()
.nth(19)?
.parse::<u64>()
.ok()?
} else {
0
};
@@ -1010,7 +1079,7 @@ fn collect_process_info_from_proc(
.map(|p| p.to_string_lossy().to_string());
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_us(pid);
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
Some(DetailedProcessInfo {
pid,
@@ -1123,7 +1192,18 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
continue;
};
let status = procstat::field(&stat_content, 0)
// Thread/comm names can contain spaces or parens, so step past the
// last ')' before parsing post-comm fields. Post-comm offsets:
// 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime
let Some(rpar) = stat_content.rfind(')') else {
continue;
};
let Some(after) = stat_content.get(rpar + 1..) else {
continue;
};
let mut it = after.split_whitespace();
let status = it
.next()
.and_then(|s| s.chars().next())
.map(|c| match c {
'R' => "Running",
@@ -1138,9 +1218,14 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.unwrap_or("Unknown")
.to_string();
let (utime, stime) = procstat::utime_stime(&stat_content).unwrap_or((0, 0));
let cpu_time_user = utime * procstat::TICK_US;
let cpu_time_system = stime * procstat::TICK_US;
// 10 fields between state and utime (ppid..cmajflt).
let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
// Convert clock ticks to microseconds (assuming 100 Hz)
// 1 tick = 10ms = 10,000 microseconds
let cpu_time_user = utime * 10_000;
let cpu_time_system = stime * 10_000;
threads.push(crate::types::ThreadInfo {
tid,
@@ -1172,17 +1257,10 @@ pub async fn collect_process_metrics(
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]),
false,
// cmd/exe/cwd feed the modal's Command & Details pane. They're
// immutable per process, so OnlyIfNotSet reads them once per PID and
// serves the cache afterwards — the "minimal refresh" optimization
// had dropped them entirely, leaving the pane blank.
ProcessRefreshKind::nothing()
.with_memory()
.with_cpu()
.with_disk_usage()
.with_cmd(sysinfo::UpdateKind::OnlyIfNotSet)
.with_exe(sysinfo::UpdateKind::OnlyIfNotSet)
.with_cwd(sysinfo::UpdateKind::OnlyIfNotSet),
.with_disk_usage(),
);
let process = system
@@ -1272,7 +1350,7 @@ pub async fn collect_process_metrics(
let threads = collect_thread_info(pid);
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_us(pid);
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
// Now construct the detailed info without holding the lock
let detailed_info = DetailedProcessInfo {
@@ -1306,26 +1384,9 @@ pub async fn collect_process_metrics(
})
}
/// Epoch microseconds -> RFC 3339 UTC for display. The old code
/// Debug-formatted a SystemTime and string-replaced it into a raw epoch
/// string that was neither ISO 8601 nor what the field documented.
fn format_journal_timestamp(timestamp_us: u64) -> String {
time::OffsetDateTime::from_unix_timestamp_nanos(timestamp_us as i128 * 1000)
.ok()
.and_then(|t| {
t.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_else(|| timestamp_us.to_string())
}
/// Collect journal entries for a specific process.
///
/// Async via tokio::process — journalctl can take hundreds of ms on slow
/// storage, and the old std::process call blocked one of the runtime's two
/// worker threads for the duration.
pub async fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String> {
let output = tokio::process::Command::new("journalctl")
/// Collect journal entries for a specific process
pub fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String> {
let output = Command::new("journalctl")
.args([
&format!("_PID={pid}"),
"--output=json",
@@ -1333,7 +1394,6 @@ pub async fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String
"--no-pager",
])
.output()
.await
.map_err(|e| format!("Failed to execute journalctl: {e}"))?;
if !output.status.success() {
@@ -1355,14 +1415,27 @@ pub async fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String
let json: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("Failed to parse journal JSON: {e}"))?;
// __REALTIME_TIMESTAMP is epoch microseconds as a string.
let timestamp_us = json
// Extract relevant fields
let timestamp_str = json
.get("__REALTIME_TIMESTAMP")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
.unwrap_or("0");
let timestamp = format_journal_timestamp(timestamp_us);
// Convert timestamp to ISO 8601 format
let timestamp = if let Ok(ts_micros) = timestamp_str.parse::<u64>() {
let ts_secs = ts_micros / 1_000_000;
let ts_nanos = (ts_micros % 1_000_000) * 1000;
let time = SystemTime::UNIX_EPOCH
+ Duration::from_secs(ts_secs)
+ Duration::from_nanos(ts_nanos);
// Simple ISO 8601 format - we can improve this if needed
format!("{time:?}")
.replace("SystemTime { tv_sec: ", "")
.replace(", tv_nsec: ", ".")
.replace(" }", "")
} else {
timestamp_str.to_string()
};
let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) {
Some("0") => LogLevel::Emergency,
@@ -1409,7 +1482,6 @@ pub async fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String
entries.push(JournalEntry {
timestamp,
timestamp_us,
priority,
message,
unit,
@@ -1421,26 +1493,7 @@ pub async fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String
}
// Sort by timestamp (newest first)
entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp_us));
// journalctl exits 0 with no output when the invoking user simply cannot
// SEE the process's entries (e.g. a user-run agent asking about a system
// service) — but it explains itself on stderr ("You are currently not
// seeing messages from other users and the system…"). Pass that hint
// along so the client can distinguish "no logs" from "no access".
let notice = if entries.is_empty() {
let err = String::from_utf8_lossy(&output.stderr);
let hint: String = err
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.take(2)
.collect::<Vec<_>>()
.join(" ");
if hint.is_empty() { None } else { Some(hint) }
} else {
None
};
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
let response_timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1454,70 +1507,6 @@ pub async fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String
entries,
total_count,
truncated,
notice,
cached_at: response_timestamp,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// comm can contain spaces and parens; parsing must key off the LAST ')'.
#[cfg(target_os = "linux")]
#[test]
fn procstat_handles_hostile_comm_names() {
let stat = "1234 (weird name) (2)) R 1 2 3 4 5 6 7 8 9 10 700 800 0 0 20";
assert_eq!(procstat::field(stat, 0), Some("R"));
assert_eq!(procstat::field(stat, 1), Some("1"));
assert_eq!(procstat::utime_stime(stat), Some((700, 800)));
}
/// USER_HZ ticks convert to MICROSECONDS — the wire contract. This used
/// to be *10 (ms), rendering process CPU times 1000x too small next to
/// thread times.
#[cfg(target_os = "linux")]
#[test]
fn cpu_times_are_microseconds() {
assert_eq!(procstat::TICK_US, 10_000);
}
#[test]
fn parent_disk_name_strips_partition_suffixes() {
assert_eq!(parent_disk_name("nvme0n1p1"), "nvme0n1");
assert_eq!(parent_disk_name("nvme1n1p12"), "nvme1n1");
assert_eq!(parent_disk_name("mmcblk0p2"), "mmcblk0");
assert_eq!(parent_disk_name("sda1"), "sda");
assert_eq!(parent_disk_name("/dev/nvme0n1p1"), "/dev/nvme0n1");
// 'p' inside a word is not a partition marker.
assert_eq!(parent_disk_name("mapper/vg-lv"), "mapper/vg-lv");
}
/// The old heuristic flagged whole-disk names ending in a digit
/// (nvme0n1, zram1) as partitions. On Linux /sys/block decides; this
/// pins the real-machine behavior for devices every Linux box has.
#[cfg(target_os = "linux")]
#[test]
fn sys_block_devices_are_not_partitions() {
let sys_block = std::path::Path::new("/sys/block");
if !sys_block.is_dir() {
return; // exotic environment; nothing to assert
}
for entry in std::fs::read_dir(sys_block).unwrap().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
assert!(
!is_partition_name(&name),
"{name} is a whole disk but was flagged as a partition"
);
}
}
#[test]
fn journal_timestamps_are_rfc3339() {
let s = format_journal_timestamp(1_786_752_000_000_000);
assert_eq!(s, "2026-08-15T00:00:00Z");
// Sub-second precision survives.
let s = format_journal_timestamp(1_786_752_000_123_456);
assert!(s.starts_with("2026-08-15T00:00:00.123456"), "{s}");
}
}
+1 -50
View File
@@ -74,55 +74,6 @@ pub struct AppState {
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
}
/// TTL-gated value behind a std Mutex, for `static` caches on hot paths.
/// Replaces the hand-rolled TempCache/GpuCache/refresh-timestamp statics
/// that each reimplemented the same at/value pair.
pub struct TtlCell<T> {
inner: std::sync::Mutex<CacheEntry<T>>,
}
impl<T: Clone> Default for TtlCell<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Clone> TtlCell<T> {
pub const fn new() -> Self {
Self {
inner: std::sync::Mutex::new(CacheEntry::new()),
}
}
/// The stored value, only while fresh. Poisoned lock reads as a miss.
pub fn get_fresh(&self, ttl: Duration) -> Option<T> {
let g = self.inner.lock().ok()?;
if g.is_fresh(ttl) {
g.value.clone()
} else {
None
}
}
pub fn set(&self, v: T) {
if let Ok(mut g) = self.inner.lock() {
g.set(v);
}
}
/// True exactly once per TTL window: restamps and tells the caller to do
/// the refresh. Atomic check-and-stamp so concurrent callers don't both
/// refresh.
pub fn claim_stale(&self, ttl: Duration) -> bool {
let Ok(mut g) = self.inner.lock() else {
return false;
};
if g.at.is_none_or(|t| t.elapsed() >= ttl) {
g.at = Some(Instant::now());
true
} else {
false
}
}
}
#[derive(Clone, Debug)]
pub struct CacheEntry<T> {
pub at: Option<Instant>,
@@ -136,7 +87,7 @@ impl<T> Default for CacheEntry<T> {
}
impl<T> CacheEntry<T> {
pub const fn new() -> Self {
pub fn new() -> Self {
Self {
at: None,
value: None,
+1 -21
View File
@@ -24,17 +24,6 @@ pub fn cert_paths() -> (PathBuf, PathBuf) {
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
let (cert_path, key_path) = cert_paths();
if cert_path.exists() && key_path.exists() {
// Keys generated by agents older than 1.60 were written with the
// default umask (typically 0644): tighten them on startup.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&key_path)
&& meta.permissions().mode() & 0o077 != 0
{
let _ = fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600));
}
}
return Ok((cert_path, key_path));
}
fs::create_dir_all(cert_path.parent().unwrap())?;
@@ -90,16 +79,7 @@ pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
let mut f = fs::File::create(&cert_path)?;
f.write_all(cert_pem.as_bytes())?;
// The private key must not be world-readable (File::create honors the
// umask, which typically yields 0644).
let mut key_opts = fs::OpenOptions::new();
key_opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
key_opts.mode(0o600);
}
let mut k = key_opts.open(&key_path)?;
let mut k = fs::File::create(&key_path)?;
k.write_all(key_pem.as_bytes())?;
println!(
+1 -11
View File
@@ -30,11 +30,6 @@ pub struct ProcessInfo {
#[derive(Debug, Clone, Serialize)]
pub struct Metrics {
/// Epoch ms when this snapshot was actually collected. The agent serves
/// TTL-cached snapshots, so the client needs the AGENT's sample time to
/// compute rates — measuring against client receive time turned cache
/// hits into a 0-then-2x sawtooth in the network graphs.
pub sampled_at_ms: u64,
pub cpu_total: f32,
pub cpu_per_core: Vec<f32>,
pub mem_total: u64,
@@ -98,8 +93,7 @@ pub struct ProcessMetricsResponse {
#[derive(Debug, Clone, Serialize)]
pub struct JournalEntry {
pub timestamp: String, // RFC 3339 UTC, for display
pub timestamp_us: u64, // epoch microseconds, for sorting/formatting
pub timestamp: String, // ISO 8601 formatted timestamp
pub priority: LogLevel,
pub message: String,
pub unit: Option<String>, // systemd unit name
@@ -126,9 +120,5 @@ pub struct JournalResponse {
pub entries: Vec<JournalEntry>,
pub total_count: u32,
pub truncated: bool,
/// journalctl's own explanation when the result is empty because of
/// journal ACCESS (not absence of logs) — e.g. a user-run agent asking
/// about a system service. None when entries exist or nothing to say.
pub notice: Option<String>,
pub cached_at: u64, // Unix timestamp when this data was cached
}
+75 -79
View File
@@ -16,7 +16,9 @@ use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all}
use crate::proto::pb;
use crate::state::AppState;
// Payloads at or below this many bytes are sent as-is; larger ones are gzipped.
// Compression threshold based on typical payload size
// Temporarily increased for testing - revert to 768 for production
//const COMPRESSION_THRESHOLD: usize = 50_000;
const COMPRESSION_THRESHOLD: usize = 768;
// Reusable buffer for compression to avoid allocations
@@ -50,66 +52,6 @@ pub async fn ws_handler(
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
/// Per-PID cache limits: entries older than MAX_AGE are swept on every
/// insert and the map is capped at MAX_ENTRIES (oldest evicted first), so a
/// client walking PIDs cannot grow agent memory without bound.
const PER_PID_CACHE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60);
const PER_PID_CACHE_MAX_ENTRIES: usize = 64;
/// Serve a per-PID request from a TTL cache, collecting on miss. One home
/// for the logic that get_process_metrics and get_journal_entries used to
/// duplicate ~50 lines apiece.
async fn respond_per_pid_cached<T, Fut>(
socket: &mut WebSocket,
cache: &Mutex<HashMap<u32, crate::state::CacheEntry<T>>>,
pid: u32,
ttl: std::time::Duration,
request_name: &str,
collect: impl FnOnce() -> Fut,
) where
T: serde::Serialize + Clone,
Fut: std::future::Future<Output = Result<T, String>>,
{
{
let cache = cache.lock().await;
if let Some(entry) = cache.get(&pid)
&& entry.is_fresh(ttl)
&& let Some(v) = entry.get()
{
let _ = send_json(socket, v).await;
return;
}
}
match collect().await {
Ok(resp) => {
{
let mut cache = cache.lock().await;
cache.retain(|_, e| e.at.is_some_and(|t| t.elapsed() < PER_PID_CACHE_MAX_AGE));
while cache.len() >= PER_PID_CACHE_MAX_ENTRIES {
let oldest = cache.iter().min_by_key(|(_, e)| e.at).map(|(k, _)| *k);
match oldest {
Some(k) => cache.remove(&k),
None => break,
};
}
cache
.entry(pid)
.or_insert_with(crate::state::CacheEntry::new)
.set(resp.clone());
}
let _ = send_json(socket, &resp).await;
}
Err(err) => {
let error_response = serde_json::json!({
"error": err,
"request": request_name,
"pid": pid
});
let _ = send_json(socket, &error_response).await;
}
}
}
async fn handle_socket(mut socket: WebSocket, state: AppState) {
state
.client_count
@@ -184,30 +126,84 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
if let Some(pid_str) = text.strip_prefix("get_process_metrics:")
&& let Ok(pid) = pid_str.parse::<u32>()
{
respond_per_pid_cached(
&mut socket,
&state.cache_process_metrics,
pid,
std::time::Duration::from_millis(250),
"get_process_metrics",
|| crate::metrics::collect_process_metrics(pid, &state),
)
.await;
let ttl = std::time::Duration::from_millis(250); // 250ms TTL
// Check cache first
{
let cache = state.cache_process_metrics.lock().await;
if let Some(entry) = cache.get(&pid)
&& entry.is_fresh(ttl)
&& let Some(cached_response) = entry.get()
{
let _ = send_json(&mut socket, cached_response).await;
continue;
}
}
// Collect fresh data
match crate::metrics::collect_process_metrics(pid, &state).await {
Ok(response) => {
// Cache the response
{
let mut cache = state.cache_process_metrics.lock().await;
cache
.entry(pid)
.or_insert_with(crate::state::CacheEntry::new)
.set(response.clone());
}
let _ = send_json(&mut socket, &response).await;
}
Err(err) => {
let error_response = serde_json::json!({
"error": err,
"request": "get_process_metrics",
"pid": pid
});
let _ = send_json(&mut socket, &error_response).await;
}
}
}
}
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
if let Some(pid_str) = text.strip_prefix("get_journal_entries:")
&& let Ok(pid) = pid_str.parse::<u32>()
{
respond_per_pid_cached(
&mut socket,
&state.cache_journal_entries,
pid,
std::time::Duration::from_secs(1),
"get_journal_entries",
|| crate::metrics::collect_journal_entries(pid),
)
.await;
let ttl = std::time::Duration::from_secs(1); // 1s TTL
// Check cache first
{
let cache = state.cache_journal_entries.lock().await;
if let Some(entry) = cache.get(&pid)
&& entry.is_fresh(ttl)
&& let Some(cached_response) = entry.get()
{
let _ = send_json(&mut socket, cached_response).await;
continue;
}
}
// Collect fresh data
match crate::metrics::collect_journal_entries(pid) {
Ok(response) => {
// Cache the response
{
let mut cache = state.cache_journal_entries.lock().await;
cache
.entry(pid)
.or_insert_with(crate::state::CacheEntry::new)
.set(response.clone());
}
let _ = send_json(&mut socket, &response).await;
}
Err(err) => {
let error_response = serde_json::json!({
"error": err,
"request": "get_journal_entries",
"pid": pid
});
let _ = send_json(&mut socket, &error_response).await;
}
}
}
}
Message::Close(_) => break,
-1
View File
@@ -42,7 +42,6 @@ async fn test_process_cache_ttl() {
};
let journal_response = JournalResponse {
notice: None,
entries: vec![],
total_count: 0,
truncated: false,
+2 -18
View File
@@ -33,7 +33,7 @@ async fn test_collect_journal_entries_self() {
// Test collecting journal entries for our own process
let pid = process::id();
match collect_journal_entries(pid).await {
match collect_journal_entries(pid) {
Ok(response) => {
assert!(response.cached_at > 0);
println!(
@@ -74,7 +74,7 @@ async fn test_collect_journal_entries_invalid_pid() {
// Test with an invalid PID - journalctl might still return empty results
let invalid_pid = 999999;
match collect_journal_entries(invalid_pid).await {
match collect_journal_entries(invalid_pid) {
Ok(response) => {
println!(
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
@@ -87,19 +87,3 @@ async fn test_collect_journal_entries_invalid_pid() {
}
}
}
/// The Command & Details pane went blank when the minimal-refresh
/// optimization dropped cmd from the detail endpoint's refresh kind.
#[tokio::test]
async fn test_process_metrics_include_command() {
let state = AppState::new();
let pid = std::process::id();
let resp = collect_process_metrics(pid, &state)
.await
.expect("collect self");
assert!(
!resp.process.command.is_empty(),
"command should not be empty for self (cmdline is always readable)"
);
println!("command = {}", resp.process.command);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop_connector"
version = "1.60.0"
version = "1.50.0"
edition = "2024"
license = "MIT"
description = "WebSocket connector library for socktop agent communication"
+4 -8
View File
@@ -1,12 +1,8 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Vendored protoc for reproducible builds where available. It ships no
// riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to
// prost-build (apt: protobuf-compiler).
// SAFETY: We're only setting PROTOC in a build script environment.
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
unsafe {
std::env::set_var("PROTOC", protoc);
}
// Set the protoc binary path to use the vendored version for CI compatibility
// SAFETY: We're only setting PROTOC in a build script environment, which is safe
unsafe {
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
}
prost_build::compile_protos(&["processes.proto"], &["."])?;
File diff suppressed because it is too large Load Diff
+44 -152
View File
@@ -6,7 +6,7 @@ use crate::error::{ConnectorError, Result};
use std::io::BufReader;
use std::sync::Arc;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
use url::Url;
#[cfg(feature = "tls")]
@@ -15,7 +15,7 @@ use {
rustls::{
DigitallySignedStruct, RootCertStore, SignatureScheme,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
crypto::{WebPkiSupportedAlgorithms, ring},
crypto::ring,
pki_types::{CertificateDer, ServerName, UnixTime},
},
rustls_pemfile::Item,
@@ -64,8 +64,7 @@ async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> R
);
}
// `true` disables Nagle: small request/response frames, latency matters.
let (ws, _) = tokio_tungstenite::connect_async_with_config(req, None, true).await?;
let (ws, _) = connect_async(req).await?;
Ok(ws)
}
@@ -86,12 +85,7 @@ async fn connect_with_ca_and_config(
der_certs.push(der);
}
}
if der_certs.is_empty() {
return Err(ConnectorError::protocol_error(format!(
"no certificates found in --tls-ca file: {ca_path}"
)));
}
root.add_parsable_certificates(der_certs.iter().cloned());
root.add_parsable_certificates(der_certs);
let mut cfg = ClientConfig::builder()
.with_root_certificates(root)
@@ -120,89 +114,57 @@ async fn connect_with_ca_and_config(
}
if !config.verify_hostname {
// Default mode: certificate PINNING without hostname verification.
// The server must present a certificate byte-identical to one in the
// --tls-ca file. This intentionally ignores expiry and chain building
// (the operator pinned this exact cert), but unlike a blanket accept
// it makes MITM certs fail the handshake.
cfg.dangerous()
.set_certificate_verifier(Arc::new(PinnedCertVerifier::new(der_certs)));
#[derive(Debug)]
struct NoVerify;
impl ServerCertVerifier for NoVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
// Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
}
let cfg = Arc::new(cfg);
// Third argument is tungstenite's `disable_nagle`: always true — socktop
// exchanges small request/response frames where Nagle only adds latency.
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
req,
None,
true,
config.verify_hostname,
Some(Connector::Rustls(cfg)),
)
.await?;
Ok(ws)
}
/// Accepts exactly the certificates the user pinned via `--tls-ca`, nothing else.
///
/// Used when hostname verification is off (the default for self-signed
/// home-lab certs). Signature validation still runs with the ring provider's
/// full algorithm set; only the certificate identity check is replaced —
/// by an exact DER comparison against the pinned certificate(s).
#[cfg(feature = "tls")]
#[derive(Debug)]
struct PinnedCertVerifier {
pinned: Vec<CertificateDer<'static>>,
algorithms: WebPkiSupportedAlgorithms,
}
#[cfg(feature = "tls")]
impl PinnedCertVerifier {
fn new(pinned: Vec<CertificateDer<'static>>) -> Self {
Self {
pinned,
algorithms: ring::default_provider().signature_verification_algorithms,
}
}
}
#[cfg(feature = "tls")]
impl ServerCertVerifier for PinnedCertVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
if self.pinned.iter().any(|p| p == end_entity) {
Ok(ServerCertVerified::assertion())
} else {
Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.algorithms.supported_schemes()
}
}
#[cfg(not(feature = "tls"))]
async fn connect_with_ca_and_config(
_url: &str,
@@ -219,73 +181,3 @@ async fn connect_with_ca_and_config(
fn ensure_crypto_provider() {
let _ = ring::default_provider().install_default();
}
#[cfg(all(test, feature = "tls"))]
mod tests {
use super::*;
fn verifier(pinned: &[&[u8]]) -> PinnedCertVerifier {
let _ = ring::default_provider().install_default();
PinnedCertVerifier::new(
pinned
.iter()
.map(|b| CertificateDer::from(b.to_vec()))
.collect(),
)
}
fn verify(v: &PinnedCertVerifier, presented: &[u8]) -> bool {
v.verify_server_cert(
&CertificateDer::from(presented.to_vec()),
&[],
&ServerName::try_from("agent.test").unwrap(),
&[],
UnixTime::now(),
)
.is_ok()
}
/// The regression this verifier exists to prevent: the old NoVerify
/// accepted ANY certificate when hostname verification was off, so the
/// documented pinning was a no-op. The pinned cert must be accepted and
/// every other cert rejected.
#[test]
fn only_the_pinned_certificate_is_accepted() {
let v = verifier(&[b"pinned-cert-der"]);
assert!(verify(&v, b"pinned-cert-der"));
assert!(!verify(&v, b"some-mitm-cert"), "unpinned cert accepted");
assert!(!verify(&v, b""), "empty cert accepted");
}
/// A --tls-ca file may hold several certs (e.g. during rotation); any of
/// them must satisfy the pin.
#[test]
fn any_cert_in_a_multi_cert_pem_satisfies_the_pin() {
let v = verifier(&[b"old-cert", b"new-cert"]);
assert!(verify(&v, b"old-cert"));
assert!(verify(&v, b"new-cert"));
assert!(!verify(&v, b"third-party-cert"));
}
/// Fail closed: an empty pin set must reject everything rather than
/// falling back to accept-all.
#[test]
fn an_empty_pin_set_rejects_all_certificates() {
let v = verifier(&[]);
assert!(!verify(&v, b"anything"));
}
/// Signature schemes come from the real provider, not a hardcoded list —
/// an agent using e.g. RSA-PKCS1 must still be able to handshake.
#[test]
fn signature_schemes_come_from_the_provider() {
let v = verifier(&[b"x"]);
let schemes = v.supported_verify_schemes();
assert!(
schemes.len() > 3,
"suspiciously short scheme list: {schemes:?}"
);
assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA256));
assert!(schemes.contains(&SignatureScheme::ECDSA_NISTP256_SHA256));
}
}
-8
View File
@@ -54,10 +54,6 @@ pub struct GpuInfo {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Metrics {
/// Epoch ms when the agent actually collected this snapshot (agents may
/// serve TTL-cached data). Absent on agents older than 1.60.
#[serde(default)]
pub sampled_at_ms: Option<u64>,
pub cpu_total: f32,
pub cpu_per_core: Vec<f32>,
pub mem_total: u64,
@@ -151,10 +147,6 @@ pub struct JournalResponse {
pub entries: Vec<JournalEntry>,
pub total_count: u32,
pub truncated: bool,
/// Agent-side explanation for an empty result (journal access limits).
/// Absent on agents older than 1.60.
#[serde(default)]
pub notice: Option<String>,
pub cached_at: u64, // Unix timestamp when this data was cached
}
-1
View File
@@ -46,7 +46,6 @@ pub async fn send_request_and_wait(
// For now, return a placeholder metrics response indicating binary data received
// TODO: Implement proper protobuf decoding for binary data
let placeholder_metrics = Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![0.0],
mem_total: 0,
+3 -1
View File
@@ -475,7 +475,9 @@ dependencies = [
[[package]]
name = "socktop_connector"
version = "1.51.0"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a63dadaa5105df11b0684759a829012257d48e72a469cc554c0cf4394605f5a"
dependencies = [
"flate2",
"js-sys",
+2 -2
View File
@@ -10,8 +10,8 @@ edition = "2021"
crate-type = ["cdylib"]
[dependencies]
# Use WASM features for WebSocket connectivity (in-repo connector via path)
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
# Use WASM features for WebSocket connectivity (published version)
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wasm-bindgen = "0.2"
View File
-4384
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -3,9 +3,6 @@ name = "zellij_socktop_plugin"
version = "0.1.0"
edition = "2021"
# Standalone package, not part of the parent workspace (same as socktop_wasm_test)
[workspace]
[lib]
crate-type = ["cdylib"]
@@ -13,7 +10,7 @@ crate-type = ["cdylib"]
zellij-tile = "0.40.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
futures = "0.3"
[dependencies.chrono]