Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a757ba1e |
@@ -18,15 +18,7 @@ env:
|
||||
jobs:
|
||||
build-deb:
|
||||
name: Build .deb for ${{ matrix.target }}
|
||||
# PINNED, not ubuntu-latest: the binaries link against this runner's
|
||||
# (multiarch) glibc, so the runner sets the MINIMUM glibc the .debs demand
|
||||
# at install time. ubuntu-latest moved to 24.04/glibc 2.39 and the packages
|
||||
# stopped installing on Debian 12/RPi OS bookworm (glibc 2.36). 22.04 links
|
||||
# 2.35, which bookworm satisfies. The "enforce glibc floor" step below
|
||||
# turns any future violation into a red build instead of a fleet-wide apt
|
||||
# failure — if this pin ever has to move past bookworm's glibc, that step
|
||||
# is the contract to renegotiate first.
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -167,22 +159,6 @@ jobs:
|
||||
mkdir -p debs
|
||||
cp target/${{ matrix.target }}/debian/*.deb debs/
|
||||
|
||||
- name: Enforce glibc floor (Debian 12 / RPi OS bookworm fleet)
|
||||
run: |
|
||||
# The fleet's oldest supported glibc. A .deb that demands newer libc6
|
||||
# than this will not install on the Pis — fail HERE, not at apt time.
|
||||
FLOOR="2.36"
|
||||
fail=0
|
||||
for deb in debs/*.deb; do
|
||||
req=$(dpkg-deb -f "$deb" Depends | sed -n 's/.*libc6 (>= \([0-9.]*\)).*/\1/p' | head -1)
|
||||
echo "$deb -> libc6 >= ${req:-none}"
|
||||
if [ -n "$req" ] && [ "$(printf '%s\n' "$req" "$FLOOR" | sort -V | tail -1)" != "$FLOOR" ]; then
|
||||
echo "::error::$deb requires libc6 >= $req, exceeding the fleet floor $FLOOR (bookworm). The build runner's glibc is too new — see the runs-on pin comment."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: List generated packages
|
||||
run: ls -lh debs/
|
||||
|
||||
|
||||
+2
-3
@@ -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)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### TUI
|
||||
|
||||
- **`--no-kill` flag and `SOCKTOP_NO_KILL` env var** disable the local
|
||||
process-kill feature regardless of agent locality, for shared terminals and
|
||||
public demos (e.g. the socktop.io webterm). Either one forces the feature
|
||||
off and suppresses the `t` kill hints; the env var covers every socktop
|
||||
invocation under a deployment without touching command lines. `App`'s
|
||||
builder renamed `with_local` → `with_kill_enabled` to match what it now
|
||||
means (locality fact AND policy).
|
||||
|
||||
## 1.60.1 — unreleased
|
||||
|
||||
Identical to 1.60.0 plus rebuilt Debian packages: the 1.60.0 debs were linked
|
||||
against glibc 2.39 (a GitHub runner migration) and would not install on
|
||||
Debian 12 / Raspberry Pi OS bookworm. CI now pins the build environment and
|
||||
gates every package against the fleet's glibc floor. 1.60.0 was never
|
||||
published to crates.io.
|
||||
|
||||
Everything since `v1.50.0`. Applies to all three crates (`socktop`, `socktop_agent`, `socktop_connector`), which move to 1.60.1 together.
|
||||
|
||||
### Security
|
||||
|
||||
- **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
-5
@@ -2412,7 +2412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socktop"
|
||||
version = "1.60.2"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
@@ -2422,7 +2422,7 @@ dependencies = [
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socktop_connector",
|
||||
"socktop_connector 1.50.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"sysinfo",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -2432,7 +2432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socktop_agent"
|
||||
version = "1.60.2"
|
||||
version = "1.50.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
@@ -2442,7 +2442,6 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gfxinfo",
|
||||
"hostname",
|
||||
"nvml-wrapper",
|
||||
"once_cell",
|
||||
"prost",
|
||||
"prost-build",
|
||||
@@ -2464,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socktop_connector"
|
||||
version = "1.60.2"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -2485,6 +2484,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,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Witty One Off
|
||||
Copyright (c) 2025 Witty One Off
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,36 +1,547 @@
|
||||
# socktop
|
||||
|
||||
_socktop_ is a remote system monitor with a rich TUI, talking to an ultra lightweight agent over WebSockets.
|
||||
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
|
||||
|
||||
<img src="./docs/socktop_demo_1_60.apng" width="100%">
|
||||
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||
## Resources
|
||||
[socktop.io](https://www.socktop.io)
|
||||
|
||||
| Resource | Location |
|
||||
| -------- | -------- |
|
||||
| Website and online demo (yes it's real) | [socktop.io](https://www.socktop.io) |
|
||||
| Quick Start guide | [https://socktop.io/assets/docs/installation/quick-start.html](https://socktop.io/assets/docs/installation/quick-start.html) |
|
||||
| Prereqs | [https://socktop.io/assets/docs/installation/prerequisites.html](https://socktop.io/assets/docs/installation/prerequisites.html) |
|
||||
| APT Install | [https://socktop.io/assets/docs/installation/apt.html](https://socktop.io/assets/docs/installation/apt.html) |
|
||||
| Cargo Install | [https://socktop.io/assets/docs/installation/cargo.html](https://socktop.io/assets/docs/installation/cargo.html)
|
||||
| Usage | [https://socktop.io/assets/docs/usage/general.html](https://socktop.io/assets/docs/usage/general.html)
|
||||
| Auth Setup | [https://socktop.io/assets/docs/security/token.html](https://socktop.io/assets/docs/security/token.html) |
|
||||
| TLS Setup | [https://socktop.io/assets/docs/security/tls.html](https://socktop.io/assets/docs/security/tls.html) |
|
||||
| Monitoring Multiple Hosts | [tmux](https://socktop.io/assets/docs/advanced/tmux.html) / [zellij](https://socktop.io/assets/docs/advanced/zellij.html) |
|
||||
<img src="./docs/socktop_demo.apng" width="100%">
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
## Features
|
||||
|
||||
Linux (all flavors), ARM/Raspberry Pi (32b/64b), MacOS, Windows, RISC-V (experimental)
|
||||
- Remote monitoring via WebSocket (JSON over WS)
|
||||
- Optional WSS (TLS): agent auto‑generates a self‑signed cert on first run; client pins the cert via --tls-ca/-t
|
||||
- TUI built with ratatui
|
||||
- CPU
|
||||
- Overall sparkline + per-core mini bars
|
||||
- Accurate per-process CPU% (Linux /proc deltas), normalized to 0–100%
|
||||
- Memory/Swap gauges with human units
|
||||
- Disks: per-device usage
|
||||
- Network: per-interface throughput with sparklines and peak markers
|
||||
- Temperatures: CPU (optional)
|
||||
- Top processes (top 50)
|
||||
- PID, name, CPU%, memory, and memory%
|
||||
- Click-to-sort by CPU% or Mem (descending)
|
||||
- Scrollbar and mouse/keyboard scrolling
|
||||
- Total process count shown in the header
|
||||
- Only top-level processes listed (threads hidden) — matches btop/top
|
||||
- Optional GPU metrics (can be disabled)
|
||||
- Optional auth token for the agent
|
||||
- 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))
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
## Prerequisites: Install Rust (rustup)
|
||||
|
||||
Contributions are welcome and you have the freedom to use whatever development tools you would like, as long as there is a human in the loop and all the clippy and unit tests pass you are good to submit a PR. Defects / Bugs just go ahead and fix and file a PR. New features, please create a issue in advance and let me know you are offering to build it. I don't want to be in a position where you worked for a couple of weeks on something and I don't want to merge it.
|
||||
Rust is fast, safe, and cross‑platform. Installing it will make your machine better. Consider yourself privileged.
|
||||
|
||||
### Development
|
||||
Linux/macOS:
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
# load cargo for this shell
|
||||
source "$HOME/.cargo/env"
|
||||
# ensure stable is up to date
|
||||
rustup update stable
|
||||
rustc --version
|
||||
cargo --version
|
||||
# after install you may need to reload your shell, e.g.:
|
||||
exec bash # or: exec zsh / exec fish
|
||||
```
|
||||
|
||||
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, you’ll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
|
||||
|
||||
### Raspberry Pi / Ubuntu / PopOS (required for GPU support)
|
||||
|
||||
**Note:** GPU monitoring is only supported on x86_64 and aarch64 (64-bit ARM) platforms. ARMv7 (32-bit) and RISC-V builds do not include GPU support.
|
||||
|
||||
For 64-bit systems with GPU support:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install libdrm-dev libdrm-amdgpu1
|
||||
```
|
||||
|
||||
For ARMv7 (32-bit Raspberry Pi), build with `--no-default-features` to disable GPU support:
|
||||
|
||||
```bash
|
||||
cargo build --release -p socktop_agent --no-default-features
|
||||
```
|
||||
|
||||
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Two components:
|
||||
|
||||
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
|
||||
|
||||
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
- Build both binaries:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jasonwitty/socktop.git
|
||||
cd socktop
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
- Start the agent on the target machine (default port 3000):
|
||||
|
||||
```bash
|
||||
./target/release/socktop_agent --port 3000
|
||||
```
|
||||
|
||||
- Connect with the TUI from your local machine:
|
||||
|
||||
```bash
|
||||
./target/release/socktop ws://REMOTE_HOST:3000/ws
|
||||
```
|
||||
|
||||
### Cross-compiling for Raspberry Pi
|
||||
|
||||
For Raspberry Pi and other ARM devices, you can cross-compile the agent from a more powerful machine:
|
||||
|
||||
- [Cross-compilation guide](./docs/cross-compiling.md) - Instructions for cross-compiling from Linux, macOS, or Windows hosts
|
||||
|
||||
### Quick demo (no agent setup)
|
||||
|
||||
Spin up a temporary local agent on port 3231 and connect automatically:
|
||||
|
||||
```bash
|
||||
socktop --demo
|
||||
```
|
||||
|
||||
Or just run `socktop` with no arguments and pick the built‑in `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
|
||||
|
||||
- Runs locally (`ws://127.0.0.1:3231/ws`)
|
||||
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
|
||||
|
||||
---
|
||||
|
||||
## Install (from crates.io)
|
||||
|
||||
You don’t need to clone this repo to use socktop. Install the published binaries with cargo:
|
||||
|
||||
```bash
|
||||
# TUI (client)
|
||||
cargo install socktop
|
||||
# Agent (server)
|
||||
cargo install socktop_agent
|
||||
```
|
||||
|
||||
This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
|
||||
|
||||
Notes:
|
||||
- After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
|
||||
- Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldn’t. Be brave.
|
||||
|
||||
System-wide agent (Linux)
|
||||
|
||||
```bash
|
||||
# If you installed with cargo, binaries are in ~/.cargo/bin
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
|
||||
# Install and enable the systemd service (example unit in docs/)
|
||||
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now socktop-agent
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
# Enable SSL
|
||||
|
||||
# Stop service
|
||||
sudo systemctl stop socktop-agent
|
||||
|
||||
# Edit service to append SSL option and port
|
||||
sudo micro /etc/systemd/system/socktop-agent.service
|
||||
|
||||
--
|
||||
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
|
||||
--
|
||||
|
||||
# Reload
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Restart
|
||||
sudo systemctl start socktop-agent
|
||||
|
||||
# check logs for certificate location
|
||||
sudo journalctl -u socktop-agent -f
|
||||
|
||||
--
|
||||
Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
|
||||
--
|
||||
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
Agent (server):
|
||||
|
||||
```bash
|
||||
socktop_agent --port 3000
|
||||
# or env: SOCKTOP_PORT=3000 socktop_agent
|
||||
# optional auth: SOCKTOP_TOKEN=changeme socktop_agent
|
||||
# enable TLS (self‑signed cert, default port 8443; you can also use -p):
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Client (TUI):
|
||||
|
||||
```bash
|
||||
socktop ws://HOST:3000/ws
|
||||
# with token:
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
# TLS with pinned server certificate (recommended over the internet):
|
||||
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# (By default hostname/SAN verification is skipped for ease on home networks. To enforce it add --verify-hostname)
|
||||
socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# shorthand:
|
||||
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
||||
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
||||
# force the small-window layout at any terminal size (normally automatic):
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Intervals (client-driven):
|
||||
- Fast metrics: ~500 ms
|
||||
- Processes: ~2 s
|
||||
- Disks: ~5 s
|
||||
|
||||
The agent stays idle unless queried. When queried, it collects just what’s needed.
|
||||
|
||||
---
|
||||
|
||||
## Compact mode
|
||||
|
||||
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
|
||||
are the first things to collapse — exactly the panes you are most likely watching. Once
|
||||
the window is too short for the Disks pane to show even one disk, socktop switches to a
|
||||
compact layout:
|
||||
|
||||
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
|
||||
- **Memory and Swap move side by side** into the row Disks vacated.
|
||||
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
|
||||
with no GPU the pane disappears entirely.
|
||||
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
|
||||
below the size where they used to vanish.
|
||||
|
||||
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
|
||||
layout at any window size:
|
||||
|
||||
```bash
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection Profiles (Named)
|
||||
|
||||
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||
|
||||
Config file location:
|
||||
|
||||
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
|
||||
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
|
||||
|
||||
### Creating a profile
|
||||
|
||||
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
|
||||
|
||||
```bash
|
||||
socktop --profile prod ws://prod-host:3000/ws
|
||||
# With TLS pinning:
|
||||
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
|
||||
|
||||
You can also set custom intervals (milliseconds):
|
||||
|
||||
```bash
|
||||
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
|
||||
```
|
||||
```
|
||||
|
||||
If a profile already exists you will be prompted before overwriting:
|
||||
|
||||
```
|
||||
$ socktop --profile prod ws://new-host:3000/ws
|
||||
Overwrite existing profile 'prod'? [y/N]: y
|
||||
```
|
||||
|
||||
To overwrite without an interactive prompt pass `--save`:
|
||||
|
||||
```bash
|
||||
socktop --profile prod --save ws://new-host:3000/ws
|
||||
```
|
||||
|
||||
### Using a saved profile
|
||||
|
||||
Just pass the profile name (no URL needed):
|
||||
|
||||
```bash
|
||||
socktop --profile prod
|
||||
socktop -P prod-tls # short flag
|
||||
```
|
||||
|
||||
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
|
||||
|
||||
### Interactive selection (no args)
|
||||
|
||||
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
|
||||
|
||||
```
|
||||
$ socktop
|
||||
Select profile:
|
||||
1. prod
|
||||
2. prod-tls
|
||||
Enter number (or blank to abort): 2
|
||||
```
|
||||
|
||||
Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
|
||||
|
||||
### JSON format
|
||||
|
||||
An example `profiles.json` (pretty‑printed):
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"prod": { "url": "ws://prod-host:3000/ws" },
|
||||
"prod-tls": {
|
||||
"url": "wss://prod-host:8443/ws",
|
||||
"tls_ca": "/home/user/certs/prod-cert.pem",
|
||||
"metrics_interval_ms": 500,
|
||||
"processes_interval_ms": 2000
|
||||
}
|
||||
},
|
||||
"version": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
|
||||
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
|
||||
- Profiles are client-side convenience only; they do not affect the agent.
|
||||
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
Update the agent (systemd):
|
||||
|
||||
```bash
|
||||
# on the server running the agent
|
||||
cargo install socktop_agent --force
|
||||
sudo systemctl stop socktop-agent
|
||||
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
|
||||
# if you changed the unit file:
|
||||
# sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
|
||||
# sudo systemctl daemon-reload
|
||||
sudo systemctl start socktop-agent
|
||||
sudo systemctl status socktop-agent --no-pager
|
||||
# logs:
|
||||
# journalctl -u socktop-agent -f
|
||||
```
|
||||
|
||||
Update the TUI (client):
|
||||
```bash
|
||||
cargo install socktop --force
|
||||
socktop ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
|
||||
|
||||
---
|
||||
|
||||
## Configuration (agent)
|
||||
|
||||
- Port:
|
||||
- Flag: --port 8080 or -p 8080
|
||||
- Positional: socktop_agent 8080
|
||||
- Env: SOCKTOP_PORT=8080
|
||||
- TLS (self‑signed):
|
||||
- Enable: --enableSSL
|
||||
- Default TLS port: 8443 (override with --port/-p)
|
||||
- Certificate/Key location (created on first TLS run):
|
||||
- Linux (XDG): $XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem} (defaults to ~/.config)
|
||||
- The agent prints these paths on creation.
|
||||
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
|
||||
- Additional SANs: set `SOCKTOP_AGENT_EXTRA_SANS` (comma‑separated) before first TLS start to include extra IPs/DNS names in the cert. Example:
|
||||
```bash
|
||||
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL
|
||||
```
|
||||
This prevents client errors like `NotValidForName` when connecting via an IP not present in the default cert SAN list.
|
||||
- Expiry / rotation: the generated cert is valid for ~397 days from creation. If the agent fails to start with an "ExpiredCertificate" error (or your client reports expiry), simply delete the existing cert and key:
|
||||
```bash
|
||||
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
|
||||
# (adjust path if XDG_CONFIG_HOME is set or different user)
|
||||
systemctl restart socktop-agent # if running under systemd
|
||||
```
|
||||
On next TLS start the agent will generate a fresh pair. Only distribute the new cert.pem to clients (never the key).
|
||||
- Auth token (optional): SOCKTOP_TOKEN=changeme
|
||||
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
|
||||
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
|
||||
|
||||
---
|
||||
|
||||
## Keyboard & Mouse
|
||||
|
||||
- Quit: q or Esc
|
||||
- Processes pane:
|
||||
- Click “CPU %” to sort by CPU descending
|
||||
- Click “Mem” to sort by memory descending
|
||||
- Mouse wheel: scroll
|
||||
- Drag scrollbar: scroll
|
||||
- Arrow/PageUp/PageDown/Home/End: scroll
|
||||
|
||||
---
|
||||
|
||||
## Example agent JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"cpu_total": 12.4,
|
||||
"cpu_per_core": [11.2, 15.7],
|
||||
"mem_total": 33554432,
|
||||
"mem_used": 18321408,
|
||||
"swap_total": 0,
|
||||
"swap_used": 0,
|
||||
"process_count": 127,
|
||||
"hostname": "myserver",
|
||||
"cpu_temp_c": 42.5,
|
||||
"disks": [{"name":"nvme0n1p2","total":512000000000,"available":320000000000}],
|
||||
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
|
||||
"top_processes": [
|
||||
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
|
||||
],
|
||||
"gpus": null
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- process_count is merged into the main metrics on the client when processes are polled.
|
||||
- top_processes are the current top 50 (sorting in the TUI is client-side).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Set a token on the agent and pass it as a query param from the client:
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
|
||||
```
|
||||
|
||||
Client:
|
||||
|
||||
```bash
|
||||
socktop "ws://HOST:3000/ws?token=changeme"
|
||||
```
|
||||
|
||||
### TLS / WSS
|
||||
|
||||
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
|
||||
|
||||
Server (generates self‑signed cert and key on first run):
|
||||
|
||||
```bash
|
||||
socktop_agent --enableSSL --port 8443
|
||||
```
|
||||
|
||||
Client (trust/pin the server cert; copy cert.pem from the agent):
|
||||
|
||||
```bash
|
||||
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Do not copy the private key off the server; only the cert.pem is needed by clients.
|
||||
- When --tls-ca/-t is supplied, the client auto‑upgrades ws:// to wss:// to avoid protocol mismatch.
|
||||
- Hostname (SAN) verification is DISABLED by default (the cert is still pinned). Use `--verify-hostname` to enable strict SAN checking.
|
||||
- You can run multiple clients with different cert paths by passing --tls-ca per invocation.
|
||||
|
||||
---
|
||||
|
||||
## Using tmux to monitor multiple hosts
|
||||
|
||||
You can use tmux to show multiple socktop instances in a single terminal.
|
||||
|
||||

|
||||
monitoring 4 Raspberry Pis using Tmux
|
||||
|
||||
Prerequisites:
|
||||
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
|
||||
|
||||
Key bindings (defaults):
|
||||
- Split left/right: Ctrl-b %
|
||||
- Split top/bottom: Ctrl-b "
|
||||
- Move between panes: Ctrl-b + Arrow keys
|
||||
- Show pane numbers: Ctrl-b q
|
||||
- Close a pane: Ctrl-b x
|
||||
- Detach from session: Ctrl-b d
|
||||
|
||||
Two panes (left/right)
|
||||
- This creates a session named "socktop", splits it horizontally, and starts two socktops.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-layout even-horizontal \; \
|
||||
attach
|
||||
```
|
||||
|
||||
Four panes (top-left, top-right, bottom-left, bottom-right)
|
||||
- This creates a 2x2 grid with one socktop per pane.
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
|
||||
split-window -h 'socktop ws://HOST2:3000/ws' \; \
|
||||
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
|
||||
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
|
||||
select-layout tiled \; \
|
||||
attach
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Replace HOST1..HOST4 (and ports) with your targets.
|
||||
- Reattach later: `tmux attach -t socktop`
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Linux: fully supported (agent and client).
|
||||
- Raspberry Pi:
|
||||
- 64-bit: aarch64-unknown-linux-gnu
|
||||
- 32-bit: armv7-unknown-linux-gnueabihf
|
||||
- Windows:
|
||||
- TUI + agent can build with stable Rust; bring your own MSVC. You’re on Windows; you know the drill.
|
||||
- CPU temperature may be unavailable.
|
||||
- binary exe for both available in build artifacts under actions.
|
||||
- macOS:
|
||||
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
@@ -45,11 +556,35 @@ cargo run -p socktop_agent -- --enableSSL --port 8443
|
||||
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
|
||||
Enable it (one-time):
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/pre-commit
|
||||
```
|
||||
|
||||
Every commit will then format Rust sources and restage them automatically.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Agent authentication (token)
|
||||
- [x] Hide per-thread entries; only show processes
|
||||
- [x] Sort top processes in the TUI
|
||||
- [x] Configurable refresh intervals (client)
|
||||
- [ ] Export metrics to file
|
||||
- [x] TLS / WSS support (self‑signed server cert + client pinning)
|
||||
- [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
|
||||
- [ ] Outage notifications and reconnect.
|
||||
- [ ] Per process detailed statistics pane
|
||||
- [ ] cleanup of Disks section, properly display physical disks / partitions, remove duplicate entries
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
MIT — see LICENSE.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 64 MiB |
@@ -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
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "socktop"
|
||||
version = "1.60.2"
|
||||
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.1", path = "../socktop_connector" }
|
||||
socktop_connector = "1.50.0"
|
||||
|
||||
tokio = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -22,10 +22,8 @@ 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"
|
||||
|
||||
+58
-1038
File diff suppressed because it is too large
Load Diff
@@ -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(""));
|
||||
}
|
||||
}
|
||||
+7
-63
@@ -2,8 +2,6 @@
|
||||
|
||||
mod app;
|
||||
mod history;
|
||||
mod local;
|
||||
mod proc_kill;
|
||||
mod profiles;
|
||||
mod retry;
|
||||
mod types;
|
||||
@@ -25,18 +23,6 @@ pub(crate) struct ParsedArgs {
|
||||
processes_interval_ms: Option<u64>,
|
||||
verify_hostname: bool,
|
||||
compact: bool,
|
||||
no_kill: bool,
|
||||
}
|
||||
|
||||
/// True when the `SOCKTOP_NO_KILL` environment variable disables the process-kill
|
||||
/// feature. Any value other than empty, `0`, or `false` (case-insensitive) counts
|
||||
/// as set, so a deployment can export `SOCKTOP_NO_KILL=1` once and every socktop
|
||||
/// launched under it — whatever its command line — has the feature off.
|
||||
pub(crate) fn no_kill_from_env() -> bool {
|
||||
match env::var("SOCKTOP_NO_KILL") {
|
||||
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||
@@ -52,12 +38,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
let mut processes_interval_ms: Option<u64> = None;
|
||||
let mut verify_hostname = false;
|
||||
let mut compact = false;
|
||||
let mut no_kill = 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] [--no-kill] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
));
|
||||
}
|
||||
"--tls-ca" | "-t" => {
|
||||
@@ -83,12 +68,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
// layout switches on its own once the window gets too short.
|
||||
compact = true;
|
||||
}
|
||||
"--no-kill" => {
|
||||
// Disable the local process-kill feature even when the agent is
|
||||
// local. For shared/kiosk deployments; SOCKTOP_NO_KILL=1 in the
|
||||
// environment does the same without touching the command line.
|
||||
no_kill = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
// intentionally undocumented
|
||||
dry_run = true;
|
||||
@@ -124,20 +103,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// An unrecognized option must never fall through to the
|
||||
// positional URL slot: an older binary handed a newer flag
|
||||
// would otherwise "connect" to the flag text — and offer to
|
||||
// save it over a named profile's URL.
|
||||
if arg.starts_with('-') {
|
||||
return Err(format!(
|
||||
"Unknown option '{arg}'. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
|
||||
));
|
||||
}
|
||||
if url.is_none() {
|
||||
url = Some(arg);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -154,7 +124,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
processes_interval_ms,
|
||||
verify_hostname,
|
||||
compact,
|
||||
no_kill,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,11 +133,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
// --help produces the bare usage text and exits cleanly; real
|
||||
// parse errors must be visible to scripts and CI as a failure.
|
||||
if !msg.starts_with("Usage:") {
|
||||
std::process::exit(2);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -180,7 +144,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, parsed.no_kill).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
}
|
||||
|
||||
let profiles_file = load_profiles();
|
||||
@@ -285,12 +249,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,
|
||||
parsed.no_kill,
|
||||
)
|
||||
.await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
}
|
||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||
(
|
||||
@@ -350,8 +309,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
eprintln!("If you don't have an agent running, you can try the demo mode.");
|
||||
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, parsed.no_kill)
|
||||
.await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
} else {
|
||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||
return Ok(());
|
||||
@@ -363,17 +321,10 @@ 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) —
|
||||
// AND neither --no-kill nor SOCKTOP_NO_KILL disables it as a matter of
|
||||
// policy (shared terminals, public demos).
|
||||
let kill_enabled = local::agent_is_local(&url) && !parsed.no_kill && !no_kill_from_env();
|
||||
let mut app = App::new()
|
||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||
.with_status(is_tls, has_token)
|
||||
.with_compact(parsed.compact)
|
||||
.with_kill_enabled(kill_enabled);
|
||||
.with_compact(parsed.compact);
|
||||
if parsed.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -440,7 +391,6 @@ fn gather_intervals(
|
||||
async fn run_demo_mode(
|
||||
_tls_ca: Option<&str>,
|
||||
compact: bool,
|
||||
no_kill: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
@@ -454,13 +404,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, --no-kill and
|
||||
// SOCKTOP_NO_KILL still override).
|
||||
let mut app = App::new()
|
||||
.with_compact(compact)
|
||||
.with_kill_enabled(local::agent_is_local(&url) && !no_kill && !no_kill_from_env());
|
||||
let mut app = App::new().with_compact(compact);
|
||||
// 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(()) } }
|
||||
}
|
||||
|
||||
@@ -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
@@ -589,7 +589,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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -43,46 +43,6 @@ pub fn truncate_cols(s: &str, max: u16) -> String {
|
||||
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.
|
||||
///
|
||||
@@ -148,23 +108,6 @@ mod tests {
|
||||
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 = [
|
||||
|
||||
@@ -249,7 +249,6 @@ mod render_tests {
|
||||
|
||||
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
|
||||
Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 1024,
|
||||
|
||||
+76
-549
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 kill feature is enabled (agent local, no policy override).
|
||||
if data.kill_enabled
|
||||
&& let Some(line) = help_text.first_mut()
|
||||
{
|
||||
line.spans.push(Span::styled(
|
||||
" t ",
|
||||
Style::default()
|
||||
.fg(PROCESS_DETAILS_ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
line.spans.push(Span::styled(
|
||||
"kill",
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
let help = Paragraph::new(Text::from(help_text))
|
||||
.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),
|
||||
|
||||
@@ -19,10 +19,6 @@ pub struct ProcessModalData<'a> {
|
||||
pub history: ProcessHistoryData<'a>,
|
||||
pub max_mem_bytes: u64,
|
||||
pub unsupported: bool,
|
||||
/// Whether the process-kill feature is available (agent local, no policy
|
||||
/// override). Only used to decide whether the `t` kill hint is shown —
|
||||
/// the kill itself is gated in `App`.
|
||||
pub kill_enabled: bool,
|
||||
}
|
||||
|
||||
/// Parameters for rendering scatter plot
|
||||
@@ -68,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)]
|
||||
@@ -84,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,
|
||||
}
|
||||
|
||||
+107
-185
@@ -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,10 +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,
|
||||
/// The process-kill feature is available (agent local, no policy
|
||||
/// override), so the `t` kill hint applies. Without it the hint would
|
||||
/// advertise a key that deliberately does nothing.
|
||||
pub kill_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -454,60 +449,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.kill_enabled {
|
||||
hints.push(Span::styled("t", key));
|
||||
hints.push(Span::styled(" kill", label));
|
||||
hints.push(Span::styled(" · ", label));
|
||||
}
|
||||
hints.push(Span::styled("x", key));
|
||||
hints.push(Span::styled(" unselect", label));
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -517,10 +468,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +499,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>,
|
||||
@@ -556,6 +509,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;
|
||||
|
||||
@@ -635,6 +598,83 @@ 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 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);
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(columns.constraints())
|
||||
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
|
||||
.split(header_area);
|
||||
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
|
||||
&& mouse.column >= cpu.x
|
||||
&& mouse.column < cpu.x + cpu.width
|
||||
{
|
||||
return Some(ProcSortBy::CpuDesc);
|
||||
}
|
||||
if let Some(mem) = columns.mem_index().map(|i| cols[i])
|
||||
&& mouse.column >= mem.x
|
||||
&& mouse.column < mem.x + mem.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,
|
||||
@@ -908,7 +948,6 @@ mod click_tests {
|
||||
|
||||
fn metrics() -> Metrics {
|
||||
Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 32_000_000_000,
|
||||
@@ -953,7 +992,6 @@ mod click_tests {
|
||||
filtered_indices: &idxs,
|
||||
cached_rows: &cache,
|
||||
peak_cpu: peak,
|
||||
kill_enabled: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -965,29 +1003,20 @@ mod click_tests {
|
||||
}
|
||||
|
||||
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 {
|
||||
processes_handle_mouse(
|
||||
&mut scroll,
|
||||
&mut drag,
|
||||
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,
|
||||
})
|
||||
Rect::new(0, 0, width, 8),
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
/// The hit-test rects are computed by a separate `Layout` call from the one `Table`
|
||||
@@ -1033,110 +1062,3 @@ mod click_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tooltip_tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use socktop_connector::{Metrics, ProcessInfo};
|
||||
|
||||
fn metrics(name: &str) -> Metrics {
|
||||
Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 32_000_000_000,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: "t".into(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![ProcessInfo {
|
||||
pid: 4242,
|
||||
name: name.into(),
|
||||
cpu_usage: 1.5,
|
||||
mem_bytes: 1_000_000,
|
||||
}],
|
||||
gpus: None,
|
||||
process_count: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the pane with a selection and return the whole buffer as text.
|
||||
fn rendered(name: &str, width: u16, kill_enabled: bool) -> String {
|
||||
let m = metrics(name);
|
||||
let mut cache = Vec::new();
|
||||
let peak = rebuild_row_cache(&m, &mut cache);
|
||||
let idxs = [0usize];
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_top_processes(
|
||||
f,
|
||||
Rect::new(0, 0, width, 12),
|
||||
ProcessDisplayParams {
|
||||
metrics: Some(&m),
|
||||
scroll_offset: 0,
|
||||
sort_by: ProcSortBy::CpuDesc,
|
||||
selected_process_pid: Some(4242),
|
||||
selected_process_index: Some(0),
|
||||
search_query: "",
|
||||
search_active: false,
|
||||
filtered_indices: &idxs,
|
||||
cached_rows: &cache,
|
||||
peak_cpu: peak,
|
||||
kill_enabled,
|
||||
},
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut out = String::new();
|
||||
for y in 0..12 {
|
||||
for x in 0..width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_offers_kill_for_a_local_agent() {
|
||||
let out = rendered("some-process", 80, true);
|
||||
assert!(out.contains("details"), "no hint rendered at all:\n{out}");
|
||||
assert!(
|
||||
out.contains("kill"),
|
||||
"local agent should offer kill:\n{out}"
|
||||
);
|
||||
assert!(out.contains("unselect"));
|
||||
}
|
||||
|
||||
/// The key does nothing for a remote agent, so advertising it would be a lie.
|
||||
#[test]
|
||||
fn hint_omits_kill_for_a_remote_agent() {
|
||||
let out = rendered("some-process", 80, false);
|
||||
assert!(out.contains("details"), "no hint rendered at all:\n{out}");
|
||||
assert!(
|
||||
!out.contains("kill"),
|
||||
"remote agent must not offer kill:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: the hint used to be sized from the full label including the
|
||||
/// process name, and was skipped entirely when that made it wider than the
|
||||
/// pane — so it vanished exactly when a long-named process was selected.
|
||||
#[test]
|
||||
fn hint_survives_a_very_long_process_name() {
|
||||
let long = "/usr/lib/firefox-esr/firefox-esr-with-a-really-long-suffix";
|
||||
let out = rendered(long, 80, true);
|
||||
assert!(
|
||||
out.contains("details") && out.contains("kill"),
|
||||
"hint disappeared for a long process name:\n{out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(':') {
|
||||
|
||||
@@ -106,79 +106,3 @@ fn test_compact_flag_documented_and_accepted() {
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_kill_flag_documented_and_accepted() {
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let out = Command::new(exe)
|
||||
.args(["--no-kill", "--help"])
|
||||
.output()
|
||||
.expect("run socktop --no-kill --help");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"socktop --no-kill --help did not succeed"
|
||||
);
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.contains("--no-kill"),
|
||||
"help text missing --no-kill\n{text}"
|
||||
);
|
||||
|
||||
// The flag must not be mistaken for the positional URL argument.
|
||||
let out2 = Command::new(exe)
|
||||
.args(["--no-kill", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
||||
.output()
|
||||
.expect("run socktop --no-kill --dry-run");
|
||||
assert!(
|
||||
out2.status.success(),
|
||||
"socktop --no-kill with a URL was rejected: {}",
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_kill_env_var_accepted() {
|
||||
// SOCKTOP_NO_KILL must not break startup — the env-only path is how the
|
||||
// webterm deployment disables the kill feature for every invocation.
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let out = Command::new(exe)
|
||||
.env("SOCKTOP_NO_KILL", "1")
|
||||
.args(["--dry-run", "ws://127.0.0.1:3000/ws"])
|
||||
.output()
|
||||
.expect("run socktop with SOCKTOP_NO_KILL=1");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"socktop with SOCKTOP_NO_KILL=1 did not succeed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_option_rejected_not_treated_as_url() {
|
||||
// Regression guard for the socktop.io incident (Aug 2026): socktop 1.60.1
|
||||
// parsed the then-unknown --no-kill flag as the positional websocket URL,
|
||||
// which made it prompt to overwrite the 'local' profile's URL with the
|
||||
// literal string "--no-kill". Unknown options must fail loudly instead of
|
||||
// falling through to the URL slot.
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let out = Command::new(exe)
|
||||
.args(["--not-a-real-flag", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
||||
.output()
|
||||
.expect("run socktop with unknown flag");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(2),
|
||||
"unknown option should exit 2, got: {:?}\nstderr: {}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("Unknown option '--not-a-real-flag'"),
|
||||
"stderr should name the rejected option\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "socktop_agent"
|
||||
version = "1.60.2"
|
||||
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]
|
||||
|
||||
@@ -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
@@ -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![])
|
||||
}
|
||||
|
||||
+263
-274
@@ -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))
|
||||
// GPUs: if we already determined none exist, short-circuit (no repeated probing)
|
||||
let gpus = if gpu_enabled() {
|
||||
if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
None
|
||||
} else if let Some(cached) = GPUS.get_fresh(TTL) {
|
||||
cached
|
||||
} else if cached_gpus().is_some() {
|
||||
cached_gpus()
|
||||
} else {
|
||||
let v = collect_all_gpus().await;
|
||||
// First probe records presence; subsequent calls rely on the flags.
|
||||
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(v.is_some(), std::sync::atomic::Ordering::Release);
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
} else {
|
||||
state
|
||||
.gpu_present
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
GPUS.set(v.clone());
|
||||
}
|
||||
set_gpus(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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -42,7 +42,6 @@ async fn test_process_cache_ttl() {
|
||||
};
|
||||
|
||||
let journal_response = JournalResponse {
|
||||
notice: None,
|
||||
entries: vec![],
|
||||
total_count: 0,
|
||||
truncated: false,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "socktop_connector"
|
||||
version = "1.60.2"
|
||||
version = "1.50.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "WebSocket connector library for socktop agent communication"
|
||||
|
||||
@@ -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() {
|
||||
// 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);
|
||||
}
|
||||
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
@@ -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,88 +114,56 @@ 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)));
|
||||
}
|
||||
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,
|
||||
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 {
|
||||
struct NoVerify;
|
||||
impl ServerCertVerifier for NoVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_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,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms)
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms)
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.algorithms.supported_schemes()
|
||||
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);
|
||||
let (ws, _) = tokio_tungstenite::connect_async_tls_with_config(
|
||||
req,
|
||||
None,
|
||||
config.verify_hostname,
|
||||
Some(Connector::Rustls(cfg)),
|
||||
)
|
||||
.await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tls"))]
|
||||
async fn connect_with_ca_and_config(
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+3
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
-4384
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user