Compare commits

..

27 Commits

Author SHA1 Message Date
jasonwitty 56a2dc372a Reject unknown options in parse_args instead of treating them as the URL
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
An older socktop handed a newer flag (webterm 0.3.9's restricted shell
passing --no-kill to 1.60.1) silently parsed the flag as the positional
websocket URL and offered to overwrite the named profile's URL with the
literal flag text. Unknown options now fail with 'Unknown option' and
exit code 2 (help remains exit 0), with a regression test covering the
exact incident shape.

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

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

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

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

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

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

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

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

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

* chore: bump all crates to 1.60.1

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review fixes for the process-kill feature:

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 08:11:17 -07:00
jason 0322308896 housekeeping-p2: security, correctness, and performance pass before 1.51 (#39)
* chore: dead-code sweep

- Delete socktop_connector/src/connector.rs: orphaned since 08f248c removed
  'pub mod connector;' during the modularization refactor. Never compiled
  (verified under default, wasm, and workspace feature combos) but shipped
  in the crates.io tarball and contained an outdated copy of the TLS
  verifier — a trap for anyone patching the pinning bug in the dead copy.
- Delete empty socktop/src/ws.rs, tracked editor backup ui/.modal.rs.backup,
  and stray test_thiserror.rs at the repo root.
- Delete the two LEGACY #[allow(dead_code)] process input handlers; the
  header-click render test now exercises the live _with_selection handler
  instead (better coverage of the real path).
- Drop unused sysinfo dependency from the socktop client.
- Replace stale 'temporarily increased for testing' comment on
  COMPRESSION_THRESHOLD (it already held the production value).

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

* fix(connector): make certificate pinning real; disable Nagle

Security: with --verify-hostname off (the default), the old NoVerify
verifier accepted ANY server certificate — the CA loaded from --tls-ca
was never consulted, so the documented pinning was a no-op and the
connection was trivially MITM-able. Replace it with PinnedCertVerifier:
the presented end-entity cert must be byte-identical to a cert in the
--tls-ca file (any cert in a multi-cert PEM matches, supporting
rotation). Signature validation now uses the ring provider's full
algorithm set instead of a hardcoded 3-scheme list. Empty PEM files
fail fast instead of failing closed per-handshake.

The --verify-hostname path is unchanged (WebPki root-store validation).

Also: the third argument of connect_async_tls_with_config is
tungstenite's disable_nagle flag, not a verification toggle — we were
passing verify_hostname there, leaving Nagle ON for default users. Pass
true unconditionally, and disable Nagle on the plain ws:// path too;
socktop exchanges small request/response frames where Nagle only adds
latency.

Client now consumes the connector via a dual path+version dep so these
fixes are in local builds and CI before the crates.io publish (cargo
strips the path on publish). Connector version -> 1.51.0.

Verified E2E: agent A's cert connects to agent A; agent B's cert
against agent A fails the handshake (the rpi-worker-1 wrong-PEM
scenario); --verify-hostname against a 127.0.0.1 SAN still connects.

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

* fix(agent): GPU worker thread, async journalctl, correctness + cache fixes

Lightweight:
- GPU collection moves to a dedicated worker thread that owns the gfxinfo
  handle for the process lifetime. gfxinfo's active_gpu() runs a full NVML
  init/teardown (~20ms, blocking) and we were paying it on the async
  runtime for every collect — measured at ~80% of the agent's entire
  active CPU on a GPU machine. The handle holds Rc<Nvml> (not Send), so a
  thread + mpsc/oneshot channel pair confines it; a zero-total-VRAM reply
  is treated as a dead session (driver reload) and re-probed.
- journalctl now runs via tokio::process instead of blocking one of the
  two runtime workers for the duration of the subprocess.
- TtlCell (state.rs) replaces the four hand-rolled static TTL caches; a
  cached negative result now counts as fresh, so hosts with no matching
  temp sensor or GPU stop rescanning every request. Single lock+clone on
  the GPU cache hit path (was two).

Correctness:
- Process/child CPU times are now microseconds as documented; they were
  milliseconds, rendering 1000x too small next to (correct) thread times.
- Non-Linux per-process CPU%% clamps AFTER dividing by core count; a
  4-cores-busy process on an 8-core box reported 12.5% instead of 50%.
- Journal timestamps are real RFC 3339 UTC plus an additive timestamp_us
  field (sorting is now numeric); the old strings were Debug-formatted
  SystemTime mangled by string replace.
- Partition detection uses /sys/block on Linux: whole-disk filesystems on
  names like nvme0n1 or zram1 are no longer misclassified as partitions.
  One shared parent_disk_name() replaces two inline copies.
- New sampled_at_ms on the metrics payload (additive) records when the
  snapshot was actually collected, so clients can compute exact rates
  across the agent's TTL cache.

Security/robustness:
- key.pem is created 0600 (was umask default 0644, world-readable) and
  pre-1.51 keys are tightened on startup.
- Per-PID detail/journal caches now evict (60s max age, 64 entries max);
  they previously grew without bound under PID-walking clients.
- The two per-PID ws handlers collapse into one generic helper.
- /proc/<pid>/stat parsing unified in one comm-safe module.

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

* fix(tui): responsive input, request timeouts, poisoned-stream reconnect

R1 — input latency: the event loop drained input once per iteration, then
slept the whole metrics interval; keys and wheel events queued for up to
500ms (or the full interval at slower rates) and applied in bursts. The
input block is extracted to drain_input() and the tail sleep replaced by
a deadline wait in <=33ms poll slices that handles and repaints input the
moment it arrives. Verified: help modal opens <150ms into a 2000ms tick.

R2 — freeze-proofing: metrics/processes/disks requests had no timeout; a
half-dead connection left ws.next() pending forever and froze the TUI
with no way to quit (raw mode eats Ctrl+C as an unread key event). All
requests now carry a 5s budget.

C3 — desync: replies are matched to requests by order alone, so a timed-
out request's late reply would shift every subsequent reply off by one.
Any timeout now treats the stream as poisoned and goes through the
reconnect flow — a fresh stream is aligned by construction. The modal
endpoints additionally mark process details unsupported (flag resets on
modal close/selection change) so a detail-less agent doesn't cause a
reconnect loop. While disconnected the fetch path idles: recovery belongs
to the manual/auto retry paths instead of 5s-timeout hammering.

C7 — fit::truncate_middle_cols replaces util::truncate_middle: display-
width aware and char-boundary safe; the byte-slicing version panicked the
draw loop on non-ASCII device names.

Verified live: agent kill -9 mid-session -> error modal in <3s, q exits
while disconnected, r reconnects and resumes.

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

* chore: version 1.51.0, path-dep the wasm examples, README notes

- socktop, socktop_agent, socktop_connector -> 1.51.0.
- socktop_wasm_test and zellij_socktop_plugin consume the in-repo
  connector via path deps so wasm-feature API drift is caught at PR time
  instead of after publish. Immediately proved out: the wasm requests
  module needed the new sampled_at_ms field, invisible to native builds.
- zellij plugin gains the standalone [workspace] marker (it could not be
  cargo-checked in-tree at all before). NOTE: its lib.rs has pre-existing
  compile errors unrelated to the connector (static mut STATE conflicts
  with register_plugin!, missing BTreeMap import) — needs its own rework,
  out of scope here.
- README: sampled_at_ms in the example payload.

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

* fix(review): restore Agent Update Required flow, command field, axis alignment

Fixes from Jason's hands-on verification of the branch:

1. Old-agent messaging regression (this branch): a detail-request timeout
   went through the loud poison/reconnect flow, burying the ProcessDetails
   modal's 'Agent Update Required' message under a connection-error modal.
   Old agents IGNORE unknown messages (no late reply, no desync), so the
   optional per-PID endpoints now use quiet_reconnect(): swap the stream
   silently (still safe against merely-slow agents) and let the modal show
   its message. Only a failed reconnect surfaces loudly. Verified against
   a real v1.40.0 agent: message shows, session stays healthy.

2. Draw starvation (this branch): an agent that never answers get_metrics
   put the loop in fetch->timeout->poison->restart cycles that never
   reached the draw call — permanently blank TUI. The iteration now paints
   before fetching, and a second consecutive metrics timeout trips a
   circuit breaker: persistent 'Agent is not responding' error, recovery
   left to the manual/30s retry paths. Verified against a 0.9-era agent.

3. Command & Details pane blank (pre-existing on master): the minimal-
   refresh optimization dropped cmd/exe/cwd from the detail endpoint's
   ProcessRefreshKind, so process.cmd() had nothing to return. Restored
   with UpdateKind::OnlyIfNotSet — immutable values, read once per PID.
   Regression test added; journal E2E re-verified (100 entries render).

4. Scatter-plot axis misalignment: Y labels used a fixed 4-char field from
   the era when CPU times were 1000x too small; honest millisecond values
   (e.g. 136114) blew through it. Labels now right-align to the widest
   value per frame and X labels/titles share the dynamic padding.

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

* feat: journal access notice, 1.60.0, install script, changelog, riscv protoc fallback

- Journal pane now distinguishes 'no entries' from 'no journal access':
  journalctl exits 0 with empty output when the agent's user simply can't
  see the target's entries (demo mode / user-run agents), explaining
  itself only on stderr. The agent forwards that hint as an additive
  JournalResponse.notice and the client renders it with practical advice.
  Verified E2E via a stub journalctl emulating the unprivileged case.
- Version 1.60.0 across all crates (1.51 would read fine, but the repo's
  scheme is 1.40/1.50/…, and a literal 1.6.0 would sort BELOW 1.50.0 in
  semver). All user-facing version strings already come from
  CARGO_PKG_VERSION — a stale binary was the only way to see an old one.
- scripts/install.sh: build-from-source install/upgrade for the test
  fleet (Linux + macOS). Detects in-repo checkouts, installs rustup when
  missing, replaces a systemd socktop-agent service binary in place and
  restarts it, requires system protoc on riscv64.
- build.rs (agent + connector): fall back to $PROTOC / PATH when
  protoc-bin-vendored has no binary for the host (riscv64) — native SBC
  builds previously panicked in the build script.
- CHANGELOG.md covering v1.50.0 -> 1.60.0.

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

* test: add notice field to cache test initializer

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

* fix: untrack zellij plugin build dir; installer updates all PATH copies

- Remove zellij_socktop_plugin/target from git (3,577 files committed by
  accident in bf6ac87): the root .gitignore anchors /target to the repo
  root, so the standalone plugin's own build dir wasn't covered. Ignore
  target/ at any depth (also fixes the pre-existing
  '/socktop-wasm-test/target' entry, which pointed at a hyphenated path
  that doesn't exist).

- install.sh now updates EVERY copy of socktop/socktop_agent on PATH,
  not just $PREFIX: a stale 'cargo install' in ~/.cargo/bin shadows
  /usr/local/bin on most PATHs, so an install could 'succeed' while
  'socktop --version' kept reporting the old release. Extra copies that
  can't be written are warned about, not fatal, and the script now
  prints which binary is actually active on PATH at the end.

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

* feat(installer): manage the socktop-agent systemd service

Upgrade path (unit already present): NEVER touch the unit file — it is
the operator's config (SSL, tokens, ports live there as Environment=
lines). Only the binary at the unit's own ExecStart path is replaced,
then the service restarts. Flags/args preserved by construction.

Fresh path (no unit): full first-time setup mirroring the deb postinst
and the agent-service docs — create the socktop system user/group and
/var/lib/socktop, install docs/socktop-agent.service (ExecStart rewritten
to wherever this run installed the agent; embedded fallback for old
refs), daemon-reload, enable --now, and print how to turn on TLS/token.

Also: system-level operations get their own sudo decision (SYS_SUDO) —
previously they inherited the PREFIX sudo flag, so a writable --prefix
made the service section run groupadd/systemctl unprivileged and die.
No sudo at all now skips service management with a warning instead of
failing the install.

Both branches dry-run verified with stubbed systemctl/sudo.

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

* fix(installer): don't bind fresh agent services onto occupied ports

The Orange Pi install put the new service straight into a crash-restart
loop: the unit's default --port 3000 collided with a Docker service
already publishing 3000 (Umami; Gitea and friends default there too).
Fresh installs now scan 3000/3001/3010/3231/3232 via ss and configure
the unit on the first free port, warning loudly when 3000 was taken and
printing the resulting ws:// URL. Upgrades still never touch the unit.

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

* fix(agent): detect NVIDIA GPUs on distros without the unversioned NVML soname

On Debian and derivatives the NVIDIA driver ships only libnvidia-ml.so.1
(the unversioned symlink belongs to the dev package), and nvml-wrapper's
default init dlopens the unversioned name — so gfxinfo reported 'No GPU
found' on a fully functional RTX A2000 host while nvidia-smi worked
fine. Arch-family distros ship the symlink, which is why the desktop
never showed this.

The GPU worker now falls back to initializing NVML directly with the
versioned soname when gfxinfo's probe fails, collecting name/util/vram
through the same handle-caching path. nvml-wrapper was already in the
tree via gfxinfo — same version, no new build cost.

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

* fix(agent): box the NVML handle variant (clippy large_enum_variant)

CI clippy runs with -D warnings; Nvml is a large struct next to the
16-byte Box<dyn Gpu> variant.

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

* fix(installer): survive self-modification mid-run; sturdier unit detection

Root cause of the mixed-up second install on the A2000 host: when run
from the clone it manages, the script's own git checkout/merge REPLACES
scripts/install.sh while bash is still executing it. Bash reads scripts
lazily by byte offset, so it resumed parsing the NEW file at the OLD
offset and executed an arbitrary tail of it — observed as the fresh-
service path running on a host whose unit already existed: the port scan
saw the still-running old service on 3000 and silently wrote a new unit
on 3001, while enable --now on the already-active service changed
nothing until a manual daemon-reload.

Fix: the whole script now runs inside main(), invoked as
'main "$@"; exit $?' so bash parses everything up front and never
reads the file again after main returns (the exit lives in the same
parse unit — demonstrated necessary: with a bare 'main "$@"' ending,
bash still executed the swapped file's trailing content after main
returned).

Also: unit existence is now checked with 'systemctl cat' instead of
grepping the full list-unit-files output.

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

* docs(installer): use a durable ref in the usage example

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:45:10 -07:00
jason ebda3c51af fix(ui): keep the important text visible on narrow terminals (#38)
Three panes painted two independent pieces of text onto one row with nothing
reserving space between them, so below roughly 105 columns the right-hand piece
was simply drawn over the tail of the left one:

  socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about⏱ 500ms metrics | 2000ms

The width arithmetic used str::len(), a byte count, so the emoji in these strings
overstated their width and left orphaned glyphs at the right edge as well. The
process table had the same problem in a different form: it handed the layout
solver a fixed, over-constrained column set, so a narrow pane crushed the
percentage-sized Name column to nothing while fixed-width PID and Mem % kept
their full width — losing the one field that identifies a process.

Add ui::fit (measure in terminal columns, truncate on character boundaries, pick
the richest wording that fits) and give each pane a priority ladder:

- Header: drop the key hints, then the TLS/token badges, then the
  "socktop — host:" prefix, then the metrics/procs words, and only then shorten
  the hostname. Hostname and intervals are what survive longest.
- CPU pane: drop the "CPU Temp:" label, then the now:/avg: labels, then the
  average, then the temperature's decimal, and the temperature itself last — a
  thermal warning outranks a second decimal place.
- Process table: Name is unconditional; CPU %, then Mem, then PID, then Mem %
  are added as the pane widens, so Mem % is the first to go and Name the last.

Also fixes sort-header clicks, which resolved against a Layout that omitted the
column spacing the Table renders with, so a click landed off by up to four
columns. Covered by a test that clicks each label where it is actually drawn.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:47:03 -07:00
jason 623a6e5f85 feat(ui): compact layout for small terminal windows (#37)
In a short window the fixed root layout runs out of rows and the CPU graph and
per-core bars are what collapse first: the header, gauges and process table hold
fixed heights, so at ~18 rows the top row is left with no drawable interior and
both panes disappear entirely.

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

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

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

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

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

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

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

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

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

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

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

* Add summary documentation for debian packaging

* fix unit test, move to macro cargo_bin!

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

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

* Fix CI build by installing libdrm development dependencies

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

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

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

* feature gate GPU stats for arm v7

* specify correct package names.

* install aarch64-linux-gnu-gcc build dep

* specify correct package name

* add RISC-V GCC compiler

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

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

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

* add ports.ubuntu.com to sources

* Add ARM64 as a foreign architecture

* fixe for ARM64 build.

* security.ubuntu.com` aNNOYING

* apt repo github page

* copy output to apt repo

* fix secrets path

* fix secrets path

* change build dep

* Fix postinst message box alignment

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

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

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

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

---------

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

* style: cargo fmt

* fix: replace manual zero-guarded divisions with checked_div

* fix: collapse nested if into match guard

* style: cargo fmt

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

* fix windows build

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 16:14:29 -08:00
jason 8f69e469e6 Update README with website link 2025-11-30 10:21:26 -08:00
68 changed files with 11221 additions and 4946 deletions
+446
View File
@@ -0,0 +1,446 @@
name: Build Debian Packages
on:
# APT publishing is release-driven: we build + publish only on `v*` tag
# pushes. PRs into master still build the .debs as a sanity check (no
# publish). Manual dispatch is kept as an escape hatch.
push:
tags:
- "v*"
pull_request:
branches:
- master
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
build-deb:
name: Build .deb for ${{ matrix.target }}
# PINNED, not ubuntu-latest: the binaries link against this runner's
# (multiarch) glibc, so the runner sets the MINIMUM glibc the .debs demand
# at install time. ubuntu-latest moved to 24.04/glibc 2.39 and the packages
# stopped installing on Debian 12/RPi OS bookworm (glibc 2.36). 22.04 links
# 2.35, which bookworm satisfies. The "enforce glibc floor" step below
# turns any future violation into a red build instead of a fleet-wide apt
# failure — if this pin ever has to move past bookworm's glibc, that step
# is the contract to renegotiate first.
runs-on: ubuntu-22.04
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
arch: amd64
- target: aarch64-unknown-linux-gnu
arch: arm64
- target: armv7-unknown-linux-gnueabihf
arch: armhf
- target: riscv64gc-unknown-linux-gnu
arch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev
- name: Install cross-compilation tools (ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo dpkg --add-architecture arm64
# Disable all existing sources and create new ones with proper arch specifications
sudo mv /etc/apt/sources.list /etc/apt/sources.list.backup
sudo mv /etc/apt/sources.list.d /etc/apt/sources.list.d.backup || true
sudo mkdir -p /etc/apt/sources.list.d
# Clear APT cache and lists
sudo rm -rf /var/lib/apt/lists/*
sudo mkdir -p /var/lib/apt/lists/partial
# Create new sources.list with both amd64 and arm64
cat << EOF | sudo tee /etc/apt/sources.list
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc) main universe restricted multiverse
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc)-updates main universe restricted multiverse
deb [arch=amd64] http://archive.ubuntu.com/ubuntu $(lsb_release -sc)-backports main universe restricted multiverse
deb [arch=amd64] http://security.ubuntu.com/ubuntu $(lsb_release -sc)-security main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc) main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-updates main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-backports main universe restricted multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports $(lsb_release -sc)-security main universe restricted multiverse
EOF
echo "=== Contents of /etc/apt/sources.list ==="
cat /etc/apt/sources.list
echo "=== Contents of /etc/apt/sources.list.d/ ==="
ls -la /etc/apt/sources.list.d/ || true
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu libdrm-dev:arm64 libdrm-amdgpu1:arm64
- name: Install cross-compilation tools (ARMhf)
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt-get update
sudo apt-get install -y gcc-arm-linux-gnueabihf
- name: Install cross-compilation tools (RISC-V)
if: matrix.target == 'riscv64gc-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-riscv64-linux-gnu
- name: Install GPU libraries (x86_64)
if: matrix.target == 'x86_64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y libdrm-dev libdrm-amdgpu1
- name: Configure cross-compilation (ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
mkdir -p .cargo
cat >> .cargo/config.toml << EOF
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
EOF
- name: Configure cross-compilation (ARMhf)
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
mkdir -p .cargo
cat >> .cargo/config.toml << EOF
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
EOF
- name: Configure cross-compilation (RISC-V)
if: matrix.target == 'riscv64gc-unknown-linux-gnu'
run: |
mkdir -p .cargo
cat >> .cargo/config.toml << EOF
[target.riscv64gc-unknown-linux-gnu]
linker = "riscv64-linux-gnu-gcc"
EOF
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
- name: Build socktop .deb package
run: |
cargo deb --package socktop --target ${{ matrix.target }} --no-strip
- name: Build socktop_agent .deb package (with GPU support)
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
run: |
cargo deb --package socktop_agent --target ${{ matrix.target }} --no-strip
- name: Build socktop_agent .deb package (without GPU support)
if: matrix.target == 'armv7-unknown-linux-gnueabihf' || matrix.target == 'riscv64gc-unknown-linux-gnu'
run: |
cargo deb --package socktop_agent --target ${{ matrix.target }} --no-strip --no-default-features
- name: Copy packages to debs directory
run: |
mkdir -p debs
cp target/${{ matrix.target }}/debian/*.deb debs/
- name: 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/
- name: Upload .deb packages as artifacts
uses: actions/upload-artifact@v4
with:
name: debian-packages-${{ matrix.arch }}
path: debs/*.deb
if-no-files-found: error
retention-days: 90
# Combine all artifacts into a single downloadable archive
combine-artifacts:
name: Combine all .deb packages
needs: build-deb
runs-on: ubuntu-latest
steps:
- name: Download AMD64 packages
uses: actions/download-artifact@v4
with:
name: debian-packages-amd64
path: all-debs
- name: Download ARM64 packages
uses: actions/download-artifact@v4
with:
name: debian-packages-arm64
path: all-debs
- name: Download ARMhf packages
uses: actions/download-artifact@v4
with:
name: debian-packages-armhf
path: all-debs
- name: Download RISC-V packages
uses: actions/download-artifact@v4
with:
name: debian-packages-riscv64
path: all-debs
- name: List all packages
run: |
echo "All generated .deb packages:"
ls -lh all-debs/
- name: Upload combined artifacts
uses: actions/upload-artifact@v4
with:
name: all-debian-packages
path: all-debs/*.deb
if-no-files-found: error
retention-days: 90
- name: Generate checksums
run: |
cd all-debs
sha256sum *.deb > SHA256SUMS
cat SHA256SUMS
- name: Upload checksums
uses: actions/upload-artifact@v4
with:
name: checksums
path: all-debs/SHA256SUMS
retention-days: 90
# Publish packages to gh-pages APT repository
publish-apt-repo:
name: Publish to APT Repository
needs: combine-artifacts
runs-on: ubuntu-latest
# Publish only on `v*` release tags — keep gh-pages stable between
# releases instead of overwriting same-version .debs on every commit.
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download all packages
uses: actions/download-artifact@v4
with:
name: all-debian-packages
path: debs
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev gpg
- name: Checkout gh-pages branch
run: |
git fetch origin gh-pages:gh-pages || echo "gh-pages branch doesn't exist yet"
if git show-ref --verify --quiet refs/heads/gh-pages; then
git checkout gh-pages
else
git checkout --orphan gh-pages
git rm -rf . 2>/dev/null || true
# Create basic structure
mkdir -p dists/stable/main/{binary-amd64,binary-arm64,binary-armhf,binary-riscv64}
mkdir -p pool/main
fi
- name: Copy packages to pool
run: |
mkdir -p pool/main
cp debs/*.deb pool/main/
ls -lh pool/main/
- name: Generate Packages files
run: |
for arch in amd64 arm64 armhf riscv64; do
mkdir -p dists/stable/main/binary-$arch
dpkg-scanpackages --arch $arch pool/main /dev/null > dists/stable/main/binary-$arch/Packages 2>/dev/null || true
if [ -s dists/stable/main/binary-$arch/Packages ]; then
gzip -9 -k -f dists/stable/main/binary-$arch/Packages
echo "Generated Packages file for $arch"
fi
done
- name: Generate Release file
run: |
cat > dists/stable/Release << EOF
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf riscv64
Components: main
Description: socktop APT repository
Date: $(date -Ru)
EOF
# Add MD5Sum
echo "MD5Sum:" >> dists/stable/Release
for arch in amd64 arm64 armhf riscv64; do
for file in dists/stable/main/binary-$arch/Packages*; do
if [ -f "$file" ]; then
md5sum "$file" | awk '{print " " $1, "'$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)'", "'"${file#dists/stable/}"'"}' >> dists/stable/Release
fi
done
done
# Add SHA256
echo "SHA256:" >> dists/stable/Release
for arch in amd64 arm64 armhf riscv64; do
for file in dists/stable/main/binary-$arch/Packages*; do
if [ -f "$file" ]; then
sha256sum "$file" | awk '{print " " $1, "'$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)'", "'"${file#dists/stable/}"'"}' >> dists/stable/Release
fi
done
done
- name: Set GPG available flag
id: check_gpg
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "available=true" >> $GITHUB_OUTPUT
else
echo "available=false" >> $GITHUB_OUTPUT
fi
- name: Import GPG key
if: steps.check_gpg.outputs.available == 'true'
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys
- name: Sign repository
if: steps.check_gpg.outputs.available == 'true'
env:
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
if [ -n "$GPG_PASSPHRASE" ]; then
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
--default-key "$GPG_KEY_ID" \
-abs -o dists/stable/Release.gpg dists/stable/Release
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
--default-key "$GPG_KEY_ID" \
--clearsign -o dists/stable/InRelease dists/stable/Release
else
gpg --batch --yes --no-tty --pinentry-mode loopback \
--default-key "$GPG_KEY_ID" \
-abs -o dists/stable/Release.gpg dists/stable/Release
gpg --batch --yes --no-tty --pinentry-mode loopback \
--default-key "$GPG_KEY_ID" \
--clearsign -o dists/stable/InRelease dists/stable/Release
fi
gpg --armor --export "$GPG_KEY_ID" > KEY.gpg
echo "✓ Repository signed"
- name: Create unsigned repository notice
if: steps.check_gpg.outputs.available == 'false'
run: |
echo "⚠️ Warning: GPG_PRIVATE_KEY not set. Repository will be UNSIGNED."
echo "⚠️ Add GPG secrets to sign the repository automatically."
echo "To add secrets: Settings → Secrets and variables → Actions → Repository secrets"
- name: Copy index.html if exists
run: |
git checkout ${{ github.ref_name }} -- index.html 2>/dev/null || echo "No index.html in source branch"
- name: Commit and push to gh-pages
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add .
if git diff --staged --quiet; then
echo "No changes to commit"
else
COMMIT_MSG="Update APT repository"
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
COMMIT_MSG="$COMMIT_MSG - Release ${{ github.ref_name }}"
else
COMMIT_MSG="$COMMIT_MSG - $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
fi
git commit -m "$COMMIT_MSG"
git push origin gh-pages
echo "✓ Published to gh-pages"
fi
# Optional: Create a release with the .deb files if this is a tag
create-release:
name: Create GitHub Release
needs: combine-artifacts
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Download all packages
uses: actions/download-artifact@v4
with:
name: all-debian-packages
path: release-debs
- name: Download checksums
uses: actions/download-artifact@v4
with:
name: checksums
path: release-debs
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: release-debs/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+12 -2
View File
@@ -1,7 +1,17 @@
/target
# Any crate's build directory, including standalone sub-crates
# (zellij_socktop_plugin, socktop_wasm_test) that live outside the workspace.
target/
.vscode/
/socktop-wasm-test/target
/.cargo/
# Documentation files from development sessions (context-specific, not for public repo)
/OPTIMIZATION_PROCESS_DETAILS.md
/THREAD_SUPPORT.md
# APT Repository - Safety: Never commit private keys!
*.asc
*-private.key
*-secret.key
gpg-private-backup.key
secring.gpg
# Note: Release.gpg, InRelease, and KEY.gpg (public) ARE safe to commit
+82
View File
@@ -0,0 +1,82 @@
# 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
+37 -111
View File
@@ -99,9 +99,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.15.0"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5932a7d9d28b0d2ea34c6b3779d35e3dd6f6345317c34e73438c4f1f29144151"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -109,15 +109,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.33.0"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1826f2e4cfc2cd19ee53c42fbf68e2f81ec21108e0b7ecf6a71cf062137360fc"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
dependencies = [
"bindgen",
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -218,26 +218,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 2.0.110",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -305,9 +285,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "castaway"
@@ -330,15 +310,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -363,17 +334,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "cmake"
version = "0.1.54"
@@ -913,12 +873,6 @@ dependencies = [
"wmi",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.12"
@@ -1247,15 +1201,6 @@ dependencies = [
"mach2",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1550,9 +1495,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
@@ -1796,6 +1741,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -1889,7 +1840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools 0.14.0",
"itertools",
"log",
"multimap",
"once_cell",
@@ -1909,7 +1860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.110",
@@ -2011,9 +1962,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
@@ -2063,7 +2014,7 @@ dependencies = [
"compact_str",
"hashbrown 0.16.0",
"indoc",
"itertools 0.14.0",
"itertools",
"kasuari",
"lru",
"strum",
@@ -2115,7 +2066,7 @@ dependencies = [
"hashbrown 0.16.0",
"indoc",
"instability",
"itertools 0.14.0",
"itertools",
"line-clipping",
"ratatui-core",
"strum",
@@ -2200,12 +2151,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2264,9 +2209,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.8"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
@@ -2467,7 +2412,7 @@ dependencies = [
[[package]]
name = "socktop"
version = "1.50.0"
version = "1.60.2"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2477,16 +2422,17 @@ dependencies = [
"ratatui",
"serde",
"serde_json",
"socktop_connector 1.50.0 (registry+https://github.com/rust-lang/crates.io-index)",
"socktop_connector",
"sysinfo",
"tempfile",
"tokio",
"unicode-width",
"url",
]
[[package]]
name = "socktop_agent"
version = "1.50.2"
version = "1.60.2"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2496,6 +2442,7 @@ dependencies = [
"futures-util",
"gfxinfo",
"hostname",
"nvml-wrapper",
"once_cell",
"prost",
"prost-build",
@@ -2517,7 +2464,7 @@ dependencies = [
[[package]]
name = "socktop_connector"
version = "1.50.0"
version = "1.60.2"
dependencies = [
"flate2",
"futures-util",
@@ -2538,27 +2485,6 @@ 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"
@@ -2790,9 +2716,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.44"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -2800,22 +2726,22 @@ dependencies = [
"num-conv",
"num_threads",
"powerfmt",
"serde",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.24"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -3080,7 +3006,7 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.14.0",
"itertools",
"unicode-segmentation",
"unicode-width",
]
+1
View File
@@ -26,6 +26,7 @@ sysinfo = "0.37"
# CLI UI
ratatui = "0.30"
crossterm = "0.29"
unicode-width = "0.2"
# web server (remote-agent)
axum = { version = "0.7", features = ["ws"] }
+156
View File
@@ -0,0 +1,156 @@
# Debian Packaging Implementation Summary
## Overview
Successfully implemented Debian packaging for socktop using `cargo-deb`, with GitHub Actions automation for building packages for both AMD64 and ARM64 architectures.
## Branches Created
1. **`feature/debian-packaging`** - Main branch with debian packaging implementation
2. **`feature/man-pages`** - Separate branch for man pages work (to be researched further)
## What Was Added
### 1. Cargo.toml Updates
Both `socktop/Cargo.toml` and `socktop_agent/Cargo.toml` were updated with:
- `[package.metadata.deb]` sections
- Package metadata (maintainer, description, dependencies)
- Asset definitions (binaries, documentation)
- Systemd service configuration (agent only)
### 2. Systemd Service
**File**: `socktop_agent/socktop-agent.service`
- Runs as `socktop` user/group
- Listens on port 3000 by default
- Security hardening enabled
- Disabled by default (user must explicitly enable)
### 3. Maintainer Scripts
**Directory**: `socktop_agent/debian/`
- **`postinst`**: Creates `socktop` user/group, sets up `/var/lib/socktop` directory
- **`postrm`**: Cleanup on package removal/purge
### 4. GitHub Actions Workflow
**File**: `.github/workflows/build-deb.yml`
Features:
- Builds for both x86_64 and ARM64
- Triggered on:
- Push to `master` or `feature/debian-packaging`
- Pull requests to `master`
- Version tags (v*)
- Manual workflow dispatch
- Creates artifacts:
- `debian-packages-amd64`
- `debian-packages-arm64`
- `all-debian-packages` (combined)
- `checksums` (SHA256SUMS)
- Automatic GitHub releases for version tags
### 5. Documentation
**File**: `docs/DEBIAN_PACKAGING.md`
Comprehensive guide covering:
- Building packages locally
- Cross-compilation for ARM64
- Installation and configuration
- Using GitHub Actions artifacts
- Creating local APT repositories
- Troubleshooting
## Package Details
### socktop (TUI Client)
- **Binary**: `/usr/bin/socktop`
- **Size**: ~3.5 MB (x86_64)
- **Dependencies**: Auto-detected
### socktop_agent (Daemon)
- **Binary**: `/usr/bin/socktop_agent`
- **Service**: `socktop-agent.service`
- **User/Group**: `socktop` (created automatically)
- **State directory**: `/var/lib/socktop`
- **Size**: ~6.7 MB (x86_64)
- **Dependencies**: Auto-detected
## Testing
Both packages successfully built locally:
```
✓ socktop_1.50.0-1_amd64.deb
✓ socktop-agent_1.50.1-1_amd64.deb
```
Verified:
- Package contents (dpkg -c)
- Package metadata (dpkg -I)
- Systemd service file inclusion
- Maintainer scripts inclusion
- Documentation inclusion
## Usage
### For Users
Download pre-built packages from GitHub Actions artifacts:
1. Go to Actions tab
2. Select latest "Build Debian Packages" run
3. Download architecture-specific artifact
4. Install: `sudo dpkg -i socktop*.deb`
### For Developers
Build locally:
```bash
cargo install cargo-deb
cargo deb --package socktop
cargo deb --package socktop_agent
```
Cross-compile for ARM64:
```bash
rustup target add aarch64-unknown-linux-gnu
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
cargo deb --package socktop --target aarch64-unknown-linux-gnu
```
## Next Steps
To get packages in official APT repositories:
1. **Short term**: Host packages on GitHub Releases (automated)
2. **Medium term**: Create PPA for Ubuntu users
3. **Long term**: Submit to Debian/Ubuntu official repositories
## Files Modified/Created
```
Modified:
socktop/Cargo.toml
socktop_agent/Cargo.toml
Created:
.github/workflows/build-deb.yml
docs/DEBIAN_PACKAGING.md
socktop_agent/socktop-agent.service
socktop_agent/debian/postinst
socktop_agent/debian/postrm
```
## Commit
```
532ed16 Add Debian packaging support with cargo-deb
```
## Resources
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
- Full documentation in `docs/DEBIAN_PACKAGING.md`
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Witty One Off
Copyright (c) 2026 Witty One Off
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+21 -519
View File
@@ -1,510 +1,36 @@
# socktop
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
_socktop_ is a remote system monitor with a rich TUI, talking to an ultra lightweight agent over WebSockets.
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
<img src="./docs/socktop_demo_1_60.apng" width="100%">
<img src="./docs/socktop_demo.apng" width="100%">
## Resources
| Resource | Location |
| -------- | -------- |
| Website and online demo (yes it's real) | [socktop.io](https://www.socktop.io) |
| Quick Start guide | [https://socktop.io/assets/docs/installation/quick-start.html](https://socktop.io/assets/docs/installation/quick-start.html) |
| Prereqs | [https://socktop.io/assets/docs/installation/prerequisites.html](https://socktop.io/assets/docs/installation/prerequisites.html) |
| APT Install | [https://socktop.io/assets/docs/installation/apt.html](https://socktop.io/assets/docs/installation/apt.html) |
| Cargo Install | [https://socktop.io/assets/docs/installation/cargo.html](https://socktop.io/assets/docs/installation/cargo.html)
| Usage | [https://socktop.io/assets/docs/usage/general.html](https://socktop.io/assets/docs/usage/general.html)
| Auth Setup | [https://socktop.io/assets/docs/security/token.html](https://socktop.io/assets/docs/security/token.html) |
| TLS Setup | [https://socktop.io/assets/docs/security/tls.html](https://socktop.io/assets/docs/security/tls.html) |
| Monitoring Multiple Hosts | [tmux](https://socktop.io/assets/docs/advanced/tmux.html) / [zellij](https://socktop.io/assets/docs/advanced/zellij.html) |
---
## Features
## Platform Support
- Remote monitoring via WebSocket (JSON over WS)
- Optional WSS (TLS): agent autogenerates a selfsigned cert on first run; client pins the cert via --tls-ca/-t
- TUI built with ratatui
- CPU
- Overall sparkline + per-core mini bars
- Accurate per-process CPU% (Linux /proc deltas), normalized to 0100%
- Memory/Swap gauges with human units
- Disks: per-device usage
- Network: per-interface throughput with sparklines and peak markers
- Temperatures: CPU (optional)
- Top processes (top 50)
- PID, name, CPU%, memory, and memory%
- Click-to-sort by CPU% or Mem (descending)
- Scrollbar and mouse/keyboard scrolling
- Total process count shown in the header
- Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled)
- Optional auth token for the agent
Linux (all flavors), ARM/Raspberry Pi (32b/64b), MacOS, Windows, RISC-V (experimental)
---
## Prerequisites: Install Rust (rustup)
## Contributing
Rust is fast, safe, and crossplatform. Installing it will make your machine better. Consider yourself privileged.
Contributions are welcome and you have the freedom to use whatever development tools you would like, as long as there is a human in the loop and all the clippy and unit tests pass you are good to submit a PR. Defects / Bugs just go ahead and fix and file a PR. New features, please create a issue in advance and let me know you are offering to build it. I don't want to be in a position where you worked for a couple of weeks on something and I don't want to merge it.
Linux/macOS:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# load cargo for this shell
source "$HOME/.cargo/env"
# ensure stable is up to date
rustup update stable
rustc --version
cargo --version
# after install you may need to reload your shell, e.g.:
exec bash # or: exec zsh / exec fish
```
Windows (for the brave): install from https://rustup.rs with the MSVC toolchain. Yes, youll need Visual Studio Build Tools. You chose Windows — enjoy the ride.
### Raspberry Pi / Ubuntu / PopOS (required)
Install GPU support with apt command below
```bash
sudo apt-get update
sudo apt-get install libdrm-dev libdrm-amdgpu1
```
_Additional note for Raspberry Pi users. Please update your system to use the newest kernel available through app, kernel version 6.6+ will use considerably less overall CPU to run the agent. For example on a rpi4 the kernel < 6.6 the agent will consume .8 cpu but on the same hardware on > 6.6 the agent will consume only .2 cpu. (these numbers indicate continuous polling at web socket endpoints, when not in use the usage is 0)_
---
## Architecture
Two components:
1) Agent (remote): small Rust WS server using sysinfo + /proc. It collects metrics only when the client requests them over the WebSocket (request-driven). No background sampling loop.
2) Client (local): TUI that connects to ws://HOST:PORT/ws (or wss://HOST:PORT/ws when TLS is enabled) and renders updates.
---
## Quick start
- Build both binaries:
```bash
git clone https://github.com/jasonwitty/socktop.git
cd socktop
cargo build --release
```
- Start the agent on the target machine (default port 3000):
```bash
./target/release/socktop_agent --port 3000
```
- Connect with the TUI from your local machine:
```bash
./target/release/socktop ws://REMOTE_HOST:3000/ws
```
### Cross-compiling for Raspberry Pi
For Raspberry Pi and other ARM devices, you can cross-compile the agent from a more powerful machine:
- [Cross-compilation guide](./docs/cross-compiling.md) - Instructions for cross-compiling from Linux, macOS, or Windows hosts
### Quick demo (no agent setup)
Spin up a temporary local agent on port 3231 and connect automatically:
```bash
socktop --demo
```
Or just run `socktop` with no arguments and pick the builtin `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent:
- Runs locally (`ws://127.0.0.1:3231/ws`)
- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C
---
## Install (from crates.io)
You dont need to clone this repo to use socktop. Install the published binaries with cargo:
```bash
# TUI (client)
cargo install socktop
# Agent (server)
cargo install socktop_agent
```
This drops socktop and socktop_agent into ~/.cargo/bin (add it to PATH).
Notes:
- After installing Rust via rustup, reload your shell (e.g., exec bash) so cargo is on PATH.
- Windows: you can also grab prebuilt EXEs from GitHub Actions artifacts if rustup scares you. It shouldnt. Be brave.
System-wide agent (Linux)
```bash
# If you installed with cargo, binaries are in ~/.cargo/bin
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
# Install and enable the systemd service (example unit in docs/)
sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now socktop-agent
```
```bash
# Enable SSL
# Stop service
sudo systemctl stop socktop-agent
# Edit service to append SSL option and port
sudo micro /etc/systemd/system/socktop-agent.service
--
ExecStart=/usr/local/bin/socktop_agent --enableSSL --port 8443
--
# Reload
sudo systemctl daemon-reload
# Restart
sudo systemctl start socktop-agent
# check logs for certificate location
sudo journalctl -u socktop-agent -f
--
Aug 22 22:25:26 rpi-master socktop_agent[2913998]: socktop_agent: generated self-signed TLS certificate at /var/lib/socktop/.config/socktop_agent/tls/cert.pem
--
```
---
## Usage
Agent (server):
```bash
socktop_agent --port 3000
# or env: SOCKTOP_PORT=3000 socktop_agent
# optional auth: SOCKTOP_TOKEN=changeme socktop_agent
# enable TLS (selfsigned cert, default port 8443; you can also use -p):
socktop_agent --enableSSL --port 8443
```
Client (TUI):
```bash
socktop ws://HOST:3000/ws
# with token:
socktop "ws://HOST:3000/ws?token=changeme"
# TLS with pinned server certificate (recommended over the internet):
socktop --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# (By default hostname/SAN verification is skipped for ease on home networks. To enforce it add --verify-hostname)
socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
```
Intervals (client-driven):
- Fast metrics: ~500 ms
- Processes: ~2 s
- Disks: ~5 s
The agent stays idle unless queried. When queried, it collects just whats needed.
---
## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
Config file location:
- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json`
- Fallback (when XDG not set): `~/.config/socktop/profiles.json`
### Creating a profile
First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically:
```bash
socktop --profile prod ws://prod-host:3000/ws
# With TLS pinning:
socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws
You can also set custom intervals (milliseconds):
```bash
socktop --profile prod --metrics-interval-ms 750 --processes-interval-ms 3000 ws://prod-host:3000/ws
```
```
If a profile already exists you will be prompted before overwriting:
```
$ socktop --profile prod ws://new-host:3000/ws
Overwrite existing profile 'prod'? [y/N]: y
```
To overwrite without an interactive prompt pass `--save`:
```bash
socktop --profile prod --save ws://new-host:3000/ws
```
### Using a saved profile
Just pass the profile name (no URL needed):
```bash
socktop --profile prod
socktop -P prod-tls # short flag
```
The stored URL (and TLS CA path, if any) plus any saved intervals will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL.
### Interactive selection (no args)
If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from:
```
$ socktop
Select profile:
1. prod
2. prod-tls
Enter number (or blank to abort): 2
```
Choosing a number starts the TUI with that profile. A builtin `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting.
### JSON format
An example `profiles.json` (prettyprinted):
```json
{
"profiles": {
"prod": { "url": "ws://prod-host:3000/ws" },
"prod-tls": {
"url": "wss://prod-host:8443/ws",
"tls_ca": "/home/user/certs/prod-cert.pem",
"metrics_interval_ms": 500,
"processes_interval_ms": 2000
}
},
"version": 0
}
```
Notes:
- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`.
- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command).
- Profiles are client-side convenience only; they do not affect the agent.
- Intervals: `metrics_interval_ms` controls the fast metrics poll (default 500 ms). `processes_interval_ms` controls process list polling (default 2000 ms). Values below 100 ms (metrics) or 200 ms (processes) are clamped.
---
## Updating
Update the agent (systemd):
```bash
# on the server running the agent
cargo install socktop_agent --force
sudo systemctl stop socktop-agent
sudo install -o root -g root -m 0755 "$HOME/.cargo/bin/socktop_agent" /usr/local/bin/socktop_agent
# if you changed the unit file:
# sudo install -o root -g root -m 0644 docs/socktop-agent.service /etc/systemd/system/socktop-agent.service
# sudo systemctl daemon-reload
sudo systemctl start socktop-agent
sudo systemctl status socktop-agent --no-pager
# logs:
# journalctl -u socktop-agent -f
```
Update the TUI (client):
```bash
cargo install socktop --force
socktop ws://HOST:3000/ws
```
Tip: If only the binary changed, restart is enough. If the unit file changed, run sudo systemctl daemon-reload.
---
## Configuration (agent)
- Port:
- Flag: --port 8080 or -p 8080
- Positional: socktop_agent 8080
- Env: SOCKTOP_PORT=8080
- TLS (selfsigned):
- Enable: --enableSSL
- Default TLS port: 8443 (override with --port/-p)
- Certificate/Key location (created on first TLS run):
- Linux (XDG): $XDG_CONFIG_HOME/socktop_agent/tls/{cert.pem,key.pem} (defaults to ~/.config)
- The agent prints these paths on creation.
- You can set XDG_CONFIG_HOME before first run to control where certs are written.
- Additional SANs: set `SOCKTOP_AGENT_EXTRA_SANS` (commaseparated) before first TLS start to include extra IPs/DNS names in the cert. Example:
```bash
SOCKTOP_AGENT_EXTRA_SANS="192.168.1.101,myhost.internal" socktop_agent --enableSSL
```
This prevents client errors like `NotValidForName` when connecting via an IP not present in the default cert SAN list.
- Expiry / rotation: the generated cert is valid for ~397 days from creation. If the agent fails to start with an "ExpiredCertificate" error (or your client reports expiry), simply delete the existing cert and key:
```bash
rm ~/.config/socktop_agent/tls/cert.pem ~/.config/socktop_agent/tls/key.pem
# (adjust path if XDG_CONFIG_HOME is set or different user)
systemctl restart socktop-agent # if running under systemd
```
On next TLS start the agent will generate a fresh pair. Only distribute the new cert.pem to clients (never the key).
- Auth token (optional): SOCKTOP_TOKEN=changeme
- Disable GPU metrics: SOCKTOP_AGENT_GPU=0
- Disable CPU temperature: SOCKTOP_AGENT_TEMP=0
---
## Keyboard & Mouse
- Quit: q or Esc
- Processes pane:
- Click “CPU %” to sort by CPU descending
- Click “Mem” to sort by memory descending
- Mouse wheel: scroll
- Drag scrollbar: scroll
- Arrow/PageUp/PageDown/Home/End: scroll
---
## Example agent JSON
```json
{
"cpu_total": 12.4,
"cpu_per_core": [11.2, 15.7],
"mem_total": 33554432,
"mem_used": 18321408,
"swap_total": 0,
"swap_used": 0,
"process_count": 127,
"hostname": "myserver",
"cpu_temp_c": 42.5,
"disks": [{"name":"nvme0n1p2","total":512000000000,"available":320000000000}],
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
"top_processes": [
{"pid":1234,"name":"nginx","cpu_usage":1.2,"mem_bytes":12345678}
],
"gpus": null
}
```
Notes:
- process_count is merged into the main metrics on the client when processes are polled.
- top_processes are the current top 50 (sorting in the TUI is client-side).
---
## Security
Set a token on the agent and pass it as a query param from the client:
Server:
```bash
SOCKTOP_TOKEN=changeme socktop_agent --port 3000
```
Client:
```bash
socktop "ws://HOST:3000/ws?token=changeme"
```
### TLS / WSS
For encrypted connections, enable TLS on the agent and pin the server certificate on the client.
Server (generates selfsigned cert and key on first run):
```bash
socktop_agent --enableSSL --port 8443
```
Client (trust/pin the server cert; copy cert.pem from the agent):
```bash
socktop --tls-ca /path/to/agent/cert.pem wss://HOST:8443/ws
```
Notes:
- Do not copy the private key off the server; only the cert.pem is needed by clients.
- When --tls-ca/-t is supplied, the client autoupgrades ws:// to wss:// to avoid protocol mismatch.
- Hostname (SAN) verification is DISABLED by default (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.
![socktop screenshot](./docs/tmux_4_rpis_v3.jpg)
monitoring 4 Raspberry Pis using Tmux
Prerequisites:
- Install tmux (Ubuntu/Debian: `sudo apt-get install tmux`)
Key bindings (defaults):
- Split left/right: Ctrl-b %
- Split top/bottom: Ctrl-b "
- Move between panes: Ctrl-b + Arrow keys
- Show pane numbers: Ctrl-b q
- Close a pane: Ctrl-b x
- Detach from session: Ctrl-b d
Two panes (left/right)
- This creates a session named "socktop", splits it horizontally, and starts two socktops.
```bash
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
split-window -h 'socktop ws://HOST2:3000/ws' \; \
select-layout even-horizontal \; \
attach
```
Four panes (top-left, top-right, bottom-left, bottom-right)
- This creates a 2x2 grid with one socktop per pane.
```bash
tmux new-session -d -s socktop 'socktop ws://HOST1:3000/ws' \; \
split-window -h 'socktop ws://HOST2:3000/ws' \; \
select-pane -t 0 \; split-window -v 'socktop ws://HOST3:3000/ws' \; \
select-pane -t 1 \; split-window -v 'socktop ws://HOST4:3000/ws' \; \
select-layout tiled \; \
attach
```
Tips:
- Replace HOST1..HOST4 (and ports) with your targets.
- Reattach later: `tmux attach -t socktop`
---
## Platform notes
- Linux: fully supported (agent and client).
- Raspberry Pi:
- 64-bit: aarch64-unknown-linux-gnu
- 32-bit: armv7-unknown-linux-gnueabihf
- Windows:
- TUI + agent can build with stable Rust; bring your own MSVC. Youre on Windows; you know the drill.
- CPU temperature may be unavailable.
- binary exe for both available in build artifacts under actions.
- macOS:
- TUI works; agent is primarily targeted at Linux. Agent will run just fine on macos for debugging but I have not documented how to run as a service, I may not given the "security" feautures with applications on macos. We will see.
---
## Development
### Development
```bash
cargo fmt
@@ -519,35 +45,11 @@ cargo run -p socktop_agent -- --enableSSL --port 8443
A sample pre-commit hook that runs `cargo fmt --all` is provided in `.githooks/pre-commit`.
Enable it (one-time):
```bash
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
```
Every commit will then format Rust sources and restage them automatically.
---
## Roadmap
- [x] Agent authentication (token)
- [x] Hide per-thread entries; only show processes
- [x] Sort top processes in the TUI
- [x] Configurable refresh intervals (client)
- [ ] Export metrics to file
- [x] TLS / WSS support (selfsigned server cert + client pinning)
- [x] Split processes/disks to separate WS calls with independent cadences (already logical on client; formalize API)
- [ ] Outage notifications and reconnect.
- [ ] Per process detailed statistics pane
- [ ] cleanup of Disks section, properly display physical disks / partitions, remove duplicate entries
---
## License
MIT — see LICENSE.
---
MIT — see [LICENSE](LICENSE).
## Acknowledgements
+42
View File
@@ -0,0 +1,42 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGkih7QBDADgX6sYMx2Lp6qcZxeCCizcy4TFsxcRJfp5mfbMplVES0hQToIP
EMC11JqPwQdLliXKjUr8Z2kgM2oqvH+dkdgzUGrw6kTK8YHc+qs37iJAOVS9D72X
tTld282NrtFwzb74nS2GKPkpWI7aSKBpHtWFPX/1ONsc56qGqFd3wwikEvCz8MeJ
HwCD1JZ9F+2DyyXWsTJNgDwPloJSUbtyVuk2gd6PeTg7AQdx92Pk/mggmYbHtP8N
wy072ku1g8K/hplmwIOGpSx1JWvAQkDU/Bb/jSqrYg2wSHO7IQnYE8I3x/zglYBl
FYNh47TVQr0zPVSYR1MQkHU5YLBTDc5UgDvtcsYUiTtq4D/m8HWmKja0/UKGxvDJ
P5sUPcp4dk77RdoCtUe5HImYGS8lo5N3+t0lz8sd9rYmRiIO4f7FJaJqJeHbUJyn
iw/GCQh5D5/D571dICrEq/QhL+k5KhJljPGoVMGPFXJIc7q+CxvGp2oOo5fOlbOn
3kSrM93AJPwT8FMAEQEAAbRFSmFzb24gV2l0dHkgKHNvY2t0b3AgYXB0IHNpZ25p
bmcga2V5KSA8amFzb25wd2l0dHkrc29ja3RvcEBwcm90b24ubWU+iQHOBBMBCgA4
FiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkih7QCGwMFCwkIBwIGFQoJCAsCBBYC
AwECHgECF4AACgkQESwaeYRl+/KV+gwAzfZVZEhO7MQV2EmNeKVK1GycFSm2oUAl
ZbwNIEHu6+tOzqXJb8o65BtGlbLSGavsMpgRCK2SL83DdLOkutG1ahQiJr+5GaXC
zbQgX+VWqGPZtQ+I6/rVoYZPMTCrqpAmFgvVpqv0xod7w8/wny8/XmhQ37KY2/0l
B38oNTvdA7C8jzSrI6kr3XqurvQRW7z+MnC+nCp9Ob9bYtY0kpd4U3NrVdb8m32U
d5LVFwD1OGvzLOSqyJ33IKjSJc4KLvW+aEsHXe+fHO9UEzH8Nbo5MmVvX3QIHiyq
jD4zN16AGsGYqCK4irtQCiD3wBOdsG/RVkgIcdlmAH3EGEp7Ux8+7v1PXYI+UrSs
XE7f1xFTJ2r5TMex6W3he073Em4qhQsrnMF5syTZsM6N+5UqXVOM1RuDVVXr7929
hC3G8pK/A2W5Lwpxl2yzock2CxhvUn7M/xm4VbcPlWTCUd/QzU8VtsgaGHcuhi5e
xHY1AU07STLB9RinjBVf2bmk4oDQcmB6uQGNBGkih7QBDACrjE+xSWP92n931/5t
+tXcujwFlIpSZdbSQFr0B0YyjPRUP4FSzEGu8vuM5ChUfWKhmN1dDr5C4qFo9NgQ
6oCN2HubajSGyXNwnOMlMb5ck79Ubmy9yDV9/ZLqpJJiozGap2/EnNoDhaANlmUg
rfqUHpIB8XC2IZ0Itt05tp/u78dJiB+R6ReZn/bVUafNV4jIqYZfLRzI3FTJ4xvK
FGs/ER+JajAdJQ8LPfazmDQSGw0huguxhopZwKQ/qWZMn1OHq/ZaPvCqbQt3irLw
dLPDC4pEaYGRyADYeyuarG0DVyUQ9XRc/NufKDvOAn33LpBPBpcvNQAsVhWTCYl7
ogQ+suVYVN8Tu7v4bUSHKwzXKvLN/ojJX/Fh7eTW4TPsgLHNHAEDUkSQozIe9vO6
o+vydDqRxuXJgdkR7lqP6PQDYrhRYZGJf57eKf6VtTKYFaMbiMWPU+vcHeB0/iDe
Pv81qro2LD2PG5WCzDpNETBceCTjykb9r0VHx4/JsiojKmsAEQEAAYkBtgQYAQoA
IBYhBB51VqgFObg5S8KCDREsGnmEZfvyBQJpIoe0AhsMAAoJEBEsGnmEZfvyNp8M
AIH+6+hGB3qADdnhNgb+3fN0511eK9Uk82lxgGARLcD8GN1UP0HlvEqkxCHy3PUe
tHcsuYVz7i8pmpEGdFx9zv7MelenUsJniUQ++OZKx6iUG/MYqz//NxY+5lyRmcu2
aYvUxhkgf9zgxXTkTyV2VV32mX//cHcwc+c/089QAPzCMaSrHdNK+ED9+k8uquJ1
lSL9Bm15z/EV42v9Q/4KTM5OBLHpNw0Rvn9C0iuZVwHXBrrA/HSGXpA54AqNUMpZ
kRPgLQcy5yVE2y1aXLXt2XdTn6YPzrAjNoazYYuCWHYIZU7dGkIswpsDirDLKHdD
onb3VShmSpemYjsuFiqhfi6qwCkeHsz/CpQAp70SZ+z9oB8H80PJVKPbPIP3zEf3
i7bcsqHA7stF+8sJclXgxBUBeDJ3O2jN/scBOcvNA6xoRp7+oJbnjDRuxBmh+fVg
TIuw2++vTF2Ml0EMv7ePTpr7b1DofuJRNYGkuAIMVXHjLTqMiTJUce3OUy003zMg
Dg==
=AaPQ
-----END PGP PUBLIC KEY BLOCK-----
+38
View File
@@ -0,0 +1,38 @@
# socktop APT Repository
This repository contains Debian packages for socktop and socktop-agent.
## Adding this repository
Add the repository to your system:
```bash
# Add the GPG key
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
# Add the repository
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
# Update and install
sudo apt update
sudo apt install socktop socktop-agent
```
## Manual Installation
You can also download and install packages manually from the `pool/main/` directory.
```bash
wget https://jasonwitty.github.io/socktop/pool/main/socktop_VERSION_ARCH.deb
sudo dpkg -i socktop_VERSION_ARCH.deb
```
## Supported Architectures
- amd64 (x86_64)
- arm64 (aarch64)
- armhf (32-bit ARM)
## Building from Source
See the main repository at https://github.com/jasonwitty/socktop
+32
View File
@@ -0,0 +1,32 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf
Components: main
Description: socktop APT repository
Date: Sun, 23 Nov 2025 04:05:21 +0000
MD5Sum:
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
SHA256:
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
-----BEGIN PGP SIGNATURE-----
iQGzBAEBCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAYACgkQESwaeYRl
+/KBsAv/eYhnK/XrNtPhLyw/zX2cGfUtBsBZrypFhV/n+TvudAIwQaqxDEvLlBUn
HBAhMKDQXGs7V45+nOgDX4rKWUqJh4SPbJgNbVte2PX7U+hsMpZBsYp3vkjApgTO
pq2CCkViyBXgTY+6vUigtvfJ9afTTWI6Qm4dLXZ7hxErBxgHQyowOoO/sF92cNOu
AosBMpE+qSy7sVqJU5g/JXJh0kddKFotXHSGA1kFMzJafJC/n5nLrusDzFJRQqyH
Io+6inYWjlb5o79z0tJzAvG1mgplLRppMBjoVJ/RJ+gT+QE70kokR6wvsgDqsKNd
mvB0TNj0zY0g6Is6V3XMyf0u+6BtLTbua913HPiqBfErgeV58vzsst+y0It42TXi
aw+UF2Kw/YhPq1rZFxgnAVcMja3qlXWpH57gmgIPovBCsPsiywWiHLsSHRzAI22b
zeTsUST/4toR/ruZVbUZvWoWAR4tzsSuwXJFx/hhinTQQTNHErXASOX986UaL9L7
o2/pTKLe
=IeBY
-----END PGP SIGNATURE-----
+14
View File
@@ -0,0 +1,14 @@
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf
Components: main
Description: socktop APT repository
Date: Sun, 23 Nov 2025 04:05:21 +0000
MD5Sum:
0bddefb2f13cb7c86cd05fe1ce20310f 1549 main/binary-amd64/Packages
674f0e552cbb7dc65380651a2a8d279e 799 main/binary-amd64/Packages.gz
SHA256:
babfbb4839e7fdfbc83742c16996791b0402a1315889b530330b338380398263 1549 main/binary-amd64/Packages
f8c48d0f7bf53eb02c6dbf5f1cdd046fe71b87273cf763c5bb2e95d9757a7a82 799 main/binary-amd64/Packages.gz
+14
View File
@@ -0,0 +1,14 @@
-----BEGIN PGP SIGNATURE-----
iQGzBAABCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkiiAEACgkQESwaeYRl
+/KzeAv+OUIbxud5FboerwpAJULV+rS3+VX4kvwg/daVZ3yX3tJNrsyNCHgmWLVu
fLeEFFc2Ax9GvFW4jrbxRAGD+3TXQEEFkb5lGzYyDjlgVzR6wLiVTTrmzWoK+cbB
4DMozqeLiZFfQjq4UFn3+mwiYFX9Dj7PVF0M60XAUJSObbJFmaEPZIfx6wcZfkiL
lLLk1eeU5MPiyudPOhVGgaD76KrUCw+8DBNKoCKIEcCY0LvuKtUK8mWYXRSPSved
4Znd3QZz063Z6R+Lj1XlGLoTPResna28T/Nca+2JgLhbrihsLMcHoFxmrvFP9FpT
MChKngj7NnGt0yqHH5J16hdwMra/vvhmF0yoQ0loIcy+q06tYEqOcau8tvAjfbId
k3rgQgnxxVE8WUmV9Bugp7jhNMO+ImKWMwzEr6wGd9ZHqpknUlAaWeO73VP+qtAN
6mEqWhkqvXGg+srH6qp3Sg0W28dYG29X3Kx8jOp7HeyvA/gLZRN7L+bq/XaA7WFA
1hba6LIY
=QoLf
-----END PGP SIGNATURE-----
@@ -0,0 +1,38 @@
Package: socktop
Version: 1.50.0-1
Architecture: amd64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 3459
Filename: pool/main/socktop_1.50.0-1_amd64.deb
Size: 1278940
MD5sum: 0215e178e306d9379669065e8c78582b
SHA1: 04e0416389f5cecd584fd1f6b3568711f2645eee
SHA256: 69eb04b1de48541c95950a97b16357fcd9c51ffaceb143f63de4a9d758fad297
Section: admin
Priority: optional
Homepage: https://github.com/jasonwitty/socktop
Description: Remote system monitor over WebSocket, TUI like top
socktop is a remote system monitor with a rich terminal user interface (TUI)
that connects to remote hosts running the socktop_agent over WebSocket. It
provides real-time monitoring of CPU, memory, processes, and more with an
interface similar to the traditional 'top' command.
Package: socktop-agent
Version: 1.50.2-1
Architecture: amd64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 6793
Filename: pool/main/socktop-agent_1.50.2-1_amd64.deb
Size: 1896272
MD5sum: 22e78d03e83dcf84d6ec4a009b285902
SHA1: 26a9f4fedfdba06a047044027223f2944cf72ba6
SHA256: 11922af475146f60347a9c52cff4bbce1ce524bdb4293b2c436f3c71876e17d5
Section: admin
Priority: optional
Homepage: https://github.com/jasonwitty/socktop
Description: Socktop agent daemon. Serves host metrics over WebSocket.
socktop_agent is the daemon component that runs on remote hosts to collect and
serve system metrics over WebSocket. It gathers CPU, memory, disk, network,
GPU, and process information that can be monitored remotely by the socktop TUI
client.
Binary file not shown.
@@ -0,0 +1,5 @@
Archive: stable
Component: main
Origin: socktop
Label: socktop
Architecture: amd64
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>socktop APT Repository</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
line-height: 1.6;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
}
pre {
background: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
h1 { color: #333; }
h2 { color: #555; margin-top: 30px; }
</style>
</head>
<body>
<h1>socktop APT Repository</h1>
<p>System monitor with remote agent support for Linux systems.</p>
<h2>Adding this repository</h2>
<pre><code># Add the GPG key
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
# Add the repository
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://jasonwitty.github.io/socktop stable main" | sudo tee /etc/apt/sources.list.d/socktop.list
# Update and install
sudo apt update
sudo apt install socktop socktop-agent</code></pre>
<h2>Manual Installation</h2>
<p>Download packages from <a href="pool/main/">pool/main/</a></p>
<h2>Supported Architectures</h2>
<ul>
<li>amd64 (x86_64)</li>
<li>arm64 (aarch64)</li>
<li>armhf (32-bit ARM)</li>
</ul>
<h2>Source Code</h2>
<p>Visit the <a href="https://github.com/jasonwitty/socktop">GitHub repository</a></p>
</body>
</html>
Binary file not shown.
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
# Debian Packaging for socktop
This document describes how to build and use Debian packages for socktop and socktop_agent.
## Prerequisites
Install `cargo-deb`:
```bash
cargo install cargo-deb
```
## Building Packages Locally
### Build for your current architecture (x86_64)
```bash
# Build socktop TUI client
cargo deb --package socktop
# Build socktop_agent daemon
cargo deb --package socktop_agent
```
The `.deb` files will be created in `target/debian/`.
### Cross-compile for ARM64 (Raspberry Pi, etc.)
First, install cross-compilation tools:
```bash
sudo apt-get update
sudo apt-get install gcc-aarch64-linux-gnu libc6-dev-arm64-cross
```
Add the ARM64 target:
```bash
rustup target add aarch64-unknown-linux-gnu
```
Configure the linker by creating `.cargo/config.toml`:
```toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
```
Build the packages:
```bash
# Build for ARM64
cargo deb --package socktop --target aarch64-unknown-linux-gnu
cargo deb --package socktop_agent --target aarch64-unknown-linux-gnu
```
## Installing Packages
### Install socktop TUI client
```bash
sudo dpkg -i socktop_*.deb
```
### Install socktop_agent daemon
```bash
sudo dpkg -i socktop_agent_*.deb
```
The agent package will:
- Create a `socktop` system user and group
- Install the binary to `/usr/bin/socktop_agent`
- Install a systemd service file (disabled by default)
- Create `/var/lib/socktop` for state files
### Enable and start the agent service
```bash
# Enable to start on boot
sudo systemctl enable socktop-agent
# Start the service
sudo systemctl start socktop-agent
# Check status
sudo systemctl status socktop-agent
```
### Configure the agent
Edit the systemd service to customize settings:
```bash
sudo systemctl edit socktop-agent
```
Add configuration in the override section:
```ini
[Service]
Environment=SOCKTOP_PORT=8080
Environment=SOCKTOP_TOKEN=your-secret-token
Environment=RUST_LOG=info
```
Then restart:
```bash
sudo systemctl restart socktop-agent
```
## GitHub Actions
The project includes a GitHub Actions workflow (`.github/workflows/build-deb.yml`) that automatically builds `.deb` packages for both x86_64 and ARM64 architectures on every push to master or when tags are created.
### Downloading pre-built packages
1. Go to the [Actions tab](https://github.com/jasonwitty/socktop/actions)
2. Click on the latest "Build Debian Packages" workflow run
3. Download the artifacts:
- `debian-packages-amd64` - x86_64 packages
- `debian-packages-arm64` - ARM64 packages
- `all-debian-packages` - All packages combined
- `checksums` - SHA256 checksums
### Release packages
When you create a git tag starting with `v` (e.g., `v1.50.0`), the workflow will automatically create a GitHub Release with all `.deb` packages attached.
```bash
git tag v1.50.0
git push origin v1.50.0
```
## Package Details
### socktop package
- **Binary**: `/usr/bin/socktop`
- **Documentation**: `/usr/share/doc/socktop/`
- **Size**: ~5-8 MB (depends on architecture)
### socktop_agent package
- **Binary**: `/usr/bin/socktop_agent`
- **Service**: `socktop-agent.service`
- **User/Group**: `socktop`
- **State directory**: `/var/lib/socktop`
- **Config directory**: `/etc/socktop` (created but empty by default)
- **Documentation**: `/usr/share/doc/socktop_agent/`
- **Size**: ~5-8 MB (depends on architecture)
## Uninstalling
```bash
# Remove packages but keep configuration
sudo apt remove socktop socktop_agent
# Remove packages and all configuration (purge)
sudo apt purge socktop socktop_agent
```
When purging `socktop_agent`, the following are removed:
- The `socktop` user and group
- `/var/lib/socktop` directory
- Empty `/etc/socktop` directory (if empty)
## Verifying Packages
Check package contents:
```bash
dpkg -c socktop_*.deb
dpkg -c socktop_agent_*.deb
```
Check package information:
```bash
dpkg -I socktop_*.deb
dpkg -I socktop_agent_*.deb
```
After installation, verify files:
```bash
dpkg -L socktop
dpkg -L socktop-agent
```
## Troubleshooting
### Service fails to start
Check logs:
```bash
sudo journalctl -u socktop-agent -f
```
Verify the socktop user exists:
```bash
id socktop
```
### Permission issues
Ensure the state directory has correct permissions:
```bash
sudo chown -R socktop:socktop /var/lib/socktop
sudo chmod 755 /var/lib/socktop
```
### Missing dependencies
If installation fails due to missing dependencies:
```bash
sudo apt --fix-broken install
```
## Creating a Local APT Repository (Advanced)
To create your own APT repository for easy installation:
1. Install required tools:
```bash
sudo apt install dpkg-dev
```
2. Create repository structure:
```bash
mkdir -p ~/socktop-repo/pool/main
cp *.deb ~/socktop-repo/pool/main/
```
3. Generate package index:
```bash
cd ~/socktop-repo
dpkg-scanpackages pool/main /dev/null | gzip -9c > pool/main/Packages.gz
```
4. Serve via HTTP (for testing):
```bash
cd ~/socktop-repo
python3 -m http.server 8000
```
5. Add to sources on client machines:
```bash
echo "deb [trusted=yes] http://your-server:8000 pool/main/" | \
sudo tee /etc/apt/sources.list.d/socktop.list
sudo apt update
sudo apt install socktop socktop-agent
```
## Contributing
When adding new features that affect packaging:
1. Update `Cargo.toml` metadata in the `[package.metadata.deb]` section
2. Add new assets to the `assets` array if needed
3. Update maintainer scripts in `socktop_agent/debian/` if needed
4. Test package building locally before committing
5. Update this documentation
## References
- [cargo-deb documentation](https://github.com/kornelski/cargo-deb)
- [Debian Policy Manual](https://www.debian.org/doc/debian-policy/)
- [systemd service files](https://www.freedesktop.org/software/systemd/man/systemd.service.html)
+13 -10
View File
@@ -2,6 +2,8 @@
This guide explains how to cross-compile the socktop_agent on various host systems and deploy it to a Raspberry Pi. Cross-compiling is particularly useful for older or resource-constrained Pi models where native compilation might be slow.
**Note:** GPU monitoring support is not available on ARMv7 (32-bit) and RISC-V architectures due to library limitations. When building for these platforms, the `--no-default-features` flag must be used to disable GPU support.
## Cross-Compilation Host Setup
Choose your host operating system:
@@ -23,8 +25,9 @@ sudo apt update
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
# For 32-bit Raspberry Pi (armv7)
# Note: GPU support not available on armv7
sudo apt update
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross libdrm-dev:armhf
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
```
### Setup Rust Cross-Compilation Targets
@@ -65,9 +68,8 @@ sudo pacman -S aarch64-linux-gnu-gcc
yay -S aarch64-linux-gnu-libdrm
# For 32-bit Raspberry Pi (armv7)
# Note: GPU support not available on armv7
sudo pacman -S arm-linux-gnueabihf-gcc
# Install libdrm for armv7 using an AUR helper
yay -S arm-linux-gnueabihf-libdrm
```
### Setup Rust Cross-Compilation Targets
@@ -114,8 +116,8 @@ cd path/to/socktop
# For 64-bit Raspberry Pi
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:aarch64-musl cargo build --release --target aarch64-unknown-linux-musl -p socktop_agent
# For 32-bit Raspberry Pi
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent
# For 32-bit Raspberry Pi (without GPU support)
docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:armv7-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p socktop_agent --no-default-features
```
The compiled binaries will be available in your local target directory.
@@ -133,11 +135,11 @@ The recommended approach for Windows is to use Windows Subsystem for Linux (WSL2
After setting up your environment, build the socktop_agent for your target Raspberry Pi:
```bash
# For 64-bit Raspberry Pi
# For 64-bit Raspberry Pi (with GPU support)
cargo build --release --target aarch64-unknown-linux-gnu -p socktop_agent
# For 32-bit Raspberry Pi
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent
# For 32-bit Raspberry Pi (without GPU support)
cargo build --release --target armv7-unknown-linux-gnueabihf -p socktop_agent --no-default-features
```
## Transfer the Binary to Your Raspberry Pi
@@ -161,11 +163,12 @@ SSH into your Raspberry Pi and install the required dependencies:
```bash
ssh pi@raspberry-pi-ip
# For Raspberry Pi OS (Debian-based)
# For Raspberry Pi OS (Debian-based) - 64-bit only
# (32-bit armv7 builds don't require these)
sudo apt update
sudo apt install libdrm-dev libdrm-amdgpu1
# For Arch Linux ARM
# For Arch Linux ARM - 64-bit only
sudo pacman -Syu
sudo pacman -S libdrm
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 MiB

+246
View File
@@ -0,0 +1,246 @@
#!/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 $?
+25 -3
View File
@@ -1,15 +1,17 @@
[package]
name = "socktop"
version = "1.50.0"
version = "1.60.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top"
edition = "2024"
license = "MIT"
readme = "README.md"
homepage = "https://github.com/jasonwitty/socktop"
repository = "https://github.com/jasonwitty/socktop"
[dependencies]
# socktop connector for agent communication
socktop_connector = "1.50.0"
socktop_connector = { version = "1.60.1", path = "../socktop_connector" }
tokio = { workspace = true }
futures-util = { workspace = true }
@@ -18,10 +20,30 @@ serde_json = { workspace = true }
url = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
unicode-width = { workspace = true }
anyhow = { workspace = true }
dirs-next = { 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 }
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3"
[package.metadata.deb]
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
license-file = ["../LICENSE", "4"]
extended-description = """\
socktop is a remote system monitor with a rich terminal user interface (TUI) \
that connects to remote hosts running the socktop_agent over WebSocket. \
It provides real-time monitoring of CPU, memory, processes, and more with \
an interface similar to the traditional 'top' command."""
depends = "$auto"
section = "admin"
priority = "optional"
assets = [
["target/release/socktop", "usr/bin/", "755"],
["../README.md", "usr/share/doc/socktop/", "644"],
]
+1517 -578
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
//! Detection of whether the connected agent is running on this same machine.
//!
//! Process-kill is only offered for *local* agents. The reasoning is a
//! security one: the PIDs shown in the UI are reported by the agent, and when
//! the user asks to kill one, socktop sends the signal with its OWN local OS
//! privileges (a direct syscall — never over the network; see [`crate::proc_kill`]).
//! A PID is therefore only meaningful — and only safe to act on — when the
//! agent lives on this machine. If we acted on a remote agent's PIDs we would
//! be signalling whatever unrelated *local* process happened to share that
//! number.
//!
//! An address is considered local when it is loopback, or when we can bind an
//! ephemeral socket to it: a bind only succeeds for an address assigned to one
//! of this host's own network interfaces, so it also covers the case of an
//! agent reached over this machine's LAN IP. Detection fails closed — any
//! parse/resolution failure, or any resolved address that is not local,
//! disables the feature.
use std::net::{IpAddr, ToSocketAddrs, UdpSocket};
/// Returns true only if the agent reached at `ws_url` is on this machine.
pub fn agent_is_local(ws_url: &str) -> bool {
let Ok(parsed) = url::Url::parse(ws_url) else {
return false;
};
match parsed.host() {
// IP literals can be checked directly without any name resolution.
Some(url::Host::Ipv4(ip)) => ip_is_local(IpAddr::V4(ip)),
Some(url::Host::Ipv6(ip)) => ip_is_local(IpAddr::V6(ip)),
// A hostname (e.g. "localhost", or a LAN name) must resolve, and every
// address it resolves to must be local. ws=80, wss=443 are the known
// default ports; an explicit port in the URL is honored.
Some(url::Host::Domain(domain)) => {
let port = parsed.port_or_known_default().unwrap_or(0);
match (domain, port).to_socket_addrs() {
Ok(addrs) => {
let mut saw_any = false;
for addr in addrs {
saw_any = true;
if !ip_is_local(addr.ip()) {
return false;
}
}
saw_any
}
Err(_) => false,
}
}
None => false,
}
}
/// An address is local if it is loopback, or if we can bind an ephemeral
/// socket to it (only possible for an address on one of our own interfaces).
/// Port 0 requests an ephemeral port and sends no traffic.
fn ip_is_local(ip: IpAddr) -> bool {
ip.is_loopback() || UdpSocket::bind((ip, 0)).is_ok()
}
#[cfg(test)]
mod tests {
use super::agent_is_local;
#[test]
fn loopback_hosts_are_local() {
assert!(agent_is_local("ws://127.0.0.1:3000/ws"));
assert!(agent_is_local("ws://localhost:3000/ws"));
assert!(agent_is_local("ws://[::1]:3000/ws"));
assert!(agent_is_local("wss://127.0.0.1/ws"));
}
#[test]
fn public_addresses_are_not_local() {
// 8.8.8.8 is not assigned to any local interface.
assert!(!agent_is_local("ws://8.8.8.8:3000/ws"));
// Documentation-range address, guaranteed not bound locally.
assert!(!agent_is_local("ws://203.0.113.1:3000/ws"));
}
#[test]
fn garbage_fails_closed() {
assert!(!agent_is_local("not a url"));
assert!(!agent_is_local(""));
}
}
+133 -12
View File
@@ -2,6 +2,8 @@
mod app;
mod history;
mod local;
mod proc_kill;
mod profiles;
mod retry;
mod types;
@@ -22,6 +24,19 @@ pub(crate) struct ParsedArgs {
metrics_interval_ms: Option<u64>,
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> {
@@ -36,11 +51,13 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false;
let mut compact = false;
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] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
));
}
"--tls-ca" | "-t" => {
@@ -61,6 +78,17 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
"--demo" => {
demo = true;
}
"--compact" => {
// Force the small-window layout at any terminal size. Without it the
// layout switches on its own once the window gets too short.
compact = true;
}
"--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;
@@ -96,11 +124,20 @@ 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] [ws://HOST:PORT/ws]"
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--no-kill] [ws://HOST:PORT/ws]"
));
}
}
@@ -116,6 +153,8 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
metrics_interval_ms,
processes_interval_ms,
verify_hostname,
compact,
no_kill,
})
}
@@ -125,6 +164,11 @@ 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(());
}
};
@@ -136,7 +180,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()).await;
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill).await;
}
let profiles_file = load_profiles();
@@ -241,7 +285,12 @@ 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()).await;
return run_demo_mode(
parsed.tls_ca.as_deref(),
parsed.compact,
parsed.no_kill,
)
.await;
}
if let Some(entry) = profiles_mut.profiles.get(name) {
(
@@ -301,7 +350,8 @@ 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()).await;
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact, parsed.no_kill)
.await;
} else {
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(());
@@ -313,9 +363,17 @@ 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_status(is_tls, has_token)
.with_compact(parsed.compact)
.with_kill_enabled(kill_enabled);
if parsed.dry_run {
return Ok(());
}
@@ -379,11 +437,30 @@ fn gather_intervals(
}
// Demo mode implementation
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn run_demo_mode(
_tls_ca: Option<&str>,
compact: bool,
no_kill: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws");
let child = spawn_demo_agent(port)?;
let mut app = App::new();
let child = match spawn_demo_agent(port) {
Ok(child) => child,
// The agent ships as its own binary, so a missing one is a setup problem,
// not a crash: tell the user how to fix it instead of dumping an io error.
Err(e @ DemoAgentError::NotFound(_)) => {
eprintln!("{e}");
return Ok(());
}
Err(e) => return Err(e.into()),
};
// Demo mode runs the real agent on loopback, so its PIDs are real local
// processes — enable the local process-kill feature, gated the same way as
// the normal connect path (loopback resolves local, --no-kill and
// SOCKTOP_NO_KILL still override).
let mut app = App::new()
.with_compact(compact)
.with_kill_enabled(local::agent_is_local(&url) && !no_kill && !no_kill_from_env());
// Demo mode connects to localhost, so disable hostname verification
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
}
@@ -399,9 +476,50 @@ impl Drop for DemoGuard {
eprintln!("Stopped demo agent on port {}", self.port);
}
}
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
#[derive(Debug)]
enum DemoAgentError {
/// The socktop_agent executable could not be located.
NotFound(std::path::PathBuf),
Io(std::io::Error),
}
impl std::fmt::Display for DemoAgentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(candidate) => write!(
f,
"Could not start demo mode: '{}' was not found{}.\n\
\n\
Demo mode runs a local agent, which is shipped as a separate binary\n\
and is not installed alongside the socktop TUI. Install it with:\n\
\n cargo install socktop_agent\n\n\
then run socktop again. See {} for other install options.",
candidate.display(),
// A bare file name means find_agent_executable() fell back to a PATH lookup.
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
" on your PATH"
} else {
""
},
env!("CARGO_PKG_HOMEPAGE"),
),
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
}
}
}
impl std::error::Error for DemoAgentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NotFound(_) => None,
Self::Io(e) => Some(e),
}
}
}
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
let candidate = find_agent_executable();
let mut cmd = std::process::Command::new(candidate);
let mut cmd = std::process::Command::new(&candidate);
cmd.arg("--port").arg(port.to_string());
cmd.env("SOCKTOP_ENABLE_SSL", "0");
@@ -409,7 +527,10 @@ fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>>
//cmd.env("SOCKTOP_AGENT_GPU", "0");
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
let child = cmd.spawn()?;
let child = cmd.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
_ => DemoAgentError::Io(e),
})?;
std::thread::sleep(std::time::Duration::from_millis(300));
Ok(DemoGuard {
port,
+181
View File
@@ -0,0 +1,181 @@
//! Local process termination.
//!
//! Signals are sent by socktop itself, using this process's own OS privileges,
//! via a direct `sysinfo` call. Nothing is transmitted to the agent — the
//! agent and connector have no kill capability at all. This code path is only
//! reachable once the agent has been verified to be local (see
//! [`crate::local`]), which guarantees the PID refers to a process on this
//! machine.
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, Signal, System};
/// The signals socktop can send. Deliberately limited to the two btop-style
/// primaries; no arbitrary-signal chooser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KillSignal {
/// SIGTERM — polite request to terminate.
Term,
/// SIGKILL — forceful, cannot be caught.
Kill,
}
impl KillSignal {
fn as_sysinfo(self) -> Signal {
match self {
KillSignal::Term => Signal::Term,
KillSignal::Kill => Signal::Kill,
}
}
/// Human-facing label for confirmation/result messages.
pub fn label(self) -> &'static str {
match self {
KillSignal::Term => "SIGTERM",
KillSignal::Kill => "SIGKILL",
}
}
}
/// Is `pid` still a live local process?
///
/// A zombie counts as gone: after a kill the entry can linger until the parent
/// reaps it, and showing a row for a process that no longer runs is exactly the
/// staleness this check exists to avoid.
pub fn process_exists(pid: u32) -> bool {
let spid = sysinfo::Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[spid]),
false,
ProcessRefreshKind::nothing(),
);
match sys.process(spid) {
Some(p) => p.status() != sysinfo::ProcessStatus::Zombie,
None => false,
}
}
/// Send `signal` to local process `pid`. Returns `Ok(())` on success, or an
/// `Err` with a human-readable reason (process gone, PID reused, permission
/// denied, signal unsupported on this platform).
///
/// `expected_name`, when given, is compared against the process that owns the
/// PID **right now**: the PID came from an agent snapshot and the confirmation
/// dialog can sit open indefinitely, so by signal time the kernel may have
/// recycled the number for an unrelated process. Both names come from the
/// same sysinfo source, so a live, unchanged target compares equal.
pub fn kill_local_process(
pid: u32,
expected_name: Option<&str>,
signal: KillSignal,
) -> Result<(), String> {
let spid = sysinfo::Pid::from_u32(pid);
// Refresh just this one PID — we don't need a full process scan to signal it.
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[spid]),
false,
ProcessRefreshKind::nothing(),
);
let Some(proc_) = sys.process(spid) else {
return Err(format!("Process {pid} no longer exists"));
};
if let Some(expected) = expected_name {
let current = proc_.name().to_string_lossy();
if current != expected {
return Err(format!(
"PID {pid} now belongs to \"{current}\", not \"{expected}\"\
not signalling. Reselect the process and try again."
));
}
}
match proc_.kill_with(signal.as_sysinfo()) {
Some(true) => Ok(()),
Some(false) => Err(format!(
"Could not send {} to PID {pid} (permission denied?)",
signal.label()
)),
None => Err(format!(
"{} is not supported on this platform",
signal.label()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::time::{Duration, Instant};
/// The path that matters: a real, live, local process must actually receive
/// the signal. Exercises the `refresh_processes_specifics` lookup as well —
/// if that call does not populate the process map, `sys.process()` returns
/// None and a live PID is reported as "no longer exists".
#[test]
fn signals_a_real_child_process() {
let mut child = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep for the test");
let pid = child.id();
let result = kill_local_process(pid, Some("sleep"), KillSignal::Term);
// Reap on every path before asserting, so a failing assert cannot leak a
// 30s sleep and cannot trip clippy's zombie_processes lint.
let deadline = Instant::now() + Duration::from_secs(5);
let mut exited = false;
while Instant::now() < deadline {
if matches!(child.try_wait(), Ok(Some(_))) {
exited = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if !exited {
let _ = child.kill();
}
let _ = child.wait();
assert!(result.is_ok(), "kill_local_process returned {result:?}");
assert!(
exited,
"SIGTERM was reported sent but the child never exited"
);
}
/// The reuse guard: a live PID whose owner does not match the name the
/// user confirmed must NOT be signalled. This also proves the name is
/// populated under ProcessRefreshKind::nothing() — if it weren't, the
/// matching-name test above would fail instead.
#[test]
fn refuses_a_pid_owned_by_a_different_process() {
let mut child = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
let result = kill_local_process(pid, Some("firefox"), KillSignal::Term);
let _ = child.kill();
let _ = child.wait();
let err = result.expect_err("signalled a process under the wrong name");
assert!(err.contains("firefox") && err.contains("sleep"), "{err}");
}
#[test]
fn reports_a_pid_that_is_gone() {
let mut child = Command::new("true").spawn().expect("spawn true");
let pid = child.id();
child.wait().expect("reap");
// The PID is now free; signalling it must fail cleanly, not panic.
assert!(kill_local_process(pid, None, KillSignal::Term).is_err());
}
}
File diff suppressed because it is too large Load Diff
+179 -26
View File
@@ -14,6 +14,10 @@ use ratatui::{
use crate::history::PerCoreHistory;
use crate::types::Metrics;
use crate::ui::fit::{cols, pick_pair};
/// Columns kept clear between the CPU title and the temperature readout.
const TITLE_GAP: u16 = 2;
/// State for dragging the scrollbar thumb
#[derive(Clone, Copy, Debug, Default)]
@@ -248,29 +252,12 @@ pub fn draw_cpu_avg_graph(
hist_sum as f64 / hist.len() as f64
};
let title = if let Some(mm) = m {
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
} else {
"CPU avg".into()
};
// Build the top-right info (CPU temp and polling intervals)
let top_right_info = if let Some(mm) = m {
mm.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into())
} else {
String::new()
};
let (title, top_right_info) = cpu_title_for_width(
m.map(|mm| mm.cpu_total),
avg_cpu,
m.and_then(|mm| mm.cpu_temp_c),
area.width,
);
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
@@ -286,12 +273,14 @@ pub fn draw_cpu_avg_graph(
.style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area);
// Render the top-right info as text overlay in the top-right corner
// Temperature overlays the top border, right-aligned inside the corner. The title
// above is sized so the two cannot collide.
if !top_right_info.is_empty() {
let w = cols(&top_right_info);
let info_area = Rect {
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
x: area.x + area.width.saturating_sub(w + 1),
y: area.y,
width: top_right_info.len() as u16 + 1,
width: w,
height: 1,
};
let info_line = Line::from(Span::raw(top_right_info));
@@ -299,6 +288,67 @@ pub fn draw_cpu_avg_graph(
}
}
/// Health glyph for a CPU temperature.
fn temp_icon(t: f32) -> &'static str {
if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
}
}
/// Chooses the CPU pane's title and its right-aligned temperature readout for a pane
/// `width` columns wide.
///
/// Both are painted onto the pane's top border, so without a shared budget the
/// temperature simply overwrites the tail of the title on a narrow pane. Detail is given
/// up in this order: the `CPU Temp:` label, then the `now:`/`avg:` labels, then the
/// average reading, then the decimal on the temperature, and only last the temperature
/// itself — the readings are what the pane is for, but a thermal warning is worth more
/// than a second decimal place.
fn cpu_title_for_width(
cpu_now: Option<f32>,
avg_cpu: f64,
temp_c: Option<f32>,
width: u16,
) -> (String, String) {
let Some(now) = cpu_now else {
return ("CPU avg".into(), String::new());
};
// Two borders, plus a column of breathing room at each end of the title.
let budget = width.saturating_sub(4);
let labelled = format!("CPU (now: {now:>5.1}% | avg: {avg_cpu:>5.1}%)");
let bare = format!("CPU ({now:.1}% | {avg_cpu:.1}%)");
let now_only = format!("CPU ({now:.1}%)");
let (temp_labelled, temp_plain, temp_coarse) = match temp_c {
Some(t) => {
let icon = temp_icon(t);
(
format!("CPU Temp: {t:.1}°C {icon}"),
format!("{t:.1}°C {icon}"),
format!("{t:.0}°C {icon}"),
)
}
None => ("CPU Temp: N/A".into(), "N/A".into(), "N/A".into()),
};
let ladder = [
(labelled.as_str(), temp_labelled.as_str()),
(labelled.as_str(), temp_plain.as_str()),
(bare.as_str(), temp_plain.as_str()),
(bare.as_str(), temp_coarse.as_str()),
(now_only.as_str(), temp_coarse.as_str()),
(now_only.as_str(), ""),
];
let (title, temp) = pick_pair(budget, TITLE_GAP, &ladder);
(title.to_string(), temp.to_string())
}
/// Draws the per-core CPU bars with sparklines and trends.
pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>,
@@ -428,6 +478,108 @@ pub fn draw_per_core_bars(
}
}
#[cfg(test)]
mod title_tests {
use super::*;
/// The defect this replaces: the temperature was painted over the title's tail on a
/// narrow pane. Whatever the width, the two must fit side by side on the border.
#[test]
fn title_and_temperature_never_overlap() {
for width in 0..=200u16 {
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
let budget = width.saturating_sub(4);
if temp.is_empty() {
continue;
}
assert!(
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
);
}
}
/// The current CPU reading is the one thing the pane must always show.
#[test]
fn the_current_reading_always_survives() {
for width in 20..=200u16 {
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
assert!(
title.contains("3.4"),
"width {width}: lost the reading ({title:?})"
);
}
}
/// The ladder from the design: temp label, then now/avg labels, then the average,
/// then the temperature's decimal, then the temperature.
#[test]
fn detail_is_dropped_in_priority_order() {
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
let (title, temp) = at(80);
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
// The "CPU Temp:" label goes first; the readings keep their labels.
let (title, temp) = at(50);
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
assert_eq!(temp, "43.0°C 😎");
// Then the now:/avg: labels.
let (title, temp) = at(40);
assert_eq!(title, "CPU (0.7% | 1.3%)");
assert_eq!(temp, "43.0°C 😎");
// Then the temperature's decimal.
let (title, temp) = at(31);
assert_eq!(title, "CPU (0.7% | 1.3%)");
assert_eq!(temp, "43°C 😎");
// Then the average reading.
let (title, temp) = at(26);
assert_eq!(title, "CPU (0.7%)");
assert_eq!(temp, "43°C 😎");
// Last of all, the temperature itself.
let (title, temp) = at(15);
assert_eq!(title, "CPU (0.7%)");
assert_eq!(temp, "");
}
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
/// reading at every tier that shows a temperature at all.
#[test]
fn the_thermal_glyph_tracks_the_temperature() {
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
for width in 26..=80u16 {
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
assert!(
temp.contains(icon),
"width {width} at {t}°C: expected {icon} in {temp:?}"
);
}
}
}
/// An agent that reports no temperature must not leave a stray label behind.
#[test]
fn a_missing_temperature_degrades_to_nothing() {
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
assert_eq!(temp, "CPU Temp: N/A");
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
assert_eq!(temp, "");
}
/// Before the first payload arrives there are no readings to show.
#[test]
fn no_metrics_yet_shows_the_placeholder() {
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
assert_eq!(title, "CPU avg");
assert!(temp.is_empty());
}
}
#[cfg(test)]
mod render_tests {
use super::*;
@@ -437,6 +589,7 @@ 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,
+3 -2
View File
@@ -1,7 +1,8 @@
//! Disk cards with per-device gauge and title line.
use crate::types::Metrics;
use crate::ui::util::{disk_icon, human, truncate_middle};
use crate::ui::fit::truncate_middle_cols;
use crate::ui::util::{disk_icon, human};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
@@ -69,7 +70,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
"{}{}{}{} {} / {} ({}%)",
indent,
disk_icon(&d.name),
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2),
temp_str,
human(used),
human(d.total),
+190
View File
@@ -0,0 +1,190 @@
//! Fitting text to the columns actually available.
//!
//! Several panes paint two independent pieces of text onto one row — a left title and a
//! right-aligned readout. Nothing reserves space for the right piece, so on a narrow
//! terminal the right one is simply painted over the tail of the left one and the title
//! is clobbered mid-word. The helpers here let a caller measure in real terminal columns
//! and pick the richest wording that still fits, so the two never overlap.
//!
//! Note that `str::len()` is a byte count and must not be used for this: `⏱` is three
//! bytes wide but one column, and `🔒` is four bytes but two columns.
use unicode_width::UnicodeWidthStr;
/// Terminal columns `s` occupies, saturating at `u16::MAX`.
pub fn cols(s: &str) -> u16 {
UnicodeWidthStr::width(s).min(u16::MAX as usize) as u16
}
/// Shortens `s` to at most `max` columns, marking the cut with `…`.
///
/// Cuts on character boundaries and accounts for wide characters, so the result never
/// exceeds `max` columns and never splits a multi-byte character.
pub fn truncate_cols(s: &str, max: u16) -> String {
if cols(s) <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
// Reserve one column for the ellipsis.
let budget = max.saturating_sub(1);
let mut used = 0u16;
let mut out = String::new();
for ch in s.chars() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > budget {
break;
}
used += w;
out.push(ch);
}
out.push('…');
out
}
/// Shortens `s` to at most `max` columns by cutting the MIDDLE, marking the
/// cut with `…` — device names like `/dev/nvme0n1p1` keep their distinctive
/// prefix and suffix. Column- and char-boundary-safe; the byte-slicing
/// predecessor in `util.rs` panicked on non-ASCII names.
pub fn truncate_middle_cols(s: &str, max: u16) -> String {
if cols(s) <= max {
return s.to_string();
}
if max <= 1 {
return truncate_cols(s, max);
}
// Reserve one column for the ellipsis; split the rest left/right.
let left_budget = (max - 1) / 2;
let right_budget = max - 1 - left_budget;
let mut left_end = 0; // byte index
let mut used = 0u16;
for (i, ch) in s.char_indices() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > left_budget {
break;
}
used += w;
left_end = i + ch.len_utf8();
}
let mut right_start = s.len();
let mut used = 0u16;
for (i, ch) in s.char_indices().rev() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > right_budget || i < left_end {
break;
}
used += w;
right_start = i;
}
format!("{}{}", &s[..left_end], &s[right_start..])
}
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
/// with at least `gap` columns between them.
///
/// Candidates are ordered most- to least-detailed; the last one is the floor and is
/// returned even if it does not fit, so callers always get something to render.
pub fn pick_pair<'a>(
width: u16,
gap: u16,
candidates: &[(&'a str, &'a str)],
) -> (&'a str, &'a str) {
let fits = |left: &str, right: &str| {
let needed = cols(left)
.saturating_add(cols(right))
.saturating_add(if right.is_empty() { 0 } else { gap });
needed <= width
};
for &(left, right) in candidates {
if fits(left, right) {
return (left, right);
}
}
candidates.last().copied().unwrap_or(("", ""))
}
#[cfg(test)]
mod tests {
use super::*;
/// The bug these helpers exist to prevent: byte length overstates the width of the
/// glyphs socktop puts in its header, which is what pushed the right-hand text into
/// the title in the first place.
#[test]
fn cols_counts_columns_not_bytes() {
assert_eq!(cols("abc"), 3);
// Stopwatch: 3 bytes, 1 column.
assert_eq!("".len(), 3);
assert_eq!(cols(""), 1);
// Lock: 4 bytes, 2 columns.
assert_eq!("🔒".len(), 4);
assert_eq!(cols("🔒"), 2);
assert_eq!(cols("⏱ 500ms metrics | 2000ms procs"), 30);
}
#[test]
fn truncate_respects_the_column_budget() {
assert_eq!(truncate_cols("cachyos-gaming", 20), "cachyos-gaming");
assert_eq!(truncate_cols("cachyos-gaming", 14), "cachyos-gaming");
assert_eq!(truncate_cols("cachyos-gaming", 10), "cachyos-g…");
assert_eq!(cols(&truncate_cols("cachyos-gaming", 10)), 10);
assert_eq!(truncate_cols("cachyos-gaming", 1), "");
assert_eq!(truncate_cols("cachyos-gaming", 0), "");
}
/// Truncation must never land mid-character or overrun the budget on wide glyphs.
#[test]
fn truncate_handles_wide_and_multibyte_characters() {
for max in 0..12u16 {
let out = truncate_cols("🔒🔒🔒 TLS", max);
assert!(cols(&out) <= max, "{out:?} exceeds {max} columns");
assert!(out.chars().all(|c| c != '\u{fffd}'), "{out:?} split a char");
}
// A wide glyph that cannot fit beside the ellipsis is dropped whole.
assert_eq!(truncate_cols("🔒ab", 2), "");
}
/// Middle truncation keeps both ends — the parts that identify a device —
/// and must never exceed the budget or split a character.
#[test]
fn truncate_middle_keeps_both_ends_within_budget() {
assert_eq!(truncate_middle_cols("/dev/nvme0n1p1", 20), "/dev/nvme0n1p1");
let out = truncate_middle_cols("/dev/nvme0n1p1", 9);
assert_eq!(cols(&out), 9);
assert!(out.starts_with("/dev"), "{out}");
assert!(out.ends_with("1p1"), "{out}");
assert!(out.contains('…'), "{out}");
// Non-ASCII names must not panic (the old byte-slicing version did).
for max in 0..12u16 {
let out = truncate_middle_cols("диск-🗄️-данные", max);
assert!(cols(&out) <= max.max(1), "{out:?} exceeds {max}");
}
}
#[test]
fn pick_pair_takes_the_richest_that_fits() {
let candidates = [
("full left text", "full right text"),
("left text", "right text"),
("left", "right"),
];
assert_eq!(pick_pair(80, 2, &candidates), candidates[0]);
assert_eq!(pick_pair(24, 2, &candidates), candidates[1]);
assert_eq!(pick_pair(12, 2, &candidates), candidates[2]);
// Below the floor the last candidate is still returned.
assert_eq!(pick_pair(1, 2, &candidates), candidates[2]);
}
/// The gap is what keeps the two pieces from touching; it must not be charged when
/// there is no right-hand piece to separate.
#[test]
fn pick_pair_only_charges_the_gap_when_both_sides_are_present() {
let candidates = [("0123456789", "x"), ("0123456789", "")];
assert_eq!(pick_pair(11, 2, &candidates), candidates[1]);
assert_eq!(pick_pair(13, 2, &candidates), candidates[0]);
}
}
+207
View File
@@ -121,3 +121,210 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
);
}
}
/// One-line GPU strip for compact mode: no device name (it is the first thing to lose
/// value when rows are scarce), just utilisation and VRAM on the single content row
/// between the block borders. Only the first GPU fits; the title says so when there are
/// more.
pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let gpus = m.and_then(|mm| mm.gpus.as_ref());
let count = gpus.map(|g| g.len()).unwrap_or(0);
let title = if count > 1 {
format!("GPU (1/{count})")
} else {
"GPU".to_string()
};
f.render_widget(Block::default().borders(Borders::ALL).title(title), area);
if area.height < 3 || area.width <= 2 {
return;
}
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width - 2,
height: 1,
};
let Some(g) = gpus.and_then(|v| v.first()) else {
f.render_widget(Paragraph::new("No GPUs"), inner);
return;
};
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = if total > 0 {
(used as f64 / total as f64).clamp(0.0, 1.0)
} else {
0.0
};
let util_label = format!("util: {util}%");
let mem_label = format!(
"vram: {}/{} ({}%)",
fmt_bytes(used),
fmt_bytes(total),
(mem_ratio * 100.0).round() as u16
);
// Bars are sized explicitly rather than left to stretch: an idle bar renders as
// empty cells, so a full-width one turns into a long blank run between two labels.
const MIN_GAUGE_W: u16 = 6;
const MAX_GAUGE_W: u16 = 24;
let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side
let gauge_w = inner
.width
.saturating_sub(labels_w)
.min(2 * MAX_GAUGE_W)
.div_euclid(2);
// Too narrow for bars worth drawing: keep the numbers, drop the bars.
if gauge_w < MIN_GAUGE_W {
f.render_widget(
Paragraph::new(Span::raw(format!("{util_label} {mem_label}")))
.style(Style::default().fg(Color::Gray)),
inner,
);
return;
}
// Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous
// on a single line: with an idle bar rendering empty, the next pair's fill ends up
// flush against the previous pair's text and reads as belonging to it.
let mut x = inner.x;
let mut place = |w: u16| {
let r = Rect {
x,
y: inner.y,
width: w,
height: 1,
};
x += w;
r
};
let util_rect = place(util_label.len() as u16 + 2);
let util_bar = place(gauge_w);
let mem_rect = place(mem_label.len() as u16 + 2);
let mem_bar = place(gauge_w);
let label = |text: &str| {
Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray))
};
f.render_widget(label(&util_label), util_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
.ratio(util as f64 / 100.0),
util_bar,
);
f.render_widget(label(&mem_label), mem_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::LightMagenta))
.label(Span::raw(""))
.ratio(mem_ratio),
mem_bar,
);
}
#[cfg(test)]
mod render_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use socktop_connector::{GpuInfo, Metrics};
fn gpu(name: &str) -> GpuInfo {
GpuInfo {
name: Some(name.into()),
vendor: None,
utilization: Some(42.0),
mem_used: Some(4_724_464_025),
mem_total: Some(17_070_817_280),
temp: None,
}
}
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus,
process_count: Some(0),
}
}
fn render(width: u16, m: &Metrics) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
terminal
.draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m)))
.unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Compact mode drops the device name — the row is one line and the numbers are
/// what the space is for.
#[test]
fn compact_strip_omits_the_device_name() {
let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")]));
let out = render(80, &m);
assert!(
!out.contains("NVIDIA"),
"name leaked into compact strip:\n{out}"
);
assert!(out.contains("util: 42%"), "{out}");
assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}");
}
/// A second GPU cannot fit on one line, so the title has to say the strip is partial
/// rather than silently showing only the first card.
#[test]
fn multiple_gpus_are_flagged_in_the_title() {
let one = render(80, &metrics(Some(vec![gpu("a")])));
assert!(one.contains("GPU") && !one.contains("1/"), "{one}");
let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")])));
assert!(two.contains("GPU (1/2)"), "{two}");
}
/// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must
/// never drop the numbers.
#[test]
fn narrow_strip_keeps_the_numbers() {
let m = metrics(Some(vec![gpu("a")]));
for width in [20u16, 30, 40, 47, 48, 80, 200] {
let out = render(width, &m);
if width >= 40 {
assert!(out.contains("util: 42%"), "width {width}:\n{out}");
}
// No panic, and the block always closes on the last row.
assert_eq!(out.lines().count(), 3, "width {width}");
}
}
#[test]
fn missing_gpu_payload_does_not_panic() {
assert!(render(80, &metrics(None)).contains("No GPUs"));
assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs"));
}
}
+211 -23
View File
@@ -1,44 +1,232 @@
//! Top header with hostname and CPU temperature indicator.
//! Top header with hostname, connection status and polling intervals.
//!
//! The row carries two pieces of text — session identity on the left, polling intervals
//! on the right — and both matter. Rather than let the right one overwrite the left when
//! they no longer both fit, the header drops detail in priority order: the hostname and
//! the intervals are what survive longest, because they are what tells you *which* host
//! you are looking at and how fresh the numbers are.
use crate::ui::fit::{cols, pick_pair, truncate_cols};
use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Build the header's left-side title from session state. Callers cache the
/// returned String and only rebuild it when one of the inputs changes.
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
let base = match hostname {
Some(h) => format!("socktop — host: {h}"),
None => "socktop — connecting...".into(),
};
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
let mut parts = vec![base, tls_txt.into()];
if has_token {
parts.push("🔑 token".into());
}
parts.push("(a: about, h: help, q: quit)".into());
parts.join(" | ")
/// Columns kept clear between the left and right halves.
const GAP: u16 = 2;
/// Never shorten the hostname below this before dropping the intervals instead.
const HOSTNAME_FLOOR: u16 = 8;
/// Session state the header renders.
#[derive(Clone, Copy)]
pub struct HeaderState<'a> {
pub hostname: Option<&'a str>,
pub is_tls: bool,
pub has_token: bool,
pub metrics_ms: u128,
pub procs_ms: u128,
}
/// Build the right-side polling interval text. Callers cache this string.
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
format!("{metrics_ms}ms metrics | {procs_ms}ms procs")
/// Builds the left and right halves of the header for a row `width` columns wide.
///
/// Detail is dropped in this order as the row narrows: the key hints, then the TLS/token
/// badges, then the `socktop — host:` prefix (leaving the bare hostname), then the
/// `metrics`/`procs` words, and only then is the hostname itself shortened. The two
/// halves are always sized to sit side by side, so neither can paint over the other.
///
/// Callers cache the result and rebuild it only when the state or the width changes.
pub fn build_header(state: HeaderState<'_>, width: u16) -> (String, String) {
let host = state.hostname.unwrap_or("connecting...");
let tls = if state.is_tls {
"🔒 TLS"
} else {
"🔒✗ TLS"
};
let badges = if state.has_token {
format!("{tls} | 🔑 token")
} else {
tls.to_string()
};
let named = format!("socktop — host: {host}");
let with_badges = format!("{named} | {badges}");
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
let intervals = format!(
"⏱ {}ms metrics | {}ms procs",
state.metrics_ms, state.procs_ms
);
let intervals_short = format!("{}ms | {}ms", state.metrics_ms, state.procs_ms);
// Richest first. The bare hostname is reached before the intervals lose their
// labels, and the hostname is only shortened once nothing else is left to give.
let ladder = [
(with_keys.as_str(), intervals.as_str()),
(with_badges.as_str(), intervals.as_str()),
(named.as_str(), intervals.as_str()),
(host, intervals.as_str()),
(host, intervals_short.as_str()),
];
let (left, right) = pick_pair(width, GAP, &ladder);
if cols(left) + cols(right) + GAP <= width {
return (left.to_string(), right.to_string());
}
// Past the floor of the ladder: shorten the hostname, and give up the intervals only
// if even a stub of a hostname will not fit beside them.
let room = width
.saturating_sub(cols(&intervals_short))
.saturating_sub(GAP);
if room >= HOSTNAME_FLOOR {
return (truncate_cols(host, room), intervals_short);
}
(truncate_cols(host, width), String::new())
}
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
let intervals_width = intervals.len() as u16;
if area.width > intervals_width + 2 {
if intervals.is_empty() {
return;
}
let intervals_width = cols(intervals);
if area.width >= intervals_width {
let right_area = Rect {
x: area.x + area.width.saturating_sub(intervals_width + 1),
x: area.x + area.width - intervals_width,
y: area.y,
width: intervals_width,
height: 1,
};
let intervals_line = Line::from(Span::raw(intervals));
f.render_widget(Paragraph::new(intervals_line), right_area);
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state(hostname: Option<&str>) -> HeaderState<'_> {
HeaderState {
hostname,
is_tls: false,
has_token: false,
metrics_ms: 500,
procs_ms: 2000,
}
}
/// The defect this replaces: the two halves were painted independently, so below
/// ~105 columns the right half landed on top of the title. Whatever the width, they
/// must now fit side by side.
#[test]
fn halves_never_overlap_at_any_width() {
for width in 0..=200u16 {
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
let used = cols(&left) + cols(&right);
if right.is_empty() {
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
} else {
assert!(
used + GAP <= width,
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
);
}
}
}
/// Hostname and intervals are the two things worth keeping; everything else is
/// context that can go.
#[test]
fn hostname_and_intervals_survive_longest() {
for width in 34..=200u16 {
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
assert!(
left.contains("cachyos-gaming"),
"width {width}: lost the hostname ({left:?})"
);
assert!(
right.contains("500ms") && right.contains("2000ms"),
"width {width}: lost the intervals ({right:?})"
);
}
}
/// The ladder from the design: key hints, then badges, then the prefix, then the
/// interval labels, then the hostname itself.
#[test]
fn detail_is_dropped_in_priority_order() {
let s = state(Some("cachyos-gaming"));
let (left, right) = build_header(s, 120);
assert_eq!(
left,
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
);
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
// Key hints go first.
let (left, _) = build_header(s, 80);
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
// Then the badges.
let (left, _) = build_header(s, 70);
assert_eq!(left, "socktop — host: cachyos-gaming");
// Then the prefix, leaving the bare hostname.
let (left, right) = build_header(s, 50);
assert_eq!(left, "cachyos-gaming");
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
// Then the interval labels.
let (left, right) = build_header(s, 34);
assert_eq!(left, "cachyos-gaming");
assert_eq!(right, "⏱ 500ms | 2000ms");
// Only then is the hostname itself shortened.
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
let (left, right) = build_header(s, 30);
assert_eq!(left, "cachyos-gam…");
assert_eq!(right, "⏱ 500ms | 2000ms");
}
/// A long hostname must not push the intervals off the row.
#[test]
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
for width in 30..=100u16 {
let (left, right) = build_header(state(Some(long)), width);
assert!(!right.is_empty(), "width {width}: intervals were dropped");
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
}
}
/// Widths too small for both: the hostname is the last thing standing.
#[test]
fn hostname_is_the_final_survivor() {
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
assert!(right.is_empty(), "intervals should have been dropped");
assert!(!left.is_empty());
assert!(cols(&left) <= 20);
}
#[test]
fn tls_and_token_badges_appear_when_there_is_room() {
let s = HeaderState {
hostname: Some("host"),
is_tls: true,
has_token: true,
metrics_ms: 500,
procs_ms: 2000,
};
let (left, _) = build_header(s, 200);
assert!(left.contains("🔒 TLS"), "{left}");
assert!(left.contains("🔑 token"), "{left}");
}
#[test]
fn a_missing_hostname_reads_as_connecting() {
let (left, _) = build_header(state(None), 120);
assert!(left.contains("connecting"), "{left}");
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Root layout computation, shared by the draw path and the input hit-testing paths.
//!
//! Two modes:
//!
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
//! network graphs next to the process table.
//!
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
//! by side into the space it vacated, the GPU collapses to a single full-width line
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
//! long before the rest of the panes stop being useful.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Which of the two layouts [`compute`] produced.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayoutMode {
Normal,
Compact,
}
impl LayoutMode {
pub fn is_compact(self) -> bool {
matches!(self, LayoutMode::Compact)
}
}
/// Rows the Disks pane needs before it can show one disk card: the card itself is
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
const DISKS_MIN_H: u16 = 5;
/// Header line.
const HEADER_H: u16 = 1;
/// Memory and Swap gauges: 1 content row between borders.
const GAUGE_H: u16 = 3;
/// A network graph at its preferred height.
const NET_H: u16 = 5;
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
// borders) before the network graphs are allowed to shrink, because restoring the CPU
// panes is the entire point of the mode.
const TOP_MIN_H: u16 = 5;
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
#[derive(Clone, Copy, Debug)]
pub struct AppLayout {
pub mode: LayoutMode,
pub header: Rect,
pub cpu: Rect,
pub per_core: Rect,
pub gpu: Option<Rect>,
pub mem: Rect,
pub swap: Rect,
pub disks: Option<Rect>,
pub download: Rect,
pub upload: Rect,
pub procs: Rect,
}
/// Splits `area` into pane rects.
///
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
if force_compact {
return compact(area, has_gpu);
}
let normal = normal(area);
match normal.disks {
Some(d) if d.height >= DISKS_MIN_H => normal,
_ => compact(area, has_gpu),
}
}
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(dir)
.constraints(constraints)
.split(area)
}
/// 66/34 split used by every full-width row in the normal layout.
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
split(
area,
Direction::Horizontal,
&[Constraint::Percentage(66), Constraint::Percentage(34)],
)
}
fn normal(area: Rect) -> AppLayout {
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H), // header
Constraint::Ratio(1, 3), // top row
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
],
);
let top = left_right(rows[1]);
let mem_lr = left_right(rows[2]);
let swap_lr = left_right(rows[3]);
// GPU spans the same vertical space as Memory + Swap.
let gpu = Rect {
x: mem_lr[1].x,
y: mem_lr[1].y,
width: mem_lr[1].width,
height: mem_lr[1].height + swap_lr[1].height,
};
let bottom = split(
rows[4],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Min(4), // disks absorbs the slack
Constraint::Length(NET_H), // download
Constraint::Length(NET_H), // upload
],
);
AppLayout {
mode: LayoutMode::Normal,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: Some(gpu),
mem: mem_lr[0],
swap: swap_lr[0],
disks: Some(left_stack[0]),
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
// Give the top row its floor first, then share any surplus with the bottom so the
// process table keeps growing with the window instead of staying pinned at 13 rows.
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
(top, avail - top)
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
(TOP_MIN_H, avail - TOP_MIN_H)
} else {
// Smaller than both floors: the network graphs are already at their minimum, so
// the top row takes what is left (panes clip below this point).
let bottom = BOTTOM_MIN_H.min(avail);
(avail - bottom, bottom)
};
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H),
Constraint::Length(top_h),
Constraint::Length(gpu_h),
Constraint::Length(bottom_h),
],
);
let top = left_right(rows[1]);
let bottom = split(
rows[3],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Length(GAUGE_H),
Constraint::Fill(1),
Constraint::Fill(1),
],
);
let gauges = split(
left_stack[0],
Direction::Horizontal,
&[Constraint::Percentage(50), Constraint::Percentage(50)],
);
AppLayout {
mode: LayoutMode::Compact,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: has_gpu.then_some(rows[2]),
mem: gauges[0],
swap: gauges[1],
disks: None,
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area(w: u16, h: u16) -> Rect {
Rect::new(0, 0, w, h)
}
/// The height where the normal layout still fits a full disk card. Below it the CPU
/// panes are the ones that collapse, which is what compact mode exists to prevent.
#[test]
fn tall_window_stays_normal() {
let l = compute(area(120, 40), false, true);
assert_eq!(l.mode, LayoutMode::Normal);
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
}
#[test]
fn short_window_switches_to_compact() {
let l = compute(area(120, 24), false, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
/// The switch happens exactly when Disks can no longer show one card, and never
/// oscillates: every height above the crossover is normal, every height below is
/// compact.
#[test]
fn mode_is_monotonic_in_height() {
let mut first_normal = None;
for h in 10..=60u16 {
let mode = compute(area(120, h), false, true).mode;
match (mode, first_normal) {
(LayoutMode::Normal, None) => first_normal = Some(h),
(LayoutMode::Compact, Some(prev)) => {
panic!("height {h} went back to compact after normal at {prev}")
}
_ => {}
}
}
assert!(first_normal.is_some(), "never reached the normal layout");
}
#[test]
fn force_compact_overrides_a_tall_window() {
let l = compute(area(200, 80), true, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
#[test]
fn compact_drops_the_gpu_row_without_a_gpu() {
let with = compute(area(120, 24), true, true);
let without = compute(area(120, 24), true, false);
assert!(with.gpu.is_some());
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
assert!(without.gpu.is_none());
// The rows a GPU-less host saves are shared between the CPU panes and the
// bottom half, and none of them are left as a gap.
assert!(without.cpu.height > with.cpu.height);
assert!(without.procs.height > with.procs.height);
assert_eq!(without.procs.y + without.procs.height, 24);
}
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
/// content rows inside their borders.
#[test]
fn compact_keeps_the_cpu_panes_drawable() {
for h in 18..=32u16 {
let l = compute(area(120, h), false, true);
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
assert!(
l.cpu.height >= TOP_MIN_H,
"height {h}: cpu pane only {} rows",
l.cpu.height
);
assert_eq!(l.per_core.height, l.cpu.height);
}
}
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
/// left the top row with no drawable interior at all.
#[test]
fn compact_beats_the_fixed_layout_at_18_rows() {
let compact = compute(area(120, 18), false, true);
let fixed = normal(area(120, 18));
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
assert!(compact.cpu.height > fixed.cpu.height);
}
#[test]
fn compact_panes_tile_the_area_without_gaps() {
for h in 16..=32u16 {
for has_gpu in [true, false] {
let l = compute(area(120, h), true, has_gpu);
assert_eq!(l.header.y, 0);
assert_eq!(l.cpu.y, l.header.y + l.header.height);
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
let after_cpu = l.cpu.y + l.cpu.height;
let bottom_y = match l.gpu {
Some(g) => {
assert_eq!(g.y, after_cpu);
assert_eq!(g.width, 120, "gpu strip spans the full width");
g.y + g.height
}
None => after_cpu,
};
assert_eq!(l.mem.y, bottom_y);
// Memory and Swap sit side by side on one row.
assert_eq!(l.swap.y, l.mem.y);
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
assert_eq!(l.mem.height, GAUGE_H);
assert_eq!(l.download.y, l.mem.y + l.mem.height);
assert_eq!(l.upload.y, l.download.y + l.download.height);
assert_eq!(l.procs.y, bottom_y);
}
}
}
/// A degenerate size must not panic or produce rects outside the frame.
#[test]
fn tiny_windows_stay_inside_the_frame() {
for h in 0..=16u16 {
for w in [0u16, 1, 20, 80] {
let l = compute(area(w, h), false, true);
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
}
}
}
}
}
+2
View File
@@ -2,8 +2,10 @@
pub mod cpu;
pub mod disks;
pub mod fit;
pub mod gpu;
pub mod header;
pub mod layout;
pub mod mem;
pub mod modal;
pub mod modal_connection;
+547 -74
View File
@@ -1,6 +1,10 @@
//! Modal window system for socktop TUI application
use super::theme::MODAL_DIM_BG;
use super::fit;
use super::theme::{
BTN_EXIT_BG_ACTIVE, BTN_RETRY_BG_ACTIVE, MODAL_BG, MODAL_BORDER_FG, MODAL_DIM_BG, MODAL_FG,
MODAL_TITLE_FG,
};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
@@ -26,6 +30,10 @@ 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 {
@@ -83,6 +91,52 @@ 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,
@@ -110,6 +164,16 @@ 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
@@ -241,7 +305,16 @@ impl ModalManager {
ModalAction::Dismiss
}
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
(Some(ModalType::Confirmation { .. }), ModalButton::ConfirmForce) => {
ModalAction::ConfirmForce
}
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => {
// Pop here so Enter-on-Cancel behaves like Esc (which pops in
// handle_key); the app's Cancel handler can then assume the
// modal is already gone.
self.pop_modal();
ModalAction::Cancel
}
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
@@ -253,12 +326,29 @@ 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,
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalButton::Cancel,
// Confirmation cycles through three: the safe affirmative, the
// escalated one, then cancel.
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => {
ModalButton::ConfirmForce
}
(Some(ModalType::Confirmation { .. }), ModalButton::ConfirmForce) => {
ModalButton::Cancel
}
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm,
_ => 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();
}
@@ -280,6 +370,150 @@ impl ModalManager {
);
}
/// Wrap `text` to at most `width` columns on word boundaries, so a dialog
/// can be sized from its content instead of guessing.
fn wrap_cols(text: &str, width: u16) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() {
word.to_string()
} else {
format!("{current} {word}")
};
if fit::cols(&candidate) <= width || current.is_empty() {
current = candidate;
} else {
lines.push(std::mem::take(&mut current));
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
/// A centered box just big enough for `message` plus `footer_rows` of
/// buttons/hints. Never exceeds the screen, and never gets so narrow that
/// the title is clipped.
///
/// `min_content_w` is the width the footer needs. Sizing from the message
/// alone clipped the key-hint line, which is longer than most questions.
fn dialog_rect(area: Rect, message: &str, footer_rows: u16, min_content_w: u16) -> Rect {
// 2 border columns + 2 columns of breathing room on each side.
const CHROME_W: u16 = 6;
const MAX_TEXT_W: u16 = 64;
const MIN_TEXT_W: u16 = 24;
let avail_text = area.width.saturating_sub(CHROME_W).max(1);
let text_w = fit::cols(message)
.min(MAX_TEXT_W)
.min(avail_text)
.max(MIN_TEXT_W.min(avail_text));
let lines = Self::wrap_cols(message, text_w);
let widest = lines
.iter()
.map(|l| fit::cols(l))
.max()
.unwrap_or(text_w)
.max(min_content_w.min(avail_text));
let width = (widest + CHROME_W).min(area.width);
// borders + blank + message + blank + footer
let height = (lines.len() as u16 + footer_rows + 4).min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
}
}
/// Shared chrome for the small dialogs: themed border, centered message
/// with real padding, and the footer row(s) returned for the caller to
/// fill with buttons.
///
/// The old versions laid their content out over `area` rather than the
/// block's inner rect, which put the first line of text on top of the
/// border and pushed the buttons against the frame.
fn render_dialog_frame(
f: &mut Frame,
area: Rect,
title: &str,
message: &str,
footer_rows: u16,
) -> Rect {
let block = Block::default()
.title(
Line::from(format!(" {title} ")).style(
Style::default()
.fg(MODAL_TITLE_FG)
.add_modifier(Modifier::BOLD),
),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER_FG))
.style(Style::default().bg(MODAL_BG));
let inner = block.inner(area);
f.render_widget(block, area);
// Pad one column each side so text never touches the border.
let padded = Rect {
x: inner.x + 1,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // breathing room under the title
Constraint::Min(1), // message
Constraint::Length(1), // gap above the footer
Constraint::Length(footer_rows), // buttons / hints
])
.split(padded);
f.render_widget(
Paragraph::new(message)
.style(Style::default().fg(MODAL_FG))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
rows[1],
);
rows[3]
}
/// One button, sized to its label and centered in `area`.
fn render_button(f: &mut Frame, area: Rect, label: &str, active: bool, accent: Color) {
let style = if active {
Style::default()
.bg(accent)
.fg(MODAL_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(accent)
};
let text = format!(" {label} ");
let w = fit::cols(&text).min(area.width);
let btn = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y,
width: w,
height: 1,
};
f.render_widget(
Paragraph::new(text)
.style(style)
.alignment(Alignment::Center),
btn,
);
}
fn render_modal_content(&mut self, f: &mut Frame, modal: &ModalType, data: ProcessModalData) {
let area = f.area();
// Different sizes for different modal types
@@ -296,6 +530,13 @@ 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)
@@ -340,86 +581,64 @@ impl ModalManager {
confirm_text: &str,
cancel_text: &str,
) {
let chunks = Layout::default()
// Three buttons + a key hint line.
let footer = Self::render_dialog_frame(f, area, title, message, 3);
let rows = Layout::default()
.direction(Direction::Vertical)
.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 buttons = Layout::default()
.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::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),
buttons[0],
.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,
);
f.render_widget(
Paragraph::new(cancel_text)
.style(cancel_style)
Paragraph::new(CONFIRM_HINT)
.style(Style::default().fg(MODAL_FG).add_modifier(Modifier::DIM))
.alignment(Alignment::Center),
buttons[1],
rows[2],
);
}
fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) {
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],
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,
);
}
@@ -505,6 +724,9 @@ 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",
"",
@@ -632,3 +854,254 @@ 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());
}
}
+65 -19
View File
@@ -95,7 +95,7 @@ impl ModalManager {
}
// Help line
let help_text = vec![Line::from(vec![
let mut help_text = vec![Line::from(vec![
Span::styled(
"X ",
Style::default()
@@ -129,6 +129,23 @@ 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());
@@ -472,13 +489,28 @@ impl ModalManager {
.borders(Borders::ALL);
let content_lines: Vec<Line> = if journal.entries.is_empty() {
vec![
let mut lines = 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
@@ -624,16 +656,29 @@ impl ModalManager {
// labels + axis title + (top) Y-axis title + legend + spacing.
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
// Y-axis labels and plot content
let mut row_buf = String::with_capacity(plot_width);
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}")
// 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!("{y_value:>4.1}")
};
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);
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$}");
// Build the row's char slice into a reusable String buffer.
row_buf.clear();
@@ -650,8 +695,8 @@ impl ModalManager {
]));
}
// Add X-axis
let x_axis_padding = " ".to_string(); // Match Y-axis label width
// Add X-axis (padding = Y label width + the space before the bar)
let x_axis_padding = " ".repeat(y_label_w + 1);
let x_axis_line = "".repeat(plot_width + 1);
lines.push(Line::from(vec![
Span::styled(x_axis_padding, Style::default()),
@@ -659,13 +704,14 @@ impl ModalManager {
]));
// Add X-axis labels
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 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 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,
@@ -677,7 +723,7 @@ impl ModalManager {
// Add axis titles with better visibility
lines.push(Line::from(vec![Span::styled(
" User CPU Time (ms) →",
format!("{}User CPU Time (ms) →", " ".repeat(y_label_w + 1)),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
+14
View File
@@ -19,6 +19,10 @@ 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
@@ -64,9 +68,15 @@ 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)]
@@ -74,6 +84,10 @@ 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,
}
+566 -117
View File
@@ -5,13 +5,14 @@ use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::Span,
text::{Line, 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,
@@ -86,6 +87,10 @@ 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)]
@@ -134,14 +139,83 @@ pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
peak
}
// Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID
Constraint::Percentage(40), // Name
Constraint::Length(8), // CPU %
Constraint::Length(12), // Mem
Constraint::Length(8), // Mem %
];
const PID_W: u16 = 8;
const CPU_W: u16 = 8;
const MEM_W: u16 = 12;
const MEM_PCT_W: u16 = 8;
/// Columns the Name field needs to identify anything. Every other column is only added
/// once Name already has this much, so Name can no longer be squeezed to nothing.
const NAME_MIN_W: u16 = 8;
/// `Table::column_spacing`.
const COL_SPACING: u16 = 1;
/// Which process columns fit in the pane, and where they sit.
///
/// The table used to hand the layout solver a fixed, over-constrained set, so on a narrow
/// pane the solver crushed the percentage-sized Name column to nothing while the fixed
/// PID and Mem % columns kept their full width — losing the one field that identifies the
/// process while keeping the ones that do not.
///
/// Columns are now added in priority order as the pane widens, so they are shed in
/// reverse as it narrows: Name is unconditional, then CPU %, then Mem, then PID, and
/// Mem % last (it is derivable from Mem, so it is the least costly to lose).
///
/// Both the draw path and the header-click hit-testing build this from the same width, so
/// a sort click always lands on the column the user can actually see.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcColumns {
pub pid: bool,
pub cpu: bool,
pub mem: bool,
pub mem_pct: bool,
}
impl ProcColumns {
pub fn for_width(width: u16) -> Self {
// Each tier is the previous one plus a column and the gap before it.
let with_cpu = NAME_MIN_W + COL_SPACING + CPU_W;
let with_mem = with_cpu + COL_SPACING + MEM_W;
let with_pid = with_mem + COL_SPACING + PID_W;
let with_mem_pct = with_pid + COL_SPACING + MEM_PCT_W;
Self {
cpu: width >= with_cpu,
mem: width >= with_mem,
pid: width >= with_pid,
mem_pct: width >= with_mem_pct,
}
}
/// Column constraints in render order. Name takes whatever the others leave.
pub fn constraints(&self) -> Vec<Constraint> {
let mut c = Vec::with_capacity(5);
if self.pid {
c.push(Constraint::Length(PID_W));
}
c.push(Constraint::Fill(1)); // Name
if self.cpu {
c.push(Constraint::Length(CPU_W));
}
if self.mem {
c.push(Constraint::Length(MEM_W));
}
if self.mem_pct {
c.push(Constraint::Length(MEM_PCT_W));
}
c
}
/// Position of the CPU % column, which is clickable to sort. `None` when too narrow
/// to render it.
pub fn cpu_index(&self) -> Option<usize> {
self.cpu.then(|| 1 + usize::from(self.pid))
}
/// Position of the Mem column, which is clickable to sort.
pub fn mem_index(&self) -> Option<usize> {
self.mem
.then(|| 1 + usize::from(self.pid) + usize::from(self.cpu))
}
}
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
// Draw outer block and title
@@ -233,6 +307,8 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.fold(0.0_f32, f32::max)
};
let columns = ProcColumns::for_width(content.width);
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix];
@@ -304,16 +380,29 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.add_modifier(Modifier::BOLD);
}
ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(name_span),
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
.style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
])
.style(emphasis)
let mut cells = Vec::with_capacity(5);
if columns.pid {
cells.push(
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
);
}
cells.push(ratatui::widgets::Cell::from(name_span));
if columns.cpu {
cells.push(
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
.style(Style::default().fg(cpu_fg)),
);
}
if columns.mem {
cells.push(ratatui::widgets::Cell::from(Span::raw(mem_span_text)));
}
if columns.mem_pct {
cells.push(
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
);
}
ratatui::widgets::Row::new(cells).style(emphasis)
});
// Header with sort indicator
@@ -325,16 +414,30 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
ProcSortBy::MemDesc => "Mem •",
_ => "Mem",
};
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
let mut header_cells = Vec::with_capacity(5);
if columns.pid {
header_cells.push("PID");
}
header_cells.push("Name");
if columns.cpu {
header_cells.push(cpu_hdr);
}
if columns.mem {
header_cells.push(mem_hdr);
}
if columns.mem_pct {
header_cells.push("Mem %");
}
let header = ratatui::widgets::Row::new(header_cells).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
// Render table inside content area (no borders here; outer block already drawn)
let table = Table::new(rows_iter, COLS.to_vec())
let table = Table::new(rows_iter, columns.constraints())
.header(header)
.column_spacing(1);
.column_spacing(COL_SPACING);
f.render_widget(table, content);
// Draw tooltip if a process is selected
@@ -351,16 +454,60 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
format!("PID {selected_pid}")
};
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;
// 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));
// Position tooltip at bottom-right of the processes area
if area.width > tooltip_width + 2 && area.height > tooltip_height + 1 {
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_height = 3;
let max_w = area.width.saturating_sub(2);
if max_w > hints_w + 6 && area.height > tooltip_height + 1 {
// The process name is the elastic part: truncate it so the hint
// always fits. The old version sized the box from the full string
// and skipped rendering entirely when a long process name made it
// wider than the pane — so the hint silently vanished exactly when
// a long-named process was selected.
let room_for_info = max_w - hints_w - 6;
let info = fit::truncate_cols(&process_info, room_for_info);
let mut spans: Vec<Span> = vec![
Span::styled(" ", label),
Span::styled(
info.clone(),
Style::default()
.fg(PROCESS_TOOLTIP_FG)
.add_modifier(Modifier::BOLD),
),
Span::styled("", label),
];
spans.extend(hints);
spans.push(Span::styled(" ", label));
let width = spans
.iter()
.map(|s| fit::cols(&s.content))
.sum::<u16>()
.saturating_add(2)
.min(area.width);
let tooltip_area = Rect {
x: area.x + area.width.saturating_sub(tooltip_width + 1),
x: area.x + area.width.saturating_sub(width + 1),
y: area.y + area.height.saturating_sub(tooltip_height + 1),
width: tooltip_width,
width,
height: tooltip_height,
};
@@ -370,11 +517,10 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.fg(PROCESS_TOOLTIP_FG),
);
let tooltip_paragraph = Paragraph::new(tooltip_text)
.block(tooltip_block)
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(tooltip_paragraph, tooltip_area);
f.render_widget(
Paragraph::new(Line::from(spans)).block(tooltip_block),
tooltip_area,
);
}
}
@@ -401,7 +547,6 @@ 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>,
@@ -411,16 +556,6 @@ 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;
@@ -500,74 +635,6 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
}
}
/// Handle mouse for content scrolling and scrollbar dragging.
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
/// LEGACY: Use processes_handle_mouse_with_selection for enhanced functionality
#[allow(dead_code)]
pub fn processes_handle_mouse(
scroll_offset: &mut usize,
drag: &mut Option<crate::ui::cpu::PerCoreScrollDrag>,
mouse: MouseEvent,
area: Rect,
total_rows: usize,
) -> Option<ProcSortBy> {
// Inner and content areas (match draw_top_processes)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height == 0 || inner.width <= 2 {
return None;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
// Scrollbar interactions (click arrows/page/drag)
per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows);
// Wheel scrolling when inside the content
crate::ui::cpu::per_core_handle_mouse(scroll_offset, mouse, content, content.height as usize);
// Header click to change sort
let header_area = Rect {
x: content.x,
y: content.y,
width: content.width,
height: 1,
};
let inside_header = mouse.row == header_area.y
&& mouse.column >= header_area.x
&& mouse.column < header_area.x + header_area.width;
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split header into the same columns
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(COLS.to_vec())
.split(header_area);
if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
return Some(ProcSortBy::CpuDesc);
}
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
return Some(ProcSortBy::MemDesc);
}
}
// Clamp to valid range
per_core_clamp(
scroll_offset,
total_rows,
(content.height.saturating_sub(1)) as usize,
);
None
}
/// Parameters for process mouse event handling
pub struct ProcessMouseParams<'a> {
pub scroll_offset: &'a mut usize,
@@ -646,15 +713,24 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
&& params.mouse.column < header_area.x + header_area.width;
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split header into the same columns
// 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(COLS.to_vec())
.constraints(columns.constraints())
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
.split(header_area);
if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width {
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
&& params.mouse.column >= cpu.x
&& params.mouse.column < cpu.x + cpu.width
{
return Some(ProcSortBy::CpuDesc);
}
if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width {
if let Some(mem) = columns.mem_index().map(|i| cols[i])
&& params.mouse.column >= mem.x
&& params.mouse.column < mem.x + mem.width
{
return Some(ProcSortBy::MemDesc);
}
}
@@ -691,3 +767,376 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
);
None
}
#[cfg(test)]
mod column_tests {
use super::*;
use ratatui::layout::{Direction, Layout, Rect};
fn name_width(w: u16) -> u16 {
let c = ProcColumns::for_width(w);
let rects = Layout::default()
.direction(Direction::Horizontal)
.constraints(c.constraints())
.spacing(COL_SPACING)
.split(Rect::new(0, 0, w, 1));
rects[usize::from(c.pid)].width
}
/// The complaint this fixes: on a narrow pane the Name column was the first thing to
/// disappear, leaving a table of numbers with nothing to identify the process. Name
/// must now be the last column standing, at every width that can render anything.
#[test]
fn name_is_never_the_column_that_gets_dropped() {
for width in NAME_MIN_W..=200u16 {
assert!(
name_width(width) >= 1,
"width {width}: Name was squeezed to nothing"
);
}
}
/// Columns are shed in reverse priority order, so a narrower pane can never show a
/// column that a wider one hid.
#[test]
fn columns_are_shed_in_priority_order() {
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
assert!(!c.mem_pct || c.pid, "width {width}: Mem % outlived PID");
assert!(!c.pid || c.mem, "width {width}: PID outlived Mem");
assert!(!c.mem || c.cpu, "width {width}: Mem outlived CPU %");
}
}
/// Columns come back as the pane widens and never flap.
#[test]
fn columns_are_monotonic_in_width() {
let mut prev = ProcColumns::for_width(0);
for width in 1..=200u16 {
let c = ProcColumns::for_width(width);
for (was, now, name) in [
(prev.cpu, c.cpu, "CPU %"),
(prev.mem, c.mem, "Mem"),
(prev.pid, c.pid, "PID"),
(prev.mem_pct, c.mem_pct, "Mem %"),
] {
assert!(!was || now, "width {width}: {name} vanished as it widened");
}
prev = c;
}
}
/// The tiers, from a comfortable pane down to a very narrow one.
#[test]
fn narrow_panes_shed_columns_in_order() {
let full = ProcColumns::for_width(48);
assert_eq!(full.constraints().len(), 5);
assert!(full.pid && full.cpu && full.mem && full.mem_pct);
// Mem % goes first.
let c = ProcColumns::for_width(45);
assert!(c.pid && c.mem && !c.mem_pct);
// Then PID.
let c = ProcColumns::for_width(35);
assert!(!c.pid && c.cpu && c.mem);
// Then Mem, leaving the name and its CPU load.
let c = ProcColumns::for_width(20);
assert!(!c.mem && c.cpu);
assert_eq!(c.constraints().len(), 2);
// At the floor, just the name.
let c = ProcColumns::for_width(10);
assert!(!c.cpu && !c.mem);
assert_eq!(c.constraints().len(), 1);
}
/// Regression guard for the old behaviour: a 130-column terminal gives the process
/// pane ~48 columns, and every column still fits there.
#[test]
fn a_wide_terminal_keeps_the_full_table() {
assert_eq!(ProcColumns::for_width(48).constraints().len(), 5);
}
/// Sort clicks are resolved by index, so those indices must track the columns that
/// are actually rendered — otherwise clicking "CPU %" would sort by Mem.
#[test]
fn sort_indices_follow_the_rendered_columns() {
let wide = ProcColumns::for_width(48);
assert_eq!(wide.cpu_index(), Some(2)); // PID, Name, CPU %
assert_eq!(wide.mem_index(), Some(3));
let narrow = ProcColumns::for_width(35);
assert_eq!(narrow.cpu_index(), Some(1)); // Name, CPU %
assert_eq!(narrow.mem_index(), Some(2));
// A column that is not rendered has no index to click.
let tiny = ProcColumns::for_width(10);
assert_eq!(tiny.cpu_index(), None);
assert_eq!(tiny.mem_index(), None);
// Whatever the width, any index returned is inside the rendered set.
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
let n = c.constraints().len();
for i in [c.cpu_index(), c.mem_index()].into_iter().flatten() {
assert!(i < n, "width {width}: index {i} outside {n} columns");
}
}
}
/// Name takes the slack, so it grows with the pane instead of being pinned to a
/// percentage that the fixed columns can crush.
#[test]
fn name_absorbs_the_leftover_width() {
assert!(
name_width(80) > name_width(60),
"Name did not grow with the pane"
);
}
}
#[cfg(test)]
mod click_tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics() -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 32_000_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid: 4242,
name: "some-process".into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
/// Renders the pane and returns its header row as text.
fn header_row(width: u16) -> String {
let m = metrics();
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
let idxs = [0usize];
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
terminal
.draw(|f| {
draw_top_processes(
f,
Rect::new(0, 0, width, 8),
ProcessDisplayParams {
metrics: Some(&m),
scroll_offset: 0,
sort_by: ProcSortBy::CpuDesc,
selected_process_pid: None,
selected_process_index: None,
search_query: "",
search_active: false,
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
kill_enabled: false,
},
)
})
.unwrap();
let buf = terminal.backend().buffer();
(0..width)
.map(|x| buf[(x, 1)].symbol().to_string())
.collect()
}
fn click(width: u16, column: u16) -> Option<ProcSortBy> {
let m = metrics();
let mut scroll = 0usize;
let mut drag = None;
let mut sel_pid = None;
let mut sel_idx = None;
let idxs = [0usize];
processes_handle_mouse_with_selection(ProcessMouseParams {
scroll_offset: &mut scroll,
selected_process_pid: &mut sel_pid,
selected_process_index: &mut sel_idx,
drag: &mut drag,
mouse: MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: 1,
modifiers: KeyModifiers::NONE,
},
area: Rect::new(0, 0, width, 8),
total_rows: 1,
metrics: Some(&m),
search_box_visible: false,
filtered_indices: &idxs,
})
}
/// The hit-test rects are computed by a separate `Layout` call from the one `Table`
/// renders with. This walks the rendered header text and clicks each label where it
/// actually appears, which catches any drift between the two — including column
/// spacing, which the two APIs configure differently.
#[test]
fn clicking_a_rendered_sort_header_sorts_by_that_column() {
for width in [40u16, 50, 60, 80, 120] {
let row = header_row(width);
let cpu_at = row.find("CPU").map(|i| row[..i].chars().count() as u16);
let mem_at = row.find("Mem").map(|i| row[..i].chars().count() as u16);
if let Some(x) = cpu_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::CpuDesc),
"width {width}: clicking the rendered 'CPU %' header at column {x} \
did not sort by CPU (header row: {row:?})"
);
}
if let Some(x) = mem_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::MemDesc),
"width {width}: clicking the rendered 'Mem' header at column {x} \
did not sort by Mem (header row: {row:?})"
);
}
}
}
/// Name is what identifies the row, so it must be rendered at every width the pane
/// can draw anything at.
#[test]
fn the_name_column_is_rendered_even_when_narrow() {
for width in [30u16, 40, 60, 120] {
let row = header_row(width);
assert!(
row.contains("Name"),
"width {width}: no Name column in header {row:?}"
);
}
}
}
#[cfg(test)]
mod tooltip_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics(name: &str) -> Metrics {
Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 32_000_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid: 4242,
name: name.into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
/// Render the pane with a selection and return the whole buffer as text.
fn rendered(name: &str, width: u16, 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}"
);
}
}
-13
View File
@@ -22,19 +22,6 @@ pub fn human(b: u64) -> String {
format!("{tb:.2}TB")
}
pub fn truncate_middle(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
if max <= 3 {
return "...".into();
}
let keep = max - 3;
let left = keep / 2;
let right = keep - left;
format!("{}...{}", &s[..left], &s[s.len() - right..])
}
pub fn disk_icon(name: &str) -> &'static str {
let n = name.to_ascii_lowercase();
if n.contains(':') {
View File
+109
View File
@@ -73,3 +73,112 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
);
assert!(text3.contains("Usage:"));
}
#[test]
fn test_compact_flag_documented_and_accepted() {
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--compact", "--help"])
.output()
.expect("run socktop --compact --help");
assert!(
out.status.success(),
"socktop --compact --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("--compact"),
"help text missing --compact\n{text}"
);
// The flag must not be mistaken for the positional URL argument.
let out2 = Command::new(exe)
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop --compact --dry-run");
assert!(
out2.status.success(),
"socktop --compact with a URL was rejected: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
#[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}"
);
}
+1
View File
@@ -8,6 +8,7 @@ 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,
+32 -8
View File
@@ -1,18 +1,19 @@
[package]
name = "socktop_agent"
version = "1.50.2"
version = "1.60.2"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024"
license = "MIT"
readme = "README.md"
homepage = "https://github.com/jasonwitty/socktop"
repository = "https://github.com/jasonwitty/socktop"
[dependencies]
# 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"] }
# 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"] }
axum = { version = "0.7", features = ["ws", "macros"] }
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] }
@@ -21,7 +22,11 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
futures-util = "0.3.31"
tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
gfxinfo = "0.1.2"
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"] }
@@ -33,7 +38,8 @@ prost = { workspace = true }
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
[features]
default = []
default = ["gpu"]
gpu = ["gfxinfo", "nvml-wrapper"]
logging = ["tracing", "tracing-subscriber"]
[build-dependencies]
@@ -45,3 +51,21 @@ protoc-bin-vendored = "3"
assert_cmd = "2.0"
tempfile = "3.10"
tokio-tungstenite = "0.21"
[package.metadata.deb]
maintainer = "Jason Witty <jasonpwitty+socktop@proton.me>"
copyright = "2024, Jason Witty <jasonpwitty+socktop@proton.me>"
license-file = ["../LICENSE", "4"]
extended-description = """\
socktop_agent is the daemon component that runs on remote hosts to collect \
and serve system metrics over WebSocket. It gathers CPU, memory, disk, network, \
GPU, and process information that can be monitored remotely by the socktop TUI client."""
depends = "$auto"
section = "admin"
priority = "optional"
assets = [
["target/release/socktop_agent", "usr/bin/", "755"],
["../README.md", "usr/share/doc/socktop_agent/", "644"],
]
maintainer-scripts = "debian/"
systemd-units = { unit-name = "socktop-agent", unit-scripts = ".", enable = false }
+6 -4
View File
@@ -1,13 +1,15 @@
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());
cfg.protoc_executable(protoc); // Use the vendored protoc directly
// 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);
}
// 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");
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
set -e
# Create socktop user and group if they don't exist
if ! getent group socktop >/dev/null; then
addgroup --system socktop
fi
if ! getent passwd socktop >/dev/null; then
adduser --system --ingroup socktop --home /var/lib/socktop \
--no-create-home --disabled-password --disabled-login \
--gecos "Socktop Agent" socktop
fi
# Create state directory
mkdir -p /var/lib/socktop
chown socktop:socktop /var/lib/socktop
chmod 755 /var/lib/socktop
# Create config directory if it doesn't exist
mkdir -p /etc/socktop
chmod 755 /etc/socktop
#DEBHELPER#
# Print helpful message to the user
cat <<EOF
┌─────────────────────────────────────────────────────────────────────┐
│ socktop-agent has been installed successfully! │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ The systemd service has been installed but is NOT enabled by │
│ default. To enable and start the service: │
│ │
│ sudo systemctl enable --now socktop-agent │
│ │
│ To start without enabling on boot: │
│ │
│ sudo systemctl start socktop-agent │
│ │
│ To check service status: │
│ │
│ sudo systemctl status socktop-agent │
│ │
│ Default settings: │
│ - Port: 3000 (use -p or --port to change) │
│ - SSL/TLS: disabled (use --enableSSL to enable) │
│ │
│ For more information, see: │
│ /usr/share/doc/socktop_agent/README.md │
│ │
└─────────────────────────────────────────────────────────────────────┘
EOF
exit 0
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -e
case "$1" in
purge)
# Remove user and group on purge
if getent passwd socktop >/dev/null; then
deluser --quiet socktop || true
fi
if getent group socktop >/dev/null; then
delgroup --quiet socktop || true
fi
# Remove state directory on purge
rm -rf /var/lib/socktop
# Remove config directory if empty
rmdir --ignore-fail-on-non-empty /etc/socktop 2>/dev/null || true
;;
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
# Do nothing on remove/upgrade
;;
*)
echo "postrm called with unknown argument \`$1'" >&2
exit 1
;;
esac
#DEBHELPER#
exit 0
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Socktop Agent - Remote System Monitor
Documentation=https://github.com/jasonwitty/socktop
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/socktop_agent --port 3000
Environment=RUST_LOG=info
# Optional authentication token:
# Environment=SOCKTOP_TOKEN=changeme
Restart=on-failure
RestartSec=5
User=socktop
Group=socktop
NoNewPrivileges=true
# Security hardening
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/socktop
StateDirectory=socktop
[Install]
WantedBy=multi-user.target
+114 -13
View File
@@ -1,5 +1,4 @@
// gpu.rs
use gfxinfo::active_gpu;
#[derive(Debug, Clone, serde::Serialize)]
pub struct GpuMetrics {
@@ -9,16 +8,118 @@ pub struct GpuMetrics {
pub mem_total_bytes: u64,
}
pub fn collect_all_gpus() -> Result<Vec<GpuMetrics>, Box<dyn std::error::Error>> {
let gpu = active_gpu()?; // Use ? to unwrap Result
let info = gpu.info();
let metrics = GpuMetrics {
name: gpu.model().to_string(),
utilization_gpu_pct: info.load_pct() as u32,
mem_used_bytes: info.used_vram(),
mem_total_bytes: info.total_vram(),
};
Ok(vec![metrics])
/// 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
}
#[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()));
}
}
}
+291 -273
View File
@@ -13,49 +13,60 @@ 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, Instant, SystemTime, UNIX_EPOCH};
use std::time::{Duration, 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.
// 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.
/// 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.
#[cfg(target_os = "linux")]
fn get_cpu_times_ms(pid: u32) -> (u64, u64) {
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) {
let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
return (0, 0);
};
let Some(rpar) = s.rfind(')') else {
let Some((utime, stime)) = procstat::utime_stime(&s) else {
return (0, 0);
};
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)
(utime * procstat::TICK_US, stime * procstat::TICK_US)
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
fn get_cpu_times_us(_pid: u32) -> (u64, u64) {
(0, 0)
}
// Runtime toggles (read once)
@@ -117,46 +128,32 @@ fn name_cache_cleanup_threshold() -> usize {
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms
// 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.
const TTL: Duration = Duration::from_millis(1500);
struct TempCache {
at: Option<Instant>,
v: Option<f32>,
}
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
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();
// Last time `state.components` was refreshed (by any caller). Both
// Gate on `state.components` refreshes (hwmon scans). Both
// collect_fast_metrics and collect_disks need fresh sensor values; without
// this gate they were each doing their own `Components::refresh` on their
// own cadence, paying the hwmon syscall cost twice per polling cycle.
// 1s is short enough that disk temps stay accurate (they change slowly) and
// long enough to suppress back-to-back refreshes from concurrent endpoints.
// 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.
const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000);
static COMPONENTS_LAST_REFRESH: OnceCell<Mutex<Option<Instant>>> = OnceCell::new();
static COMPONENTS_STAMP: crate::state::TtlCell<()> = crate::state::TtlCell::new();
/// Refresh `state.components` only if the cached refresh timestamp is older
/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components
/// lock.
/// Refresh `state.components` at most once per `COMPONENTS_REFRESH_TTL`.
/// Caller must already hold the components lock.
fn refresh_components_if_stale(components: &mut sysinfo::Components) {
let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None));
let mut last = match lock.lock() {
Ok(g) => g,
Err(_) => return, // Poisoned — skip; values stay as-is until next call
};
let now = Instant::now();
let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL);
if stale {
if COMPONENTS_STAMP.claim_stale(COMPONENTS_REFRESH_TTL) {
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 {
@@ -166,54 +163,6 @@ 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());
@@ -253,10 +202,13 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let swap_used = sys.used_swap();
drop(sys);
// CPU temperature: only refresh sensors if cache is stale
let cpu_temp_c = if cached_temp().is_some() {
cached_temp()
} else if temp_enabled() {
// 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 {
let val = {
let mut components = state.components.lock().await;
refresh_components_if_stale(&mut components);
@@ -273,10 +225,8 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
}
})
};
set_temp(val);
TEMP.set(val);
val
} else {
None
};
// Networks with reusable name cache
@@ -320,47 +270,37 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
cache.infos.clone()
};
// GPUs: if we already determined none exist, short-circuit (no repeated probing)
let gpus = if gpu_enabled() {
if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed)
{
None
} else if cached_gpus().is_some() {
cached_gpus()
} else {
let v = match collect_all_gpus() {
Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None,
Err(_e) => {
#[cfg(feature = "logging")]
warn!("gpu collection failed: {_e}");
None
}
};
// First probe records presence; subsequent calls rely on cache flags.
if !state
.gpu_checked
.swap(true, std::sync::atomic::Ordering::AcqRel)
{
if v.is_some() {
state
.gpu_present
.store(true, std::sync::atomic::Ordering::Release);
} else {
state
.gpu_present
.store(false, std::sync::atomic::Ordering::Release);
}
}
set_gpus(v.clone());
v
}
} else {
// GPUs: negative-probe cache short-circuits GPU-less hosts; otherwise the
// TTL cache answers, and only a stale miss reaches the worker thread.
let gpus = if !gpu_enabled()
|| (state.gpu_checked.load(std::sync::atomic::Ordering::Acquire)
&& !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed))
{
None
} else if let Some(cached) = GPUS.get_fresh(TTL) {
cached
} else {
let v = collect_all_gpus().await;
// First probe records presence; subsequent calls rely on the flags.
if !state
.gpu_checked
.swap(true, std::sync::atomic::Ordering::AcqRel)
{
state
.gpu_present
.store(v.is_some(), std::sync::atomic::Ordering::Release);
}
GPUS.set(v.clone());
v
};
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,
@@ -381,6 +321,48 @@ 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());
@@ -445,19 +427,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
return None;
}
// 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');
let is_partition = is_partition_name(&name);
// Try to find temperature for this disk
let temperature = disk_temps.iter().find_map(|(key, &temp)| {
@@ -491,25 +461,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
for partition in &partitions {
if partition.is_partition {
// 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)
};
let parent_name = parent_disk_name(&partition.name);
// Look up temperature for the PARENT disk, not the partition
// Strip /dev/ prefix if present for matching
@@ -553,21 +505,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// Add partitions after their parent disk
for partition in partitions {
if partition.is_partition {
// 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)
};
let parent_name = parent_disk_name(&partition.name);
// Find where to insert this partition (after its parent)
if let Some(parent_idx) = disks.iter().position(|d| d.name == parent_name) {
@@ -619,15 +557,8 @@ fn read_total_jiffies() -> io::Result<u64> {
#[cfg(target_os = "linux")]
#[inline]
fn read_proc_jiffies(pid: u32) -> Option<u64> {
let path = format!("/proc/{pid}/stat");
let s = fs::read_to_string(path).ok()?;
// Find the right parenthesis that terminates comm; everything after is space-separated fields starting at "state"
let rpar = s.rfind(')')?;
let after = s.get(rpar + 2..)?; // skip ") "
let mut it = after.split_whitespace();
// utime (14th field) is offset 11 from "state", stime (15th) is next
let utime = it.nth(11)?.parse::<u64>().ok()?;
let stime = it.next()?.parse::<u64>().ok()?;
let s = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (utime, stime) = procstat::utime_stime(&s)?;
Some(utime.saturating_add(stime))
}
@@ -657,9 +588,17 @@ 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,
false,
true,
ProcessRefreshKind::nothing().with_memory().without_tasks(),
);
@@ -788,8 +727,11 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
//JW too complicated. simplify to remove strange behavior
// For active systems, get accurate CPU metrics
sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind.with_cpu());
// 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());
// } else {
// // For idle systems, just get basic process info
@@ -818,9 +760,12 @@ 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
let raw = p.cpu_usage(); // This is per-core percentage
let total_cpu = raw.clamp(0.0, 100.0) / cpu_count;
// 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);
proc_cache.reusable_vec.push(ProcessInfo {
pid,
@@ -843,6 +788,13 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let cache_cleanup_threshold = name_cache_cleanup_threshold();
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
// `now` is only consumed by the `tracing::debug!` below, so gate
// the binding with the same cfg as its consumer. Without this,
// a non-logging build (the default) emits an unused-variable
// warning. The Linux CI doesn't catch it because this block lives
// in the `#[cfg(not(target_os = "linux"))]` collect_processes_all —
// the warning only surfaces on the Windows build matrix.
#[cfg(feature = "logging")]
let now = std::time::Instant::now();
proc_cache
.names
@@ -977,15 +929,8 @@ 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()?;
// 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()
// Post-comm field 1 is ppid.
procstat::field(&stat, 1)?.parse::<u32>().ok()
}
/// Collect process information from /proc files
@@ -1028,16 +973,9 @@ fn collect_process_info_from_proc(
let thread_count = st.threads;
let status = proc_state_label(st.state_ch).to_string();
// Read start time from stat — comm-safe via rfind(')').
// starttime is post-comm field 19.
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
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()?
procstat::field(&stat, 19)?.parse::<u64>().ok()?
} else {
0
};
@@ -1072,7 +1010,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_ms(pid);
let (cpu_time_user, cpu_time_system) = get_cpu_times_us(pid);
Some(DetailedProcessInfo {
pid,
@@ -1185,18 +1123,7 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
continue;
};
// 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()
let status = procstat::field(&stat_content, 0)
.and_then(|s| s.chars().next())
.map(|c| match c {
'R' => "Running",
@@ -1211,14 +1138,9 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.unwrap_or("Unknown")
.to_string();
// 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;
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;
threads.push(crate::types::ThreadInfo {
tid,
@@ -1250,10 +1172,17 @@ 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_disk_usage()
.with_cmd(sysinfo::UpdateKind::OnlyIfNotSet)
.with_exe(sysinfo::UpdateKind::OnlyIfNotSet)
.with_cwd(sysinfo::UpdateKind::OnlyIfNotSet),
);
let process = system
@@ -1343,7 +1272,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_ms(pid);
let (cpu_time_user, cpu_time_system) = get_cpu_times_us(pid);
// Now construct the detailed info without holding the lock
let detailed_info = DetailedProcessInfo {
@@ -1377,9 +1306,26 @@ pub async fn collect_process_metrics(
})
}
/// Collect journal entries for a specific process
pub fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String> {
let output = Command::new("journalctl")
/// 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")
.args([
&format!("_PID={pid}"),
"--output=json",
@@ -1387,6 +1333,7 @@ pub 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() {
@@ -1408,27 +1355,14 @@ pub 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}"))?;
// Extract relevant fields
let timestamp_str = json
// __REALTIME_TIMESTAMP is epoch microseconds as a string.
let timestamp_us = json
.get("__REALTIME_TIMESTAMP")
.and_then(|v| v.as_str())
.unwrap_or("0");
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
// 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 timestamp = format_journal_timestamp(timestamp_us);
let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) {
Some("0") => LogLevel::Emergency,
@@ -1475,6 +1409,7 @@ pub fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String> {
entries.push(JournalEntry {
timestamp,
timestamp_us,
priority,
message,
unit,
@@ -1486,7 +1421,26 @@ pub fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String> {
}
// Sort by timestamp (newest first)
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
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
};
let response_timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1500,6 +1454,70 @@ pub 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}");
}
}
+50 -1
View File
@@ -74,6 +74,55 @@ 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>,
@@ -87,7 +136,7 @@ impl<T> Default for CacheEntry<T> {
}
impl<T> CacheEntry<T> {
pub fn new() -> Self {
pub const fn new() -> Self {
Self {
at: None,
value: None,
+21 -1
View File
@@ -24,6 +24,17 @@ 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())?;
@@ -79,7 +90,16 @@ 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())?;
let mut k = fs::File::create(&key_path)?;
// 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)?;
k.write_all(key_pem.as_bytes())?;
println!(
+11 -1
View File
@@ -30,6 +30,11 @@ 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,
@@ -93,7 +98,8 @@ pub struct ProcessMetricsResponse {
#[derive(Debug, Clone, Serialize)]
pub struct JournalEntry {
pub timestamp: String, // ISO 8601 formatted timestamp
pub timestamp: String, // RFC 3339 UTC, for display
pub timestamp_us: u64, // epoch microseconds, for sorting/formatting
pub priority: LogLevel,
pub message: String,
pub unit: Option<String>, // systemd unit name
@@ -120,5 +126,9 @@ 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
}
+79 -75
View File
@@ -16,9 +16,7 @@ use crate::metrics::{collect_disks, collect_fast_metrics, collect_processes_all}
use crate::proto::pb;
use crate::state::AppState;
// Compression threshold based on typical payload size
// Temporarily increased for testing - revert to 768 for production
//const COMPRESSION_THRESHOLD: usize = 50_000;
// Payloads at or below this many bytes are sent as-is; larger ones are gzipped.
const COMPRESSION_THRESHOLD: usize = 768;
// Reusable buffer for compression to avoid allocations
@@ -52,6 +50,66 @@ 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
@@ -126,84 +184,30 @@ 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>()
{
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;
}
}
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;
}
}
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>()
{
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;
}
}
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;
}
}
Message::Close(_) => break,
+1
View File
@@ -42,6 +42,7 @@ async fn test_process_cache_ttl() {
};
let journal_response = JournalResponse {
notice: None,
entries: vec![],
total_count: 0,
truncated: false,
+18 -2
View File
@@ -33,7 +33,7 @@ async fn test_collect_journal_entries_self() {
// Test collecting journal entries for our own process
let pid = process::id();
match collect_journal_entries(pid) {
match collect_journal_entries(pid).await {
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) {
match collect_journal_entries(invalid_pid).await {
Ok(response) => {
println!(
"✓ Journal query completed for invalid PID {} (empty result expected): {} entries",
@@ -87,3 +87,19 @@ async fn test_collect_journal_entries_invalid_pid() {
}
}
}
/// The Command & Details pane went blank when the minimal-refresh
/// optimization dropped cmd from the detail endpoint's refresh kind.
#[tokio::test]
async fn test_process_metrics_include_command() {
let state = AppState::new();
let pid = std::process::id();
let resp = collect_process_metrics(pid, &state)
.await
.expect("collect self");
assert!(
!resp.process.command.is_empty(),
"command should not be empty for self (cmdline is always readable)"
);
println!("command = {}", resp.process.command);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "socktop_connector"
version = "1.50.0"
version = "1.60.2"
edition = "2024"
license = "MIT"
description = "WebSocket connector library for socktop agent communication"
+8 -4
View File
@@ -1,8 +1,12 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set the protoc binary path to use the vendored version for CI compatibility
// SAFETY: We're only setting PROTOC in a build script environment, which is safe
unsafe {
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
// Vendored protoc for reproducible builds where available. It ships no
// riscv64 binary, so on such hosts leave $PROTOC / PATH lookup to
// prost-build (apt: protobuf-compiler).
// SAFETY: We're only setting PROTOC in a build script environment.
if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() {
unsafe {
std::env::set_var("PROTOC", protoc);
}
}
prost_build::compile_protos(&["processes.proto"], &["."])?;
File diff suppressed because it is too large Load Diff
+152 -44
View File
@@ -6,7 +6,7 @@ use crate::error::{ConnectorError, Result};
use std::io::BufReader;
use std::sync::Arc;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use url::Url;
#[cfg(feature = "tls")]
@@ -15,7 +15,7 @@ use {
rustls::{
DigitallySignedStruct, RootCertStore, SignatureScheme,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
crypto::ring,
crypto::{WebPkiSupportedAlgorithms, ring},
pki_types::{CertificateDer, ServerName, UnixTime},
},
rustls_pemfile::Item,
@@ -64,7 +64,8 @@ async fn connect_without_ca_and_config(url: &str, config: &ConnectorConfig) -> R
);
}
let (ws, _) = connect_async(req).await?;
// `true` disables Nagle: small request/response frames, latency matters.
let (ws, _) = tokio_tungstenite::connect_async_with_config(req, None, true).await?;
Ok(ws)
}
@@ -85,7 +86,12 @@ async fn connect_with_ca_and_config(
der_certs.push(der);
}
}
root.add_parsable_certificates(der_certs);
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());
let mut cfg = ClientConfig::builder()
.with_root_certificates(root)
@@ -114,57 +120,89 @@ async fn connect_with_ca_and_config(
}
if !config.verify_hostname {
#[derive(Debug)]
struct NoVerify;
impl ServerCertVerifier for NoVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
cfg.dangerous().set_certificate_verifier(Arc::new(NoVerify));
// Note: hostname verification disabled (default). Set SOCKTOP_VERIFY_NAME=1 to enable strict SAN checking.
// 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,
config.verify_hostname,
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 {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
if self.pinned.iter().any(|p| p == end_entity) {
Ok(ServerCertVerified::assertion())
} else {
Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algorithms)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.algorithms.supported_schemes()
}
}
#[cfg(not(feature = "tls"))]
async fn connect_with_ca_and_config(
_url: &str,
@@ -181,3 +219,73 @@ async fn connect_with_ca_and_config(
fn ensure_crypto_provider() {
let _ = ring::default_provider().install_default();
}
#[cfg(all(test, feature = "tls"))]
mod tests {
use super::*;
fn verifier(pinned: &[&[u8]]) -> PinnedCertVerifier {
let _ = ring::default_provider().install_default();
PinnedCertVerifier::new(
pinned
.iter()
.map(|b| CertificateDer::from(b.to_vec()))
.collect(),
)
}
fn verify(v: &PinnedCertVerifier, presented: &[u8]) -> bool {
v.verify_server_cert(
&CertificateDer::from(presented.to_vec()),
&[],
&ServerName::try_from("agent.test").unwrap(),
&[],
UnixTime::now(),
)
.is_ok()
}
/// The regression this verifier exists to prevent: the old NoVerify
/// accepted ANY certificate when hostname verification was off, so the
/// documented pinning was a no-op. The pinned cert must be accepted and
/// every other cert rejected.
#[test]
fn only_the_pinned_certificate_is_accepted() {
let v = verifier(&[b"pinned-cert-der"]);
assert!(verify(&v, b"pinned-cert-der"));
assert!(!verify(&v, b"some-mitm-cert"), "unpinned cert accepted");
assert!(!verify(&v, b""), "empty cert accepted");
}
/// A --tls-ca file may hold several certs (e.g. during rotation); any of
/// them must satisfy the pin.
#[test]
fn any_cert_in_a_multi_cert_pem_satisfies_the_pin() {
let v = verifier(&[b"old-cert", b"new-cert"]);
assert!(verify(&v, b"old-cert"));
assert!(verify(&v, b"new-cert"));
assert!(!verify(&v, b"third-party-cert"));
}
/// Fail closed: an empty pin set must reject everything rather than
/// falling back to accept-all.
#[test]
fn an_empty_pin_set_rejects_all_certificates() {
let v = verifier(&[]);
assert!(!verify(&v, b"anything"));
}
/// Signature schemes come from the real provider, not a hardcoded list —
/// an agent using e.g. RSA-PKCS1 must still be able to handshake.
#[test]
fn signature_schemes_come_from_the_provider() {
let v = verifier(&[b"x"]);
let schemes = v.supported_verify_schemes();
assert!(
schemes.len() > 3,
"suspiciously short scheme list: {schemes:?}"
);
assert!(schemes.contains(&SignatureScheme::RSA_PKCS1_SHA256));
assert!(schemes.contains(&SignatureScheme::ECDSA_NISTP256_SHA256));
}
}
+8
View File
@@ -54,6 +54,10 @@ 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,
@@ -147,6 +151,10 @@ pub struct JournalResponse {
pub entries: Vec<JournalEntry>,
pub total_count: u32,
pub truncated: bool,
/// Agent-side explanation for an empty result (journal access limits).
/// Absent on agents older than 1.60.
#[serde(default)]
pub notice: Option<String>,
pub cached_at: u64, // Unix timestamp when this data was cached
}
+1
View File
@@ -46,6 +46,7 @@ pub async fn send_request_and_wait(
// For now, return a placeholder metrics response indicating binary data received
// TODO: Implement proper protobuf decoding for binary data
let placeholder_metrics = Metrics {
sampled_at_ms: None,
cpu_total: 0.0,
cpu_per_core: vec![0.0],
mem_total: 0,
+3 -5
View File
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytes"
version = "1.10.1"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
@@ -475,9 +475,7 @@ dependencies = [
[[package]]
name = "socktop_connector"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a63dadaa5105df11b0684759a829012257d48e72a469cc554c0cf4394605f5a"
version = "1.51.0"
dependencies = [
"flate2",
"js-sys",
+2 -2
View File
@@ -10,8 +10,8 @@ edition = "2021"
crate-type = ["cdylib"]
[dependencies]
# Use WASM features for WebSocket connectivity (published version)
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
# Use WASM features for WebSocket connectivity (in-repo connector via path)
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wasm-bindgen = "0.2"
View File
+4384
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,6 +3,9 @@ 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"]
@@ -10,7 +13,7 @@ crate-type = ["cdylib"]
zellij-tile = "0.40.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
socktop_connector = { version = "0.1.5", default-features = false, features = ["wasm"] }
socktop_connector = { path = "../socktop_connector", default-features = false, features = ["wasm"] }
futures = "0.3"
[dependencies.chrono]