Compare commits

..

194 Commits

Author SHA1 Message Date
jason b635f5d7f4 feature - add error modal support and retry
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-09-15 10:16:47 -07:00
jason b4ed036357 bump version
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-09-10 13:05:50 -07:00
jason ec0e409488 fix clippy warnings. 2025-09-10 11:43:12 -07:00
jason 08f248c696 Housekeeping and QOL
non functional update:

- refactor stream of consciousness into separate files.
- combine equivelent functions used in networking and wasm features.
- cleanups and version bumps.
2025-09-10 10:39:21 -07:00
jason cea133b7da show actual metrics 2025-09-10 09:32:00 -07:00
jason e679896ca0 Add AI generated zliij plugin scafold 2025-09-09 15:51:44 -07:00
jason 5e5fde190a add screenshot 2025-09-09 13:49:06 -07:00
jason 8286d21a2a Merge branch 'feature/extract-socktop-connector' of https://github.com/jasonwitty/socktop into feature/extract-socktop-connector 2025-09-09 13:43:48 -07:00
jason b91fc7b016 allow user to easily override location with text entry. 2025-09-09 13:43:45 -07:00
jason f936767835 Update README.md 2025-09-09 02:57:32 -07:00
jason 5f2777cdb2 Update README.md 2025-09-09 02:53:50 -07:00
jason 49164da105 docs: Complete WASM documentation update - reflect full networking capabilities 2025-09-09 02:42:12 -07:00
jason 22c1f80e70 docs(connector): update README to reflect full WASM support 2025-09-09 02:38:35 -07:00
jason a486225008 fix: formatting from cargo fmt 2025-09-09 02:32:20 -07:00
jason d97f7507e8 feat(connector): implement gzipped protobuf support for WASM and fix all warnings 2025-09-09 02:30:16 -07:00
jason e4186a7ec0 WASM compatibilty 2025-09-08 12:29:03 -07:00
jason f59c28d966 WASM compatibility update
Related to: Usage as a lib #8

1.  feature gating of TLS and other features not supported with WASM.
2. updated documentation.
3. creation of AI slop WASM example for verification.
2025-09-08 12:28:44 -07:00
jason 06cd6d0c82 Reference: Usage as a lib #8
- Implement protocol versioning
- migrate to thisError
- general error handling improvements in socktop_connector lib
- improve documentation
- increment  version
2025-09-07 18:55:23 -07:00
jason ffc246b705 Add WASM compatibility documentation and minimal tokio features 2025-09-04 14:49:39 -07:00
jason cd2816915d cargo fmt 2025-09-04 06:19:16 -07:00
jason 7cd5941434 Add continuous monitoring examples to documentation 2025-09-04 06:17:23 -07:00
jason 76c7fe1d6f Fix CI: Update test path for WebSocket integration test 2025-09-04 06:11:59 -07:00
jason eed04f1d5c Fix remaining clippy warnings in socktop_agent 2025-09-04 06:04:57 -07:00
jason 764c25846f Fix clippy warnings: collapse nested if statements using let-else patterns 2025-09-04 05:58:17 -07:00
jason a9bf4208ab cargo fmt 2025-09-04 05:53:59 -07:00
jason 9c1416eabf Fix build script to use vendored protoc binary for CI 2025-09-04 05:52:57 -07:00
jason e7350f8908 Update Cargo.lock with protoc-bin-vendored dependency 2025-09-04 05:50:17 -07:00
jason 2647b611d2 Fix build script to use protoc-bin-vendored for CI compatibility 2025-09-04 05:48:02 -07:00
jason a359f17367 fix for failed CI build 2025-09-04 05:45:13 -07:00
jason d93b7aca5a remove invalid slug 2025-09-04 05:41:59 -07:00
jason e51054811c Refactor for additional socktop connector library
- socktop connector allows you to communicate with socktop agent directly from you code without needing to implement the agent API directly.
- will also be used for non tui implementation of "socktop collector" in the future.
- moved to rust 2024 to take advantage of some new features that helped with refactor.
- fixed everything that exploded with update.
- added rust docs for lib.
2025-09-04 05:30:25 -07:00
jason 622767a605 Merge pull request #7 from jasonwitty/6-accessibility-cross-compile-guide
re: Accessibility: Cross-compile guide
2025-08-30 02:00:13 -07:00
jason 0c5a1d7553 update readme 2025-08-30 01:59:25 -07:00
jason 0bd709d2a7 slipsteam note for rpi users on kernel version 2025-08-29 11:28:59 -07:00
jason 31f5f9ce76 re: Accessibility: Cross-compile guide 2025-08-29 11:23:41 -07:00
jason df2308e6e9 code optimizations to reduce cpu usage of agent on all platforms and additional unit test. 2025-08-28 16:03:05 -07:00
jason 7592709a43 clamp then divide by cores for more accurate statistics 2025-08-28 13:11:48 -07:00
jason 61fe1cc38e socktop_agent: bump version to 1.40.65 2025-08-28 12:27:48 -07:00
jason eed346abb6 scripts: add publish_socktop_agent.sh job 2025-08-28 12:07:03 -07:00
jason ab3bb33711 socktop_agent: bump version to 1.40.64 2025-08-28 12:03:45 -07:00
jason 7caf2f4bfb remove ununused var 2025-08-27 17:15:35 -07:00
jason b249c7ba99 Update metrics.rs 2025-08-27 16:55:12 -07:00
jason f0858525e8 fix for macos defect where processes less than .01% were being filtered 2025-08-27 16:55:09 -07:00
jason 2fe005ed90 ProcessesToUpdate::All enum 2025-08-27 16:20:55 -07:00
jason ca6a5cbdfa use ProcessesToUpdate::All ENUM 2025-08-27 16:20:41 -07:00
jason 56301d61fd fixes for non linux compilation issues. 2025-08-27 16:11:17 -07:00
jason 55e5c708fe MACOS / NON LINUX metrics optimizations. 2025-08-27 16:00:29 -07:00
jason 2d17cf1598 additional optimizations for macos 2025-08-27 15:05:38 -07:00
jason 353c08c35e increment version and macos performance 2025-08-26 12:14:32 -07:00
jason f13ea45360 increment version 2025-08-26 10:57:01 -07:00
jason 8ce00a5dad non linux optimizations for macbook 2025-08-26 10:14:14 -07:00
jason f37b8d9ff4 chore(agent): fix clippy unused mut on non-linux process list 2025-08-26 00:22:29 -07:00
jason 322981ada7 cargo fmt and version bump 2025-08-26 00:20:52 -07:00
jason 3394beab67 chore: make pre-commit resilient when cargo absent 2025-08-26 00:18:10 -07:00
jason c9ebea92f5 perf(agent,non-linux): enable CPU core normalization by default; tighter scaling threshold 2025-08-25 23:50:26 -07:00
jason e2dc5e8ac9 perf(agent,non-linux): add per-process CPU scaling heuristic to reduce overreporting 2025-08-25 23:47:31 -07:00
jason beddba0072 perf(agent,non-linux): reduce process collection overhead; configurable CPU sample delay 2025-08-25 23:44:28 -07:00
jason cacc4cba9f cargo fmt 2025-08-25 23:24:50 -07:00
jason 66270c16b7 perf(agent): windows/mac cpu collection tweak; add optional normalization; silence linux dead_code 2025-08-25 22:59:07 -07:00
jason 00d5777d05 Merge branch 'master' of https://github.com/jasonwitty/socktop 2025-08-25 22:35:02 -07:00
jason f62b5274d2 optimize non linux metrics collection 2025-08-25 22:35:01 -07:00
jason bbbe35111a Update README.md 2025-08-25 01:33:40 -07:00
jason a4356b5ece update readme with animated demo 2025-08-24 20:35:56 -07:00
jason b6e656738b chore(release): bump to 1.40.0
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-24 18:56:40 -07:00
jason f83cb07d57 Release candidate 1.4
increment version, support version flag on socktop
2025-08-24 18:03:45 -07:00
jason 7697c7dc2b docs: add per-crate README.md and link via Cargo.toml readme field 2025-08-24 17:56:22 -07:00
jason 1043fffc8d Merge pull request #5 from jasonwitty/feature/housekeeping
Feature/housekeeping
2025-08-24 17:46:26 -07:00
jason ce59dd9dfe chore(agent): fix clippy needless_return for non-linux process collection
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-24 12:52:56 -07:00
jason 8d48fa4c3b increment version 2025-08-24 12:47:49 -07:00
jason 51e702368e cargo fmt 2025-08-24 12:40:35 -07:00
jason 85f9a44e46 perf(agent): add hostname + TTL caches (metrics/disks/processes) and reuse sys for processes 2025-08-24 12:38:32 -07:00
jason b2468a5936 refactor(agent): remove unused background sampler infrastructure (request-driven only) 2025-08-24 12:29:23 -07:00
jason 8de5943f34 test(agent): move inline port parsing test to tests/port_parse.rs 2025-08-24 12:15:32 -07:00
jason e624751f56 chore: align sysinfo to 0.37 across workspace 2025-08-24 12:11:38 -07:00
jason 8bd1af7a27 chore: remove unused deps (thiserror, chrono, futures, nvml-wrapper, tungstenite, bytes, prost-types) 2025-08-24 12:08:52 -07:00
jason 5c32d15156 first run messaging.
display welcome message on first run when a profile file is not created and remote server not specified.
2025-08-24 12:01:17 -07:00
jason 471d547b5d cargo fmt and increment version 2025-08-23 02:18:09 -07:00
jason d3aff590bc client: fully disable hostname verification by custom ServerCertVerifier unless --verify-hostname used 2025-08-23 02:15:45 -07:00
jason 47910725a8 increment version 2025-08-22 22:48:41 -07:00
jason a8e3f4ef26 docs: describe --verify-hostname flag and default relaxed SAN behavior 2025-08-22 22:39:18 -07:00
jason fab1e5a104 client: default skip hostname verification; add --verify-hostname to enable 2025-08-22 22:39:06 -07:00
jason d0455611d5 cargo fmt 2025-08-22 14:09:02 -07:00
jason 4c45b85c98 fix san and increment version 2025-08-22 14:07:41 -07:00
jason d9fdc31e8f docs: document SOCKTOP_AGENT_EXTRA_SANS for additional certificate SANs 2025-08-22 13:48:07 -07:00
jason dc9aa4c026 agent: support extra SANs via SOCKTOP_AGENT_EXTRA_SANS env var 2025-08-22 13:47:51 -07:00
jason c2e91bd20c docs: document TLS cert expiry and manual regeneration procedure 2025-08-22 12:57:48 -07:00
jason 25229d6b03 cargo fmt 2025-08-22 12:49:12 -07:00
jason 290e2a8fb2 fix for expired certificate 2025-08-22 12:46:01 -07:00
jason 30d263c71e agent: dynamic self-signed cert validity (~397d from now) to avoid immediate expiry 2025-08-22 12:43:48 -07:00
jason 9b177f3206 cargo fmt 2025-08-22 11:55:41 -07:00
jason 8a6ae3fcd7 increment version 2025-08-22 11:53:27 -07:00
jason 5b8ec7efc1 agent: add --version / -V flag 2025-08-22 11:52:51 -07:00
jason 155c420a1a cargo fmt all check ins 2025-08-22 11:10:11 -07:00
jason d3fa55e572 chore: ignore .vscode and remove from repo 2025-08-22 11:09:33 -07:00
jason faf2861b29 cargo fmt 2025-08-22 10:53:47 -07:00
jason 59432ab1d3 agent: fix rcgen usage for self-signed cert generation 2025-08-22 10:48:01 -07:00
jason d1c8a64418 agent: replace openssl self-signed cert generation with rcgen (pure Rust) 2025-08-22 10:46:29 -07:00
jason 8def4b2d06 Publish: include proto in each crate and fix build.rs paths 2025-08-22 10:44:56 -07:00
jason a42ca71a9f update version prior to cargo publish 2025-08-22 10:41:17 -07:00
jason 9f675fa804 Merge pull request #4 from jasonwitty/feature/connection-profiles
Feature/connection profiles
2025-08-22 09:31:28 -07:00
jason 3ac03c07ba enable GPU polling only when GPU is present 2025-08-22 09:27:05 -07:00
jason e53d0ab98d Add TLS / Token, polling interval indicators.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-21 17:38:26 -07:00
jason 2ca51adc61 tui: refine header icons (crossed TLS when disabled, spacing fix) 2025-08-21 17:28:21 -07:00
jason 67ecf36883 feat(tui): header shows TLS/token status and polling intervals 2025-08-21 17:24:41 -07:00
jason 9a35306340 cargo fmt 2025-08-21 16:19:49 -07:00
jason a4bb6f170a feat(client): configurable metrics/process intervals with profile persistence; docs updated 2025-08-21 16:18:41 -07:00
jason 384953d5d5 Merge pull request #3 from jasonwitty/feature/connection-profiles
Feature/connection profiles
2025-08-21 16:17:49 -07:00
jason f9114426cc add unit tests for profile creation and update readme 2025-08-21 14:42:15 -07:00
jason 8ee2a03a2c chore(client): clean up demo mode integration and add stop log line 2025-08-21 13:55:02 -07:00
jason 0275b1871d cargo fmt 2025-08-21 13:49:36 -07:00
jason 9491dc50a8 feat(client): demo mode (--demo or select demo) auto-spawns local agent on 3231 2025-08-21 13:47:28 -07:00
jason e7eb3e6557 cargo fmt 2025-08-21 13:18:36 -07:00
jason a596acfb72 chore(client): refactor profile overwrite logic to satisfy clippy 2025-08-21 13:17:53 -07:00
jason b727e54589 feat(client): prompt for URL/CA when specifying a new profile name 2025-08-21 12:56:11 -07:00
jason 2af08c455a fix(client): correct profile overwrite prompt logic (only save on confirm or --save) 2025-08-21 12:48:53 -07:00
jason d049846564 docs: add connection profiles section to README 2025-08-21 12:41:46 -07:00
jason 97308f9d15 feat(client): connection profiles (--profile/-P, --save) with JSON persistence 2025-08-21 12:39:21 -07:00
jason 4cef273e57 Merge pull request #2 from jasonwitty/feature/protobuf-processes
Feature/protobuf processes
2025-08-21 11:50:27 -07:00
jason 660474a6ce ci cleanup
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-20 20:36:49 -07:00
jason 93dd14967d try/fix windows again ! 2025-08-20 16:32:51 -07:00
jason 923a3872fe add logging to help debug windows problems 2025-08-20 15:47:28 -07:00
jason 5f10e34341 windows try/fix 2025-08-20 15:20:07 -07:00
jason b80d322650 cargo fmt 2025-08-20 11:29:22 -07:00
jason fff386f9d5 fixing windows build problems. i hate windows !
Agent:
Added GET /healthz that returns 200 immediately.
File: main.rs (router now includes /healthz).
CI workflow:
Start agent from target/release on both OSes.
Set SOCKTOP_ENABLE_SSL=0 explicitly.
Ubuntu: wait on curl http://127.0.0.1:3000/healthz (60s), log tail and ss/netstat on failure.
Windows: wait on Invoke-WebRequest to /healthz (60s), capture stdout/stderr, print netstat on failure.
File: .github/workflows/ci.yml.
2025-08-20 11:26:09 -07:00
jason 93f4e1feea fix windows build after ssl feature and optimize build 2025-08-20 10:24:24 -07:00
jason 97255b42fb fix windows build 2025-08-20 00:14:21 -07:00
jason 554a2c349f protobuff Process list
BREAKING: Process list over WS is now Protocol Buffers; client required.
Agent: returns all processes (no server-side top-k); large payloads gzip-compressed.
Client: decodes protobuf (gz/raw), moves sorting/pagination to TUI.
Build: add prost/prost-build with vendored protoc; enable thin LTO, panic=abort, strip symbols.
Cleanup: cfg-gate Linux-only code; fix Clippy across platforms; tests updated (ws probe TLS CA).
2025-08-19 23:24:36 -07:00
jason 10501168c5 clippy fixes. 2025-08-19 15:52:30 -07:00
jason d346c61c28 Merge pull request #1 from jasonwitty/feature/wss-selfsigned
SSL Support
2025-08-19 15:33:50 -07:00
jason 7652095109 cargo fmt
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-19 15:33:11 -07:00
jason 6b58ac67f6 Merge branch 'master' into feature/wss-selfsigned 2025-08-19 15:31:10 -07:00
jason 3ad1d52fe2 fix windows build.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Gate Linux-specific imports and fields to avoid Windows dead-code/unused warnings.
Ensure Linux-only proc CPU tracker is not referenced on non-Linux builds.
2025-08-16 17:42:18 -07:00
jason 2e8cc24e81 remove dead code and increment version 2025-08-16 17:31:32 -07:00
jason 36e73fd9ed cargo fmt 2025-08-16 01:25:03 -07:00
jason 3d14e4a370 SSL Support
Add WSS/TLS (self‑signed) with client cert pinning; auto ws→wss on --tls-ca/-t; add -p/-t flags; harden TLS test; fix clippy; update README.

feat: WSS/TLS support (self‑signed + pinning), auto ws→wss when CA provided, new -p/-t flags; tests + clippy cleanup; docs updated.

Add TLS: self‑signed certs on agent, client pin via --tls-ca/-t (auto‑upgrade to wss), CLI/tests/README updates, clippy fixes.

12 files changed
Cargo.toml
README.md
Cargo.tomlsocktop_agent
main.rssocktop_agent/src
tls.rssocktop_agent/src
cli_args.rssocktop_agent/tests
Add Context...
README.md
2025-08-16 01:23:20 -07:00
jason c6b8c9c905 patch for macbook compatibility issues. 2025-08-15 19:21:34 -07:00
jason f980b6ace9 Update README.md 2025-08-13 20:17:36 -07:00
jason 6a27280f8d Merge branch 'master' of https://github.com/jasonwitty/socktop
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-12 17:12:38 -07:00
jason 38b0cdcf0e update screenshot 2025-08-12 17:12:27 -07:00
jason 268627ed63 Update README.md 2025-08-12 17:10:12 -07:00
jason 55a663cf7a Update README.md 2025-08-12 17:09:31 -07:00
jason 8b76ccb742 Merge branch 'master' of https://github.com/jasonwitty/socktop 2025-08-12 17:07:36 -07:00
jason d0f6cb0e70 fix screenshot 2025-08-12 17:07:25 -07:00
jason 56ebe6bbab Rename tmux_4_rpis_v2jpg to tmux_4_rpis_v2.jpg 2025-08-12 17:05:40 -07:00
jason dc90de7ff1 update readme and add new screenshots 2025-08-12 17:02:01 -07:00
jason 319f47eb73 Update README.md 2025-08-12 16:48:35 -07:00
jason fd2889ccca Update cargo versions in prep for publish. 2025-08-12 16:12:08 -07:00
jason 0859f50897 multiple feature and performance improvements (see description)
Here are concise release notes you can paste into your GitHub release.

Release notes — 2025-08-12

Highlights

Agent back to near-zero CPU when idle (request-driven, no background samplers).
Accurate per-process CPU% via /proc deltas; only top-level processes (threads hidden).
TUI: processes pane gets scrollbar, click-to-sort (CPU% or Mem) with indicator, stable total count.
Network panes made taller; disks slightly reduced.
README revamped: rustup prereqs, crates.io install, update/systemd instructions.
Clippy cleanups across agent and client.
Agent

Reverted precompressed caches and background samplers; WebSocket path is request-driven again.
Ensured on-demand gzip for larger replies; no per-request overhead when small.
Processes: switched to refresh_processes_specifics with ProcessRefreshKind::everything().without_tasks() to exclude threads.
Per-process CPU% now computed from /proc jiffies deltas using a small ProcCpuTracker (fixes “always 0%”/scaling issues).
Optional metrics and light caching:
CPU temp and GPU metrics gated by env (SOCKTOP_AGENT_TEMP=0, SOCKTOP_AGENT_GPU=0).
Tiny TTL caches via once_cell to avoid rescanning sensors every tick.
Dependencies: added once_cell = "1.19".
No API changes to WS endpoints.
Client (TUI)

Processes pane:
Scrollbar (mouse wheel, drag; keyboard arrows/PageUp/PageDown/Home/End).
Click header to sort by CPU% or Mem; dot indicator on active column.
Preserves process_count across fast metrics updates to avoid flicker.
UI/theme:
Shared scrollbar colors moved to ui/theme.rs; both CPU and Processes reuse them.
Cached pane rect to fix input handling; removed unused vars.
Layout: network download/upload get more vertical space; disks shrink slightly.
Clippy fixes: derive Default for ProcSortBy; style/import cleanups.
Docs

README: added rustup install steps (with proper shell reload), install via cargo install socktop and cargo install socktop_agent, and a clear Updating section (systemd service steps included).
Features list updated; roadmap marks independent cadences as done.
Upgrade notes

Agent: cargo install socktop_agent --force, then restart your systemd service; if unit changed, systemctl daemon-reload.
TUI: cargo install socktop --force.
Optional envs to trim overhead: SOCKTOP_AGENT_GPU=0, SOCKTOP_AGENT_TEMP=0.
No config or API breaking changes.
2025-08-12 15:52:46 -07:00
jason 5c002f0b2b Update README.md 2025-08-12 00:01:21 -07:00
jason 5a824c2098 only run apt command if build box is linux 2025-08-11 23:49:45 -07:00
jason ffb381e40e install deps libdrm-dev libdrm-amdgpu1 on build box 2025-08-11 23:46:08 -07:00
jason 0a70d7fd39 rustfmt 2025-08-11 23:41:23 -07:00
jason 8d81ee1f7e clippy clean ups 2025-08-11 23:37:50 -07:00
jason 1e248306a6 clippy clean up multiple files 2025-08-11 23:27:18 -07:00
jason 7cd6a6e0a1 gpu clippy cleanup 2025-08-11 23:08:35 -07:00
jason 8f58feffbe clippy clean up app.rs 2025-08-11 22:59:36 -07:00
jason 5790ef753b clippy cleanup 2025-08-11 22:56:35 -07:00
jason 9a49fd6b24 clippy cleanup main 2025-08-11 22:50:15 -07:00
jason b35e431200 clippy clean up 2025-08-11 22:46:58 -07:00
jason 4b52382326 another clippy cleanup 2025-08-11 22:45:24 -07:00
jason 11506699e3 clippy clean up 2025-08-11 22:44:43 -07:00
jason 4c6c707dd0 struct cleanup 2025-08-11 22:41:12 -07:00
jason d69a4104fc performance improvements and formatting cleanup 2025-08-11 22:37:46 -07:00
jason c3f81eef25 clippy code clean up
clippy code clean up
2025-08-11 20:47:21 -07:00
jason 05276f9eea fix clippy issues causing workflow failure. 2025-08-11 14:39:19 -07:00
jason 0105b29bfc fix additional clippy warnings. 2025-08-11 14:34:45 -07:00
jason 0cbba6b290 fix clippy format warnings 2025-08-11 14:30:41 -07:00
jason 250f7bf93a remove unused vendor field. 2025-08-11 14:25:58 -07:00
jason 4efeb3b60f update screenshots 2025-08-11 14:17:04 -07:00
jason d20061614c update default screenshot 2025-08-11 13:47:23 -07:00
jason 289c9f7ebe add cargo install documentation and enable release artifacts in actions
add cargo install documentation and enable release artifacts in actions
2025-08-11 13:38:26 -07:00
jason bdfa74be54 add license for socktop_agent 2025-08-11 12:10:40 -07:00
jason 6efdc35b19 add license so we can publish to cargo 2025-08-11 12:09:14 -07:00
jason 20278d67f1 new feature: gpu support 2025-08-11 12:04:55 -07:00
jason a4f69a5f7d Add scrollbar to CPU per core area. 2025-08-10 23:32:44 -07:00
jason 9b1643afac add screenshot and information abotu running as service. 2025-08-09 22:40:44 -07:00
jason a9086eac84 Create 14900KS_arch_alacritty.jpg 2025-08-08 21:27:27 -07:00
jason 4bd6744df4 fix clippy command 2025-08-08 17:33:30 -07:00
jason 274a485f8d fmt: apply rustfmt 2025-08-08 17:25:15 -07:00
jason 747aef0005 Merge branch 'master' of https://github.com/jasonwitty/socktop 2025-08-08 16:51:22 -07:00
jason a0e17c6e22 fix build warning and add ghostty screenshot 2025-08-08 16:46:46 -07:00
jason 19973c24d8 Update README.md 2025-08-08 14:15:28 -07:00
jason 9a4c8b703e Update README.md 2025-08-08 13:24:32 -07:00
jason 968a25eaf1 Update README.md 2025-08-08 13:22:57 -07:00
jason 13fb22c7ee add rustup installer line for lazy people 2025-08-08 13:14:33 -07:00
jason 6c867774f7 correct path in docs 2025-08-08 13:09:53 -07:00
jason 7c3e4a6e39 fix executable in documentation readme (socktop_agent) 2025-08-08 13:06:15 -07:00
jason 466a32a90a add tmux instructions to readme 2025-08-08 13:00:36 -07:00
jason cb4882e983 patch for ci failure. 2025-08-08 12:55:48 -07:00
jason 87378e49ac Merge branch 'master' of https://github.com/jasonwitty/socktop
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-08-08 12:42:37 -07:00
jason 100434fc3c Major refactor, additional comments, performance improvements, idle performance improvements, access token, port specification
Release highlights

Introduced split client/agent architecture with a ratatui-based TUI and a lightweight WebSocket agent.
Added adaptive (idle-aware) sampler: agent samples fast only when clients are connected; sleeps when idle.
Implemented metrics JSON caching for instant ws replies; cold-start does one-off collection.
Port configuration: --port/-p, positional PORT, or SOCKTOP_PORT env (default 3000).
Optional token auth: SOCKTOP_TOKEN on agent, ws://HOST:PORT/ws?token=VALUE in client.
Logging via tracing with RUST_LOG control.
CI workflow (fmt, clippy, build) for Linux and Windows.
Systemd unit example for always-on agent.
TUI features

CPU: overall sparkline + per-core history with trend arrows and color thresholds.
Memory/Swap gauges with humanized labels.
Disks panel with per-device usage and icons.
Network download/upload sparklines (KB/s) with peak tracking.
Top processes table (PID, name, CPU%, mem, mem%).
Header with hostname and CPU temperature indicator.
Agent changes

sysinfo 0.36.1 targeted refresh: refresh_cpu_all, refresh_memory, refresh_processes_specifics(ProcessesToUpdate::All, ProcessRefreshKind::new().with_cpu().with_memory(), true).
WebSocket handler: client counting with wake notifications, cold-start handling, proper Response returns.
Sampler uses MissedTickBehavior::Skip to avoid catch-up bursts.
Docs

README updates: running instructions, port configuration, optional token auth, platform notes, example JSON.
Added socktop-agent.service systemd unit.
Platform notes

Linux (AMD/Intel) supported; tested on AMD, targeting Intel next.
Raspberry Pi supported (availability of temps varies by model).
Windows builds/run; CPU temperature may be unavailable (shows N/A).
Known/next

Roadmap includes configurable refresh interval, TUI filtering/sorting, TLS/WSS, and export to file.
Add Context...
README.md
2025-08-08 12:41:32 -07:00
jason deef4702f4 Update README.md 2025-08-08 01:08:02 -07:00
jason 1c2415bc1b correct readme 2025-08-08 01:06:43 -07:00
jasonwitty fac09b381f initial checkin 2025-08-08 01:03:35 -07:00
134 changed files with 15105 additions and 1900 deletions
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# This repository uses a custom hooks directory (.githooks). To enable this pre-commit hook run:
# git config core.hooksPath .githooks
# Ensure this file is executable: chmod +x .githooks/pre-commit
set -euo pipefail
echo "[pre-commit] Running cargo fmt --all" >&2
if ! command -v cargo >/dev/null 2>&1; then
# Try loading rustup environment (common install path)
if [ -f "$HOME/.cargo/env" ]; then
# shellcheck source=/dev/null
. "$HOME/.cargo/env"
fi
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "[pre-commit] cargo not found in PATH; skipping fmt (install Rust or adjust PATH)." >&2
exit 0
fi
cargo fmt --all
# Stage any Rust files that were reformatted
changed=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
if [ -n "$changed" ]; then
echo "$changed" | xargs git add
echo "[pre-commit] Added formatted files" >&2
fi
# Fail if further diffs remain (shouldn't happen normally)
unfmt=$(git diff --name-only --diff-filter=M | grep -E '\\.rs$' || true)
if [ -n "$unfmt" ]; then
echo "[pre-commit] Some Rust files still differ after formatting:" >&2
echo "$unfmt" >&2
exit 1
fi
exit 0
+129
View File
@@ -0,0 +1,129 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install system dependencies (Linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y libdrm-dev libdrm-amdgpu1
- name: Cargo fmt
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build (release)
run: cargo build --release --workspace
- name: "Linux: start agent and run WS probe"
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
RUST_LOG=info SOCKTOP_ENABLE_SSL=0 SOCKTOP_AGENT_GPU=0 SOCKTOP_AGENT_TEMP=0 ./target/release/socktop_agent -p 3000 > agent.log 2>&1 &
AGENT_PID=$!
for i in {1..60}; do
if curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then break; fi
sleep 1
done
if ! curl -fsS http://127.0.0.1:3000/healthz >/dev/null; then
echo "--- agent.log (tail) ---"
tail -n 200 agent.log || true
(command -v ss >/dev/null && ss -ltnp || netstat -ltnp) || true
kill $AGENT_PID || true
exit 1
fi
SOCKTOP_WS=ws://127.0.0.1:3000/ws cargo test -p socktop_connector --test integration_test -- --nocapture
kill $AGENT_PID || true
- name: "Windows: start agent and run WS probe"
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$env:SOCKTOP_ENABLE_SSL = "0"
$env:SOCKTOP_AGENT_GPU = "0"
$env:SOCKTOP_AGENT_TEMP = "0"
$out = Join-Path $PWD "agent.out.txt"
$err = Join-Path $PWD "agent.err.txt"
$p = Start-Process -FilePath "${PWD}\target\release\socktop_agent.exe" -ArgumentList "-p 3000" -RedirectStandardOutput $out -RedirectStandardError $err -PassThru -NoNewWindow
$ready = $false
for ($i = 0; $i -lt 60; $i++) {
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = "curl.exe"
$pinfo.Arguments = "-fsS http://127.0.0.1:3000/healthz"
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$proc = [System.Diagnostics.Process]::Start($pinfo)
$proc.WaitForExit()
if ($proc.ExitCode -eq 0) { $ready = $true; break }
Start-Sleep -Seconds 1
}
if (-not $ready) {
Write-Warning "TCP connect to (127.0.0.1 : 3000) failed"
if (Test-Path $out) { Write-Host "--- agent.out (full) ---"; Get-Content $out }
if (Test-Path $err) { Write-Host "--- agent.err (full) ---"; Get-Content $err }
Write-Host "--- netstat ---"
netstat -ano | Select-String ":3000" | ForEach-Object { $_.Line }
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
throw "agent did not become ready"
}
$env:SOCKTOP_WS = "ws://127.0.0.1:3000/ws"
try {
cargo test -p socktop_connector --test integration_test -- --nocapture
} finally {
if ($p -and !$p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
}
- name: Smoke test (client --help)
run: cargo run -p socktop -- --help
- name: Package artifacts (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -e
mkdir -p dist
cp target/release/socktop dist/
cp target/release/socktop_agent dist/
tar czf socktop-${{ matrix.os }}.tar.gz -C dist .
- name: Package artifacts (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item target\release\socktop.exe dist\
Copy-Item target\release\socktop_agent.exe dist\
Compress-Archive -Path dist\* -DestinationPath socktop-${{ matrix.os }}.zip -Force
- name: Upload build artifacts (ephemeral)
uses: actions/upload-artifact@v4
with:
name: socktop-${{ matrix.os }}
path: |
*.tar.gz
*.zip
- name: Upload to rolling GitHub Release (main only)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
tag_name: latest
name: Latest build
prerelease: true
draft: false
files: |
*.tar.gz
*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -0,0 +1,3 @@
/target
.vscode/
/socktop-wasm-test/target
-423
View File
@@ -1,423 +0,0 @@
# APT Repository Setup Summary
## 🎉 What You Now Have
You now have a complete system for creating and hosting your own APT repository for socktop packages, **without needing a sponsor or official Debian/Ubuntu approval**.
## 📁 Files Created
### Scripts (in `scripts/`)
- **`init-apt-repo.sh`** - Initializes the APT repository directory structure
- **`add-package-to-repo.sh`** - Adds .deb packages to the repository and generates metadata
- **`sign-apt-repo.sh`** - Signs the repository with your GPG key
- **`setup-apt-repo.sh`** - All-in-one interactive wizard to set everything up
### Documentation
- **`QUICK_START_APT_REPO.md`** - Quick start guide (< 10 minutes)
- **`docs/APT_REPOSITORY.md`** - Comprehensive 600+ line guide covering everything
- **`APT_REPO_SUMMARY.md`** - This file
### GitHub Actions
- **`.github/workflows/publish-apt-repo.yml`** - Automated building, signing, and publishing
## 🚀 Quick Start (Choose One)
### Option 1: Interactive Setup (Recommended for First Time)
Run the setup wizard:
```bash
./scripts/setup-apt-repo.sh
```
This walks you through:
1. ✅ Checking prerequisites
2. 🔑 Setting up GPG key
3. 📦 Finding/building packages
4. 📝 Creating repository structure
5. ✍️ Signing the repository
6. 📋 Next steps to publish to gh-pages
### Option 2: Manual Step-by-Step
```bash
# 1. Initialize
./scripts/init-apt-repo.sh
# 2. Build packages
cargo deb --package socktop
cargo deb --package socktop_agent
# 3. Add packages
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
# 4. Sign (replace YOUR-KEY-ID)
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
# 5. Update URLs
sed -i 's/YOUR-USERNAME/your-github-username/g' apt-repo/*.{md,html}
# 6. Publish to gh-pages (see below)
```
### Option 3: Fully Automated (After Initial Setup)
Once gh-pages branch exists, just tag releases:
```bash
git tag v1.50.0
git push --tags
# GitHub Actions will:
# - Build packages for AMD64 and ARM64
# - Update APT repository
# - Sign with your GPG key
# - Push to gh-pages branch automatically
```
## 📤 Publishing to GitHub Pages (gh-pages branch)
**Why gh-pages branch?**
- ✅ Keeps main branch clean (source code only)
- ✅ Separate branch for published content
- ✅ GitHub Actions can auto-update it
- ✅ You can customize the landing page
**Initial Setup:**
```bash
# Create gh-pages branch
git checkout --orphan gh-pages
git rm -rf .
# Copy apt-repo CONTENTS to root (not the folder!)
cp -r apt-repo/* .
rm -rf apt-repo
# Commit and push
git add .
git commit -m "Initialize APT repository"
git push -u origin gh-pages
# Return to main
git checkout main
```
**Enable in GitHub:**
1. Settings → Pages
2. Source: **gh-pages****/ (root)**
3. Save
Your repo will be at: `https://your-username.github.io/socktop/`
**Note:** GitHub Pages only allows `/` (root) or `/docs`. Since we use gh-pages branch, contents go in the root of that branch.
See `SETUP_GITHUB_PAGES.md` for detailed step-by-step instructions.
### Alternative: Self-Hosted Server
Copy `apt-repo/` contents to your web server:
```bash
rsync -avz apt-repo/ user@example.com:/var/www/apt/
```
Configure Apache/Nginx to serve the directory. See `docs/APT_REPOSITORY.md` for details.
## 🤖 GitHub Actions Automation
### Required Secrets
Add these in GitHub Settings → Secrets → Actions:
1. **GPG_PRIVATE_KEY**
```bash
gpg --armor --export-secret-key YOUR-KEY-ID
# Copy entire output including BEGIN/END lines
```
2. **GPG_KEY_ID**
```bash
gpg --list-secret-keys --keyid-format LONG
# Use the ID after "rsa4096/"
```
3. **GPG_PASSPHRASE**
```bash
# Your GPG passphrase (leave empty if no passphrase)
```
### Triggers
The workflow runs on:
- **Version tags**: `git tag v1.50.0 && git push --tags`
- **Manual dispatch**: Actions tab → "Publish APT Repository" → Run workflow
### What It Does
1. ✅ Builds packages for AMD64 and ARM64
2. ✅ Initializes or updates APT repository
3. ✅ Generates Packages files and metadata
4. ✅ Signs with your GPG key
5. ✅ Commits and pushes to gh-pages branch
6. ✅ Creates GitHub Release with artifacts
7. ✅ Generates summary with installation instructions
## 👥 User Installation
Once published, users install with:
```bash
# Add repository
curl -fsSL https://your-username.github.io/socktop/KEY.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://your-username.github.io/socktop stable main" | \
sudo tee /etc/apt/sources.list.d/socktop.list
# Install
sudo apt update
sudo apt install socktop socktop-agent
# The agent service is automatically installed and configured
sudo systemctl enable --now socktop-agent
```
## 🔧 Maintenance
### Release New Version (Automated)
```bash
# Update version in Cargo.toml, commit changes
git add . && git commit -m "Bump version to 1.51.0"
git tag v1.51.0
git push origin main --tags
# GitHub Actions automatically:
# - Builds packages for AMD64 and ARM64
# - Updates apt-repo
# - Signs with GPG
# - Pushes to gh-pages branch
```
### Manual Update (if needed)
```bash
# On main branch
cargo deb --package socktop
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
./scripts/sign-apt-repo.sh
# Switch to gh-pages and update
git checkout gh-pages
cp -r apt-repo/* .
git add . && git commit -m "Release v1.51.0" && git push
git checkout main
```
### Remove Old Versions
```bash
# On gh-pages branch
git checkout gh-pages
rm pool/main/socktop_1.50.0_*.deb
# Regenerate metadata (re-add remaining packages)
git add . && git commit -m "Remove old versions" && git push
git checkout main
```
## 🎯 Key Benefits
✅ **No sponsor needed** - Host your own repository
✅ **Full control** - You decide when to release
✅ **Free hosting** - GitHub Pages at no cost
✅ **Automated** - GitHub Actions does the work
✅ **Professional** - Just like official repos
✅ **Multi-arch** - AMD64, ARM64 support built-in
✅ **Secure** - GPG signed packages
✅ **Easy updates** - Users get updates via `apt upgrade`
## 📊 Repository Structure
```
apt-repo/
├── dists/
│ └── stable/
│ ├── Release # Main metadata (checksums)
│ ├── Release.gpg # Detached signature
│ ├── InRelease # Clearsigned release
│ └── main/
│ ├── binary-amd64/
│ │ ├── Packages # Package list
│ │ ├── Packages.gz # Compressed
│ │ └── Release # Component metadata
│ ├── binary-arm64/
│ └── binary-armhf/
├── pool/
│ └── main/
│ ├── socktop_1.50.0_amd64.deb
│ ├── socktop-agent_1.50.1_amd64.deb
│ ├── socktop_1.50.0_arm64.deb
│ └── socktop-agent_1.50.1_arm64.deb
├── KEY.gpg # Public GPG key
├── README.md # Repository info
├── index.html # Web interface
└── packages.html # Package listing
```
## 🔑 GPG Key Management
### Create New Key
```bash
gpg --full-generate-key
# Choose RSA 4096, no expiration (or 2 years)
```
### Export Keys
```bash
# Public key (for users)
gpg --armor --export YOUR-KEY-ID > KEY.gpg
# Private key (for GitHub Secrets)
gpg --armor --export-secret-key YOUR-KEY-ID
```
### Backup Keys
```bash
# Backup to safe location
gpg --export-secret-keys YOUR-KEY-ID > gpg-private-backup.key
gpg --export YOUR-KEY-ID > gpg-public-backup.key
```
### Key Rotation
If your key expires or is compromised:
```bash
./scripts/sign-apt-repo.sh apt-repo stable NEW-KEY-ID
gpg --armor --export NEW-KEY-ID > apt-repo/KEY.gpg
# Users need to re-import the key
```
## 🐛 Troubleshooting
### "Repository not signed"
```bash
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
ls apt-repo/dists/stable/Release* # Should show 3 files
```
### "Package not found"
```bash
cd apt-repo
dpkg-scanpackages --arch amd64 pool/main /dev/null > dists/stable/main/binary-amd64/Packages
gzip -9 -k -f dists/stable/main/binary-amd64/Packages
cd ..
./scripts/sign-apt-repo.sh
```
### "404 Not Found" on GitHub Pages
- Wait 2-3 minutes after pushing
- Check Settings → Pages is enabled
- Verify source branch/directory
### GitHub Actions not signing
- Check all 3 secrets are set correctly
- GPG_PRIVATE_KEY must include BEGIN/END lines
- Test signing locally first
## 📚 Documentation
| File | Purpose | Length |
|------|---------|--------|
| `QUICK_START_APT_REPO.md` | Get started in < 10 minutes | Quick |
| `SETUP_GITHUB_PAGES.md` | Detailed gh-pages setup guide | Step-by-step |
| `docs/APT_REPOSITORY.md` | Complete guide with all options | Comprehensive |
| `docs/DEBIAN_PACKAGING.md` | How .deb packages are built | Technical |
| `DEBIAN_PACKAGING_SUMMARY.md` | Overview of packaging work | Summary |
| `APT_REPO_SUMMARY.md` | This file | Overview |
## 🎓 Learning Path
1. **Start here**: `QUICK_START_APT_REPO.md` (10 min)
2. **Set up**: Run `./scripts/setup-apt-repo.sh` (15 min)
3. **Publish**: Follow `SETUP_GITHUB_PAGES.md` (5 min)
4. **Automate**: Set up GitHub Actions secrets (10 min)
5. **Advanced**: Read `docs/APT_REPOSITORY.md` as needed
## 🚦 Next Steps
Choose your path:
### Just Getting Started?
1. ✅ Read `QUICK_START_APT_REPO.md`
2. ✅ Run `./scripts/setup-apt-repo.sh`
3. ✅ Follow `SETUP_GITHUB_PAGES.md` to publish
4. ✅ Test installation on a VM
### Want Automation?
1. ✅ Generate/export GPG key
2. ✅ Add GitHub Secrets
3. ✅ Tag a release: `git tag v1.50.0 && git push --tags`
4. ✅ Watch GitHub Actions magic happen
### Want to Understand Everything?
1. ✅ Read `docs/APT_REPOSITORY.md` (comprehensive)
2. ✅ Study the scripts in `scripts/`
3. ✅ Examine `.github/workflows/publish-apt-repo.yml`
4. ✅ Learn about Debian repository format
### Ready for Production?
1. ✅ Set up monitoring/analytics
2. ✅ Create PPA for Ubuntu (Launchpad)
3. ✅ Apply to Debian mentors for official inclusion
4. ✅ Set up repository mirrors
5. ✅ Document best practices for users
## 🌟 Success Criteria
You'll know you're successful when:
- [ ] Users can `apt install socktop`
- [ ] Updates work with `apt upgrade`
- [ ] Multiple architectures supported
- [ ] Repository is GPG signed
- [ ] GitHub Actions publishes automatically
- [ ] Installation instructions in README
- [ ] Zero sponsor or approval needed
## 💡 Pro Tips
1. **Test first**: Always test on a fresh VM before publishing
2. **Keep versions**: Don't delete old .deb files immediately
3. **Backup GPG key**: Store it safely offline
4. **Monitor downloads**: Use GitHub Insights or server logs
5. **Document everything**: Help users troubleshoot
6. **Version consistently**: Use semantic versioning
7. **Sign always**: Never publish unsigned repositories
## 🔗 Resources
- [Debian Repository Format](https://wiki.debian.org/DebianRepository/Format)
- [GitHub Pages Docs](https://docs.github.com/en/pages)
- [cargo-deb](https://github.com/kornelski/cargo-deb)
- [Ubuntu PPA Guide](https://help.launchpad.net/Packaging/PPA)
- [Debian Mentors](https://mentors.debian.net/)
## 🎊 Congratulations!
You now have everything you need to:
- ✅ Create your own APT repository
- ✅ Host it for free on GitHub Pages
- ✅ Automate the entire process
- ✅ Distribute packages professionally
- ✅ Provide easy installation for users
**No sponsor required. No approval needed. You're in control!** 🚀
---
**Questions?** Check the docs or open an issue.
**Ready to publish?** Run `./scripts/setup-apt-repo.sh` and follow the wizard!
Generated
+3457
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
[workspace]
resolver = "2"
members = [
"socktop",
"socktop_agent",
"socktop_connector"
]
[workspace.dependencies]
# async + streams
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
anyhow = "1.0"
# websocket
tokio-tungstenite = { version = "0.24", features = ["__rustls-tls", "connect"] }
url = "2.5"
# JSON + error handling
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# system stats (align across crates)
sysinfo = "0.37"
# CLI UI
ratatui = "0.28"
crossterm = "0.27"
# web server (remote-agent)
axum = { version = "0.7", features = ["ws"] }
# protobuf
prost = "0.13"
dirs-next = "2"
# compression
flate2 = "1.0"
# TLS
rustls = { version = "0.23", features = ["ring"] }
rustls-pemfile = "2.1"
[profile.release]
# Favor smaller, simpler binaries with good runtime perf
lto = "thin"
codegen-units = 1
panic = "abort"
opt-level = 3
strip = "symbols"
-109
View File
@@ -1,109 +0,0 @@
# 🚀 Get Your APT Repository Live in 5 Minutes
## You're Here Because...
You want to publish socktop packages via APT, but GitHub Pages won't let you select `apt-repo/` folder. Here's why and how to fix it:
**The Issue:** GitHub Pages only serves from `/` (root) or `/docs`, not custom folders like `/apt-repo`.
**The Solution:** Use a `gh-pages` branch where `apt-repo` contents go in the root.
## Quick Setup (5 Steps)
### 1. Create apt-repo locally (if you haven't)
```bash
./scripts/setup-apt-repo.sh
```
This creates `apt-repo/` with your packages and signs them.
### 2. Create gh-pages branch
```bash
git checkout --orphan gh-pages
git rm -rf .
```
### 3. Copy apt-repo to root
```bash
cp -r apt-repo/* .
rm -rf apt-repo
ls
# You should see: dists/ pool/ KEY.gpg index.html README.md
```
### 4. Push to GitHub
```bash
git add .
git commit -m "Initialize APT repository"
git push -u origin gh-pages
git checkout main
```
### 5. Enable GitHub Pages
1. Go to: **Settings → Pages**
2. Source: **gh-pages****/ (root)**
3. Click **Save**
**Done!** ✅ Your repo will be live at `https://your-username.github.io/socktop/` in 1-2 minutes.
## Test It
```bash
curl -I https://your-username.github.io/socktop/KEY.gpg
# Should return: HTTP/2 200
```
## Install It (On Any Debian/Ubuntu System)
```bash
curl -fsSL https://your-username.github.io/socktop/KEY.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://your-username.github.io/socktop stable main" | \
sudo tee /etc/apt/sources.list.d/socktop.list
sudo apt update
sudo apt install socktop socktop-agent
```
## What's Next?
### Now (Optional):
- Customize `index.html` on gh-pages for a nice landing page
- Add installation instructions to your main README
### Later:
- Set up GitHub Actions automation (see `QUICK_START_APT_REPO.md`)
- Add more architectures (ARM64, ARMv7)
## Understanding the Setup
```
main branch: gh-pages branch:
├── src/ ├── dists/
├── Cargo.toml ├── pool/
├── scripts/ ├── KEY.gpg
└── apt-repo/ (local) └── index.html ← GitHub Pages serves this
Work here ↑ Published here ↑
```
- **main**: Your development work
- **gh-pages**: What users see/download
- **apt-repo/**: Local folder (ignored in git, see `.gitignore`)
## Need More Help?
- **Quick start**: `QUICK_START_APT_REPO.md`
- **Detailed setup**: `SETUP_GITHUB_PAGES.md`
- **Why gh-pages?**: `WHY_GHPAGES_BRANCH.md`
- **Full guide**: `docs/APT_REPOSITORY.md`
---
**You got this!** 🎉
-42
View File
@@ -1,42 +0,0 @@
-----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-----
-221
View File
@@ -1,221 +0,0 @@
# Quick Start: Setting Up Your socktop APT Repository
This guide will get your APT repository up and running in **under 10 minutes**.
## Prerequisites
- [ ] Debian packages built (or use GitHub Actions to build them)
- [ ] GPG key for signing
- [ ] GitHub repository with Pages enabled
## Step 1: Create GPG Key (if needed)
```bash
# Generate a new key
gpg --full-generate-key
# Select:
# - RSA and RSA (default)
# - 4096 bits
# - Key does not expire (or 2 years)
# - Your name and email
# Get your key ID
gpg --list-secret-keys --keyid-format LONG
# Look for the part after "rsa4096/" - that's your KEY-ID
```
## Step 2: Initialize Repository Locally
```bash
cd socktop
# Create the repository structure
./scripts/init-apt-repo.sh
# Build packages (or download from GitHub Actions)
cargo install cargo-deb
cargo deb --package socktop
cargo deb --package socktop_agent
# Add packages to repository
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
# Sign the repository (replace YOUR-KEY-ID with actual key ID)
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
# Update URLs with your GitHub username
sed -i 's/YOUR-USERNAME/your-github-username/g' apt-repo/README.md apt-repo/index.html
```
## Step 3: Publish to GitHub Pages (gh-pages branch)
```bash
# Create gh-pages branch
git checkout --orphan gh-pages
git rm -rf .
# Copy apt-repo CONTENTS to root (not the folder itself)
cp -r apt-repo/* .
rm -rf apt-repo
# Commit and push
git add .
git commit -m "Initialize APT repository"
git push -u origin gh-pages
# Go back to main branch
git checkout main
```
Then in GitHub:
1. Go to **Settings → Pages**
2. Source: **Deploy from a branch**
3. Branch: **gh-pages****/ (root)** → **Save**
Wait 1-2 minutes, then visit: `https://your-username.github.io/socktop/`
## Step 4: Automate with GitHub Actions
Add these secrets to your repository (Settings → Secrets → Actions):
```bash
# Export your private key
gpg --armor --export-secret-key YOUR-KEY-ID
# Copy the ENTIRE output and save as secret: GPG_PRIVATE_KEY
```
Add these three secrets:
- **GPG_PRIVATE_KEY**: Your exported private key
- **GPG_KEY_ID**: Your key ID (e.g., `ABC123DEF456`)
- **GPG_PASSPHRASE**: Your key passphrase (leave empty if no passphrase)
The workflow in `.github/workflows/publish-apt-repo.yml` will now:
- Build packages for AMD64 and ARM64
- Update the APT repository
- Sign with your GPG key
- Push to gh-pages automatically
Trigger it by:
- Creating a version tag: `git tag v1.50.0 && git push --tags`
- Manual dispatch from GitHub Actions tab
## Step 5: Test It
On any Debian/Ubuntu system:
```bash
# Add your repository
curl -fsSL https://your-username.github.io/socktop/KEY.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://your-username.github.io/socktop stable main" | \
sudo tee /etc/apt/sources.list.d/socktop.list
# Install
sudo apt update
sudo apt install socktop socktop-agent
# Verify
socktop --version
socktop_agent --version
```
## Maintenance
### Add a New Version
```bash
# Build new packages
cargo deb --package socktop
cargo deb --package socktop_agent
# Add to repository
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
# Re-sign
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
# Publish
cd docs/apt # or wherever your apt-repo is
git add .
git commit -m "Release v1.51.0"
git push origin main
```
### Or Just Tag and Let GitHub Actions Do It
```bash
# Update version in Cargo.toml
# Commit changes
git add .
git commit -m "Bump version to 1.51.0"
# Tag and push
git tag v1.51.0
git push origin main --tags
# GitHub Actions will:
# - Build packages for AMD64 and ARM64
# - Update gh-pages branch automatically
# - Sign and publish!
```
## Troubleshooting
### "Repository not signed" error
Make sure you signed it:
```bash
./scripts/sign-apt-repo.sh apt-repo stable YOUR-KEY-ID
ls apt-repo/dists/stable/Release*
# Should show: Release, Release.gpg, InRelease, KEY.gpg
```
### "404 Not Found" on GitHub Pages
1. Check Settings → Pages is enabled
2. Wait 2-3 minutes for GitHub to deploy
3. Verify the URL structure matches your settings
### GitHub Actions not signing
Check that all three secrets are set correctly:
- Settings → Secrets and variables → Actions
- Make sure GPG_PRIVATE_KEY includes the BEGIN/END lines
- Test locally first
## What's Next?
✅ You now have a working APT repository!
**Share it:**
- Add installation instructions to your main README
- Tweet/blog about it
- Submit to awesome-rust lists
**Improve it:**
- Customize your GitHub Pages site (it's just HTML!)
- Add more architectures (ARMv7)
- Create multiple distributions (stable, testing)
- Set up download statistics
- Apply to Ubuntu PPA (Launchpad)
- Eventually submit to official Debian repos
## Full Documentation
For detailed information, see:
- `docs/APT_REPOSITORY.md` - Complete APT repository guide
- `docs/DEBIAN_PACKAGING.md` - Debian packaging details
- `DEBIAN_PACKAGING_SUMMARY.md` - Quick summary
## Questions?
Open an issue on GitHub or check the full documentation.
---
**Happy packaging! 📦**
+541 -23
View File
@@ -1,38 +1,556 @@
# socktop APT Repository
# socktop
This repository contains Debian packages for socktop and socktop-agent.
socktop is a remote system monitor with a rich TUI, inspired by top/btop, talking to a lightweight agent over WebSockets.
## Adding this repository
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
Add the repository to your system:
<img src="./docs/socktop_demo.apng" width="100%">
---
## Features
- 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
---
## Prerequisites: Install Rust (rustup)
Rust is fast, safe, and crossplatform. Installing it will make your machine better. Consider yourself privileged.
Linux/macOS:
```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
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
```
## Manual Installation
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.
You can also download and install packages manually from the `pool/main/` directory.
### Raspberry Pi / Ubuntu / PopOS (required)
Install GPU support with apt command below
```bash
wget https://jasonwitty.github.io/socktop/pool/main/socktop_VERSION_ARCH.deb
sudo dpkg -i socktop_VERSION_ARCH.deb
sudo apt-get update
sudo apt-get install libdrm-dev libdrm-amdgpu1
```
## Supported Architectures
_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)_
- amd64 (x86_64)
- arm64 (aarch64)
- armhf (32-bit ARM)
---
## Building from Source
## Architecture
See the main repository at https://github.com/jasonwitty/socktop
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
```bash
cargo fmt
cargo clippy --all-targets --all-features
cargo run -p socktop -- ws://127.0.0.1:3000/ws
# TLS (dev): first run will create certs under ~/.config/socktop_agent/tls/
cargo run -p socktop_agent -- --enableSSL --port 8443
```
### Auto-format on commit
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.
---
## Acknowledgements
- ratatui for the TUI
- sysinfo for system metrics
- tokio-tungstenite for WebSockets
-351
View File
@@ -1,351 +0,0 @@
# Setting Up GitHub Pages for socktop APT Repository
This guide walks you through the initial setup of your APT repository on GitHub Pages using the `gh-pages` branch.
## Prerequisites
- [ ] You've run `./scripts/setup-apt-repo.sh` or manually created `apt-repo/`
- [ ] `apt-repo/` contains signed packages and metadata
- [ ] You have a GitHub repository for socktop
## Step-by-Step Setup
### 1. Verify Your Local Repository
First, make sure everything is ready:
```bash
# Check that apt-repo exists and has content
ls -la apt-repo/
# You should see:
# - dists/stable/Release, Release.gpg, InRelease
# - pool/main/*.deb
# - KEY.gpg
# - index.html, README.md
```
### 2. Create and Switch to gh-pages Branch
```bash
# Create a new orphan branch (no history from main)
git checkout --orphan gh-pages
# Remove all files from staging
git rm -rf .
```
**Important:** This creates a completely separate branch. Don't worry - your main branch is safe!
### 3. Copy APT Repository to Root
```bash
# Copy CONTENTS of apt-repo to root of gh-pages
cp -r apt-repo/* .
# Remove the apt-repo directory itself
rm -rf apt-repo
# Verify the structure
ls -la
# You should see in the current directory:
# - dists/
# - pool/
# - KEY.gpg
# - index.html
# - README.md
```
**Why root?** GitHub Pages can only serve from:
- `/` (root) - what we're doing
- `/docs` directory
- NOT from custom directories like `/apt-repo`
### 4. Commit and Push
```bash
# Add all files
git add .
# Commit
git commit -m "Initialize APT repository for GitHub Pages"
# Push to gh-pages branch
git push -u origin gh-pages
```
### 5. Return to Main Branch
```bash
# Switch back to your main development branch
git checkout main
# Verify you're back on main
git branch
# Should show: * main
```
### 6. Enable GitHub Pages
1. Go to your repository on GitHub
2. Click **Settings** (top right)
3. Click **Pages** (left sidebar)
4. Under "Build and deployment":
- Source: **Deploy from a branch**
- Branch: **gh-pages**
- Folder: **/ (root)**
- Click **Save**
### 7. Wait for Deployment
GitHub will deploy your site. This usually takes 1-2 minutes.
You can watch the progress:
- Go to **Actions** tab
- Look for "pages build and deployment" workflow
### 8. Verify Your Repository is Live
Once deployed, your repository will be at:
```
https://YOUR-USERNAME.github.io/socktop/
```
Test it:
```bash
# Check the public key is accessible
curl -I https://YOUR-USERNAME.github.io/socktop/KEY.gpg
# Should return: HTTP/2 200
# Check the Release file
curl -I https://YOUR-USERNAME.github.io/socktop/dists/stable/Release
# Should return: HTTP/2 200
```
### 9. Test Installation (Optional but Recommended)
On a Debian/Ubuntu VM or system:
```bash
# Add GPG key
curl -fsSL https://YOUR-USERNAME.github.io/socktop/KEY.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
# Add repository
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://YOUR-USERNAME.github.io/socktop stable main" | \
sudo tee /etc/apt/sources.list.d/socktop.list
# Update package lists
sudo apt update
# You should see:
# Get:1 https://YOUR-USERNAME.github.io/socktop stable InRelease [xxx B]
# Install packages
sudo apt install socktop socktop-agent
# Verify
socktop --version
```
## Understanding the Two Branches
After setup, you'll have two branches:
### `main` branch (development)
```
main/
├── src/
├── Cargo.toml
├── scripts/
├── docs/
├── apt-repo/ ← Local build artifact (not published)
└── ...
```
**Purpose:** Source code, development, building packages
### `gh-pages` branch (published)
```
gh-pages/
├── dists/
├── pool/
├── KEY.gpg
├── index.html ← Customize this for a nice landing page!
└── README.md
```
**Purpose:** Published APT repository served by GitHub Pages
## Workflow Going Forward
### Manual Updates
When you release a new version:
```bash
# 1. On main branch, build new packages
git checkout main
cargo deb --package socktop
cargo deb --package socktop_agent
# 2. Update local apt-repo
./scripts/add-package-to-repo.sh target/debian/socktop_*.deb
./scripts/add-package-to-repo.sh target/debian/socktop-agent_*.deb
./scripts/sign-apt-repo.sh apt-repo stable YOUR-GPG-KEY-ID
# 3. Switch to gh-pages and update
git checkout gh-pages
cp -r apt-repo/* .
git add .
git commit -m "Release v1.51.0"
git push origin gh-pages
# 4. Return to main
git checkout main
```
### Automated Updates (Recommended)
Set up GitHub Actions to do this automatically:
1. Add GitHub Secrets (Settings → Secrets → Actions):
- `GPG_PRIVATE_KEY` - Your exported private key
- `GPG_KEY_ID` - Your GPG key ID
- `GPG_PASSPHRASE` - Your GPG passphrase (if any)
2. Tag and push:
```bash
git tag v1.51.0
git push origin main --tags
```
3. GitHub Actions will automatically:
- Build packages for AMD64 and ARM64
- Update apt-repo
- Sign with your GPG key
- Push to gh-pages
- Create GitHub Release
See `.github/workflows/publish-apt-repo.yml` for details.
## Customizing Your GitHub Pages Site
The `gh-pages` branch contains `index.html` which users see when they visit:
`https://YOUR-USERNAME.github.io/socktop/`
You can customize this! On the `gh-pages` branch:
```bash
git checkout gh-pages
# Edit index.html
nano index.html
# Add features, badges, screenshots, etc.
git add index.html
git commit -m "Improve landing page"
git push origin gh-pages
git checkout main
```
## Troubleshooting
### "404 Not Found" on GitHub Pages
**Check:**
- Settings → Pages shows "Your site is live at..."
- Wait 2-3 minutes after pushing
- Verify branch is `gh-pages` and folder is `/`
- Check Actions tab for deployment errors
### "Repository not found" when installing
**Check:**
- URL is correct: `https://USERNAME.github.io/REPO/` (no trailing /apt-repo)
- Files exist at the URLs:
```bash
curl -I https://USERNAME.github.io/REPO/dists/stable/InRelease
curl -I https://USERNAME.github.io/REPO/KEY.gpg
```
### "GPG error" when installing
**Check:**
- Repository is signed: `ls gh-pages/dists/stable/Release.gpg`
- Users imported the key: `curl https://USERNAME.github.io/REPO/KEY.gpg | gpg --import`
### Changes not appearing
**Check:**
- You committed and pushed to `gh-pages` (not `main`)
- Wait 1-2 minutes for GitHub to redeploy
- Clear browser cache if viewing index.html
- For apt: `sudo apt clean && sudo apt update`
## Success Checklist
After completing this guide, you should have:
- [ ] `gh-pages` branch created and pushed
- [ ] GitHub Pages enabled and deployed
- [ ] Site accessible at `https://USERNAME.github.io/socktop/`
- [ ] `KEY.gpg` downloadable
- [ ] `dists/stable/InRelease` accessible
- [ ] Packages in `pool/main/*.deb` downloadable
- [ ] Successfully tested installation on a test system
- [ ] Understand the workflow for future updates
## Next Steps
1. **Update your main README.md** with installation instructions
2. **Set up GitHub Actions** for automated releases
3. **Customize index.html** on gh-pages for a nice landing page
4. **Test on multiple architectures** (AMD64, ARM64)
5. **Share your repository** with users
## Quick Reference
**Switch branches:**
```bash
git checkout main # Development
git checkout gh-pages # Published site
```
**Update published site manually:**
```bash
git checkout main
# ... build packages, update apt-repo ...
git checkout gh-pages
cp -r apt-repo/* .
git add . && git commit -m "Update" && git push
git checkout main
```
**Your repository URL:**
```
https://YOUR-USERNAME.github.io/socktop/
```
**User installation command:**
```bash
curl -fsSL https://YOUR-USERNAME.github.io/socktop/KEY.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/socktop-archive-keyring.gpg] https://YOUR-USERNAME.github.io/socktop stable main" | \
sudo tee /etc/apt/sources.list.d/socktop.list
sudo apt update && sudo apt install socktop socktop-agent
```
---
**Need help?** See:
- `QUICK_START_APT_REPO.md` - Overall quick start
- `docs/APT_REPOSITORY.md` - Comprehensive guide
- `docs/APT_WORKFLOW.md` - Visual workflow diagrams
-119
View File
@@ -1,119 +0,0 @@
# Why We Use the gh-pages Branch
## The Problem
GitHub Pages has a limitation - it can only serve static sites from:
1. **`/` (root)** of a branch
2. **`/docs`** directory of a branch
3. **NOT** from custom directories like `/apt-repo`
## Why Not `/docs`?
When you tried to enable GitHub Pages with `apt-repo/` checked into main, you couldn't select it because:
```
main/
├── src/
├── Cargo.toml
├── apt-repo/ ← GitHub Pages can't serve from here!
└── ...
```
You could move it to `/docs`:
```
main/
├── src/
├── Cargo.toml
├── docs/ ← GitHub Pages CAN serve from here
│ ├── dists/
│ ├── pool/
│ └── ...
└── ...
```
But this has downsides:
- ❌ Mixed source code and published content
- ❌ Large .deb files bloat the main branch
- ❌ Can't easily customize the site without affecting source
- ❌ Messy git history with binary files
## Why gh-pages Branch (Our Solution)
Using a separate `gh-pages` branch is cleaner:
```
main branch (source code):
├── src/
├── Cargo.toml
├── scripts/
└── docs/ ← Documentation source
gh-pages branch (published):
├── dists/
├── pool/
├── KEY.gpg
├── index.html ← Customizable landing page
└── README.md
```
### Benefits
**Clean separation**: Source code stays in `main`, published content in `gh-pages`
**No binary bloat**: .deb files don't clutter your main branch history
**Easy automation**: GitHub Actions can push to gh-pages without affecting main
**Customizable**: You can make a beautiful landing page on gh-pages
**Standard practice**: Most GitHub Pages projects use gh-pages branch
**Root URL**: Your repo is at `https://username.github.io/socktop/` (not `/apt-repo`)
### Workflow
```
Developer (main branch)
Build packages
Update apt-repo/ (local)
Push to gh-pages branch
GitHub Pages serves
Users: apt install socktop
```
## The Setup
**One-time setup:**
```bash
git checkout --orphan gh-pages
git rm -rf .
cp -r apt-repo/* .
rm -rf apt-repo
git add . && git commit -m "Initialize APT repository"
git push -u origin gh-pages
git checkout main
```
**Going forward:**
- Work on `main` branch for development
- `gh-pages` branch gets updated by GitHub Actions (or manually)
- Never need to switch branches manually after automation is set up!
## Comparison
| Approach | Location | URL | Pros | Cons |
|----------|----------|-----|------|------|
| **gh-pages branch** ✅ | gh-pages:/ | `username.github.io/socktop/` | Clean, automated, customizable | Two branches |
| `/docs` on main | main:/docs | `username.github.io/socktop/` | One branch | Mixed content, binary bloat |
| `/apt-repo` on main | main:/apt-repo | ❌ Not possible | - | GitHub Pages won't allow it |
## Conclusion
The `gh-pages` branch approach is:
- The **cleanest** solution
- The **most flexible** for customization
- The **easiest to automate**
- **Industry standard** for GitHub Pages
That's why we chose it! 🚀
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf riscv64
Components: main
Description: socktop APT repository
Date: Mon, 24 Nov 2025 20:01:32 +0000
MD5Sum:
0b1110782420dd7941940d8f99adda34 1627 main/binary-amd64/Packages
7c1447cf37137d7a72534cea3cb3872f 836 main/binary-amd64/Packages.gz
1e8f176ddd62df4bed3d11aa9673d5f1 1627 main/binary-arm64/Packages
165f010f2f2e85775e602bbfc32b9a67 832 main/binary-arm64/Packages.gz
0c17d836ddf76547285336b1a9948daf 1610 main/binary-armhf/Packages
6ee29ba21442c21c3a1ad89aaa1bcdf6 820 main/binary-armhf/Packages.gz
1fc7e24923a509ea722c2d0d5189700a 1623 main/binary-riscv64/Packages
6fc7db17b307382c4042cd7fab03010a 820 main/binary-riscv64/Packages.gz
SHA256:
eb77b0c29f6d909a0e26596ff5c897ad32c45fa0c95d14f435e68264e2f02024 1627 main/binary-amd64/Packages
e045e3303e653094ad828aa11adb0fd7194f2fe422b99cd0ee0a1c2100d86544 836 main/binary-amd64/Packages.gz
7054a62c0a4cdda4be5a264809c358a83f8323b388d43880d73557588c20fe15 1627 main/binary-arm64/Packages
d87f2cc66015a7d6f7b6755330af0003fa444d27229eb6127df00ab847b9cd53 832 main/binary-arm64/Packages.gz
d17d55bd46dc1955b09615b3bb7ce009f34e277b1b11e131b340afc77157e554 1610 main/binary-armhf/Packages
53e37cd254c6f96272f7b9f83e2ad86e2013be1a3e4061a35592c795102aa087 820 main/binary-armhf/Packages.gz
a43bb5de1b4b43fbd02e141db8c0134558b4750f9c2835f468cb731701103ba6 1623 main/binary-riscv64/Packages
1d0be4332d0794b3a25ea81774690030276fb791f47eaf07758041183079807d 820 main/binary-riscv64/Packages.gz
-----BEGIN PGP SIGNATURE-----
iQGzBAEBCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkkuZ4ACgkQESwaeYRl
+/LaawwAv9CIkkuqc7bvgyLNNVf2GEi0UV3I/DBg0YX47gKI2u4wp760sf53BYkk
wmYZkPcQFFAICVEY7j/WqvnRBkNb67sF7eS4p14IW9UB4GAMs5U0k6737VIwp47G
AUbuqZKv3kRk/x7XeZgmqnipXtSqlfVct2dx1+53yGqnwdSywpU9Ns64Iod4/lLQ
ipXkKOmb+SdGM90uv4lQ+BdleRykqb8cC761LKJEYZEByUal95woBW8cp9EycJXu
yqqAX21Bgx8YC7aF0Z61e9BYlUGXnxFwz4pn0hwvLli8X5dJEdUxWnuto+b/5HAk
ql7gicDHfxOKOUv2qQFsShsybJm4axWMBqMudDohZTtylfp0rgAQX8Tr6U1u303a
1kmU8v0Ph/XOfZT1gGnHfEn6RlOmDfaSRqcF2USyZTzrPgo9g8EKCiBuZm+Som9a
VjQmvdRHiJ0K84bfNpjFW4aJo0xE2EQR7xS/RXCA0SQ9YhFrxBxYr5TxWM0N0C6j
LCsp4O4A
=Jzma
-----END PGP SIGNATURE-----
-26
View File
@@ -1,26 +0,0 @@
Origin: socktop
Label: socktop
Suite: stable
Codename: stable
Architectures: amd64 arm64 armhf riscv64
Components: main
Description: socktop APT repository
Date: Mon, 24 Nov 2025 20:01:32 +0000
MD5Sum:
0b1110782420dd7941940d8f99adda34 1627 main/binary-amd64/Packages
7c1447cf37137d7a72534cea3cb3872f 836 main/binary-amd64/Packages.gz
1e8f176ddd62df4bed3d11aa9673d5f1 1627 main/binary-arm64/Packages
165f010f2f2e85775e602bbfc32b9a67 832 main/binary-arm64/Packages.gz
0c17d836ddf76547285336b1a9948daf 1610 main/binary-armhf/Packages
6ee29ba21442c21c3a1ad89aaa1bcdf6 820 main/binary-armhf/Packages.gz
1fc7e24923a509ea722c2d0d5189700a 1623 main/binary-riscv64/Packages
6fc7db17b307382c4042cd7fab03010a 820 main/binary-riscv64/Packages.gz
SHA256:
eb77b0c29f6d909a0e26596ff5c897ad32c45fa0c95d14f435e68264e2f02024 1627 main/binary-amd64/Packages
e045e3303e653094ad828aa11adb0fd7194f2fe422b99cd0ee0a1c2100d86544 836 main/binary-amd64/Packages.gz
7054a62c0a4cdda4be5a264809c358a83f8323b388d43880d73557588c20fe15 1627 main/binary-arm64/Packages
d87f2cc66015a7d6f7b6755330af0003fa444d27229eb6127df00ab847b9cd53 832 main/binary-arm64/Packages.gz
d17d55bd46dc1955b09615b3bb7ce009f34e277b1b11e131b340afc77157e554 1610 main/binary-armhf/Packages
53e37cd254c6f96272f7b9f83e2ad86e2013be1a3e4061a35592c795102aa087 820 main/binary-armhf/Packages.gz
a43bb5de1b4b43fbd02e141db8c0134558b4750f9c2835f468cb731701103ba6 1623 main/binary-riscv64/Packages
1d0be4332d0794b3a25ea81774690030276fb791f47eaf07758041183079807d 820 main/binary-riscv64/Packages.gz
-14
View File
@@ -1,14 +0,0 @@
-----BEGIN PGP SIGNATURE-----
iQGzBAABCgAdFiEEHnVWqAU5uDlLwoINESwaeYRl+/IFAmkkuZwACgkQESwaeYRl
+/LkCQv+M3ceDIfGIJYN5PoDJjE5ON2RuOg+GQscz44qcOwrXxj2E76LpAhkjbrC
RFQOHPp2harTDLAQ5b1PcxCy7DygTYgyFVXxn3bqf5NwzXFDHGzVMhFHLBqDr7e7
rkG6k2H6OMUV4SLhx6XQMp74fMP3e4qvKiZRP0LPn2ZiQcnh5CLKRDWwPhC8GWHM
Fh053Drg8bkWY/qG4070FVfQU/Os5w65pS9knDPe+1AFC9Rl7glNYtcMVPO6psvX
2UgvSCZ5McFZJt+eQceWDFIK6Zl0gJ5YEFVsIPug93x3EEdXXYL5UfosaYeSS1L8
g1ATdNA3otvPYvcwOVo/USjwwQ9OODb3tQLlp8NynOJ+v9oTju22RXLaa4iwt7d/
qk+bfmxfZTjEb0dz92SKGtIHwTSzDUAxb7kvpQLtQ3utGuH/46ozxJQ0FUoOLATr
pp2n7aCmgWupuRybB6U9tQNICydOGbY5lBrmzQC3/dR2IdayCuRhKqiGe5z10KCa
gu7LGncL
=1a8X
-----END PGP SIGNATURE-----
-40
View File
@@ -1,40 +0,0 @@
Package: socktop
Version: 1.50.0-1
Architecture: amd64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 3447
Depends: libc6 (>= 2.39)
Filename: pool/main/socktop_1.50.0-1_amd64.deb
Size: 1277472
MD5sum: 8af32694d8ea66feb97bc9896dd1034f
SHA1: acf596897449d1ec3a3eaee7c7fe25e716d91a8c
SHA256: 59ade1d2cc919fa672c2b8b50a05905defc1a220776949b78743d054eaa07994
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: 6726
Depends: libc6 (>= 2.39), libdrm-amdgpu1 (>= 2.4.80)
Filename: pool/main/socktop-agent_1.50.2-1_amd64.deb
Size: 1869464
MD5sum: b708dbc7330e8c8f2d2d6812f978f3c9
SHA1: 2fce3376934586a630031f9a6dc4ed196c37200e
SHA256: 4a1d7e9794048b6dc0a74a428d1536903c9c3e3ed8b52d53539f1ecb4ebc3967
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.
-5
View File
@@ -1,5 +0,0 @@
Archive: stable
Component: main
Origin: socktop
Label: socktop
Architecture: amd64
-40
View File
@@ -1,40 +0,0 @@
Package: socktop
Version: 1.50.0-1
Architecture: arm64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 2908
Depends: libc6 (>= 2.39)
Filename: pool/main/socktop_1.50.0-1_arm64.deb
Size: 1139764
MD5sum: 05fa53ec3555238b6454ffe44acba4a5
SHA1: c6f062f0d191e45d34e2d349864dfa552433bd0c
SHA256: 976de361b1f867e5c3fe1b8bba34862f6e92564980367a1b5a9ff024c7825273
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: arm64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 5220
Depends: libc6 (>= 2.39), libdrm-amdgpu1 (>= 2.4.80)
Filename: pool/main/socktop-agent_1.50.2-1_arm64.deb
Size: 1645464
MD5sum: 517f46814ffc34ca3075ce0fc6020f1f
SHA1: a120d9b763b453c0e860fea8e3f121e4cabda329
SHA256: 72d562c50f4de437c5e8f46fb79cc0216b96f5bdc2b96958a51ae69aa156eead
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.
-40
View File
@@ -1,40 +0,0 @@
Package: socktop
Version: 1.50.0-1
Architecture: armhf
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 2706
Depends: libc6:armhf (>= 2.39)
Filename: pool/main/socktop_1.50.0-1_armhf.deb
Size: 986508
MD5sum: 5872abb834d52fefd05d3be848c0c466
SHA1: c2cd0f4bc1578541836f44c8595a90b07815448f
SHA256: c246bd1fbad3598129dd3101296791aa75d0d817d2a9b33ea18e21b95712bdeb
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: armhf
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 3919
Depends: libc6:armhf (>= 2.39)
Filename: pool/main/socktop-agent_1.50.2-1_armhf.deb
Size: 1494848
MD5sum: 90aae9922ffe58b9685c10e38cdb91f0
SHA1: f8aab5171952a9a5b1251a00e907ae6805286edd
SHA256: 6c1f813a899416243a8c6d11dd424832a8a7184e3da200ba72e3afe23cb5fcd6
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.
-40
View File
@@ -1,40 +0,0 @@
Package: socktop
Version: 1.50.0-1
Architecture: riscv64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 2664
Depends: libc6:riscv64 (>= 2.39)
Filename: pool/main/socktop_1.50.0-1_riscv64.deb
Size: 1133776
MD5sum: 8d35cb4a2e61e4817bedd36362f3d21e
SHA1: 242c3818e20e4fa8ab2fd6bd9a897aab0246c01d
SHA256: c8138d798820fc0bcdcf12a945f35b6a45635cbec109548ce65cb4ab3db7690a
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: riscv64
Maintainer: Jason Witty <jasonpwitty+socktop@proton.me>
Installed-Size: 4068
Depends: libc6:riscv64 (>= 2.39)
Filename: pool/main/socktop-agent_1.50.2-1_riscv64.deb
Size: 1698504
MD5sum: 76249497cbd2bb14e86b0a680a628d22
SHA1: 1b427d86313d2d906886f33f5f274b6515958245
SHA256: 52f17798f0a208a5067a266fad84e535c30137620273a218caac465fb969f48e
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+204
View File
@@ -0,0 +1,204 @@
# Cross-Compiling socktop_agent for Raspberry Pi
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.
## Cross-Compilation Host Setup
Choose your host operating system:
- [Debian/Ubuntu](#debianubuntu-based-systems)
- [Arch Linux](#arch-linux-based-systems)
- [macOS](#macos)
- [Windows](#windows)
## Debian/Ubuntu Based Systems
### Prerequisites
Install the cross-compilation toolchain for your target Raspberry Pi architecture:
```bash
# For 64-bit Raspberry Pi (aarch64)
sudo apt update
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdrm-dev:arm64
# For 32-bit Raspberry Pi (armv7)
sudo apt update
sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross libdrm-dev:armhf
```
### Setup Rust Cross-Compilation Targets
```bash
# For 64-bit Raspberry Pi
rustup target add aarch64-unknown-linux-gnu
# For 32-bit Raspberry Pi
rustup target add armv7-unknown-linux-gnueabihf
```
### Configure Cargo for Cross-Compilation
Create or edit `~/.cargo/config.toml`:
```toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
```
## Arch Linux Based Systems
### Prerequisites
Install the cross-compilation toolchain using pacman and AUR:
```bash
# Install base dependencies
sudo pacman -S base-devel
# For 64-bit Raspberry Pi (aarch64)
sudo pacman -S aarch64-linux-gnu-gcc
# Install libdrm for aarch64 using an AUR helper (e.g., yay, paru)
yay -S aarch64-linux-gnu-libdrm
# For 32-bit Raspberry Pi (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
```bash
# For 64-bit Raspberry Pi
rustup target add aarch64-unknown-linux-gnu
# For 32-bit Raspberry Pi
rustup target add armv7-unknown-linux-gnueabihf
```
### Configure Cargo for Cross-Compilation
Create or edit `~/.cargo/config.toml`:
```toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
```
## macOS
The recommended approach for cross-compiling from macOS is to use Docker:
```bash
# Install Docker
brew install --cask docker
# Pull a cross-compilation Docker image
docker pull messense/rust-musl-cross:armv7-musleabihf # For 32-bit Pi
docker pull messense/rust-musl-cross:aarch64-musl # For 64-bit Pi
```
### Using Docker for Cross-Compilation
```bash
# Navigate to your socktop project directory
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
```
The compiled binaries will be available in your local target directory.
## Windows
The recommended approach for Windows is to use Windows Subsystem for Linux (WSL2):
1. Install WSL2 with a Debian/Ubuntu distribution by following the [official Microsoft documentation](https://docs.microsoft.com/en-us/windows/wsl/install).
2. Once WSL2 is set up with a Debian/Ubuntu distribution, open your WSL terminal and follow the [Debian/Ubuntu instructions](#debianubuntu-based-systems) above.
## Cross-Compile the Agent
After setting up your environment, build the socktop_agent for your target Raspberry Pi:
```bash
# For 64-bit Raspberry Pi
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
```
## Transfer the Binary to Your Raspberry Pi
Use SCP to transfer the compiled binary to your Raspberry Pi:
```bash
# For 64-bit Raspberry Pi
scp target/aarch64-unknown-linux-gnu/release/socktop_agent pi@raspberry-pi-ip:~/
# For 32-bit Raspberry Pi
scp target/armv7-unknown-linux-gnueabihf/release/socktop_agent pi@raspberry-pi-ip:~/
```
Replace `raspberry-pi-ip` with your Raspberry Pi's IP address and `pi` with your username.
## Install Dependencies on the Raspberry Pi
SSH into your Raspberry Pi and install the required dependencies:
```bash
ssh pi@raspberry-pi-ip
# For Raspberry Pi OS (Debian-based)
sudo apt update
sudo apt install libdrm-dev libdrm-amdgpu1
# For Arch Linux ARM
sudo pacman -Syu
sudo pacman -S libdrm
```
## Make the Binary Executable and Install
```bash
chmod +x ~/socktop_agent
# Optional: Install system-wide
sudo install -o root -g root -m 0755 ~/socktop_agent /usr/local/bin/socktop_agent
# Optional: Set up as a systemd service
sudo install -o root -g root -m 0644 ~/socktop-agent.service /etc/systemd/system/socktop-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now socktop-agent
```
## Troubleshooting
If you encounter issues with the cross-compiled binary:
1. **Incorrect Architecture**: Ensure you've chosen the correct target for your Raspberry Pi model:
- For Raspberry Pi 2: use `armv7-unknown-linux-gnueabihf`
- For Raspberry Pi 3/4/5 in 64-bit mode: use `aarch64-unknown-linux-gnu`
- For Raspberry Pi 3/4/5 in 32-bit mode: use `armv7-unknown-linux-gnueabihf`
2. **Dependency Issues**: Check for missing libraries:
```bash
ldd ~/socktop_agent
```
3. **Run with Backtrace**: Get detailed error information:
```bash
RUST_BACKTRACE=1 ~/socktop_agent
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+18
View File
@@ -0,0 +1,18 @@
[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
Restart=on-failure
User=socktop
Group=socktop
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

-364
View File
@@ -1,364 +0,0 @@
<!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>
/* Catppuccin Frappe Color Palette */
:root {
--ctp-base: #303446;
--ctp-mantle: #292c3c;
--ctp-crust: #232634;
--ctp-text: #c6d0f5;
--ctp-subtext1: #b5bfe2;
--ctp-subtext0: #a5adce;
--ctp-overlay2: #949cbb;
--ctp-overlay1: #838ba7;
--ctp-overlay0: #737994;
--ctp-surface2: #626880;
--ctp-surface1: #51576d;
--ctp-surface0: #414559;
--ctp-lavender: #babbf1;
--ctp-blue: #8caaee;
--ctp-sapphire: #85c1dc;
--ctp-sky: #99d1db;
--ctp-teal: #81c8be;
--ctp-green: #a6d189;
--ctp-yellow: #e5c890;
--ctp-peach: #ef9f76;
--ctp-maroon: #ea999c;
--ctp-red: #e78284;
--ctp-mauve: #ca9ee6;
--ctp-pink: #f4b8e4;
--ctp-flamingo: #eebebe;
--ctp-rosewater: #f2d5cf;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background-color: var(--ctp-base);
color: var(--ctp-text);
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background-color: var(--ctp-mantle);
border-radius: 12px;
padding: 40px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
h1 {
color: var(--ctp-blue);
font-size: 2.5em;
margin-bottom: 10px;
border-bottom: 3px solid var(--ctp-surface0);
padding-bottom: 15px;
}
h2 {
color: var(--ctp-mauve);
font-size: 1.8em;
margin-top: 35px;
margin-bottom: 15px;
}
h3 {
color: var(--ctp-sapphire);
font-size: 1.3em;
margin-top: 25px;
margin-bottom: 10px;
}
p {
margin-bottom: 15px;
color: var(--ctp-subtext0);
}
.subtitle {
color: var(--ctp-subtext1);
font-size: 1.2em;
margin-bottom: 30px;
}
code {
background-color: var(--ctp-surface0);
color: var(--ctp-green);
padding: 3px 8px;
border-radius: 5px;
font-family: "Courier New", Courier, monospace;
font-size: 0.95em;
}
pre {
background-color: var(--ctp-crust);
border: 1px solid var(--ctp-surface0);
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin: 15px 0;
position: relative;
padding-top: 18px; /* leave space for top-right button */
}
pre code {
background: transparent;
color: var(--ctp-text);
display: block;
white-space: pre;
font-family: "Courier New", Courier, monospace;
font-size: 0.95em;
}
/* Copy button styles */
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: var(--ctp-surface1);
color: var(--ctp-text);
border: 1px solid var(--ctp-surface2);
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 6px;
transition:
background 0.12s ease,
transform 0.08s ease;
}
.copy-btn:hover {
background: var(--ctp-surface2);
transform: translateY(-1px);
}
.copy-btn:active {
transform: translateY(0);
}
.copy-btn.copied {
background: var(--ctp-green);
color: var(--ctp-crust);
border-color: transparent;
}
.badge {
display: inline-block;
background-color: var(--ctp-surface1);
color: var(--ctp-text);
padding: 5px 12px;
border-radius: 6px;
font-size: 0.9em;
margin: 5px 5px 5px 0;
border: 1px solid var(--ctp-surface2);
}
.badge.arch {
background-color: var(--ctp-surface0);
color: var(--ctp-lavender);
}
.note {
background-color: var(--ctp-surface0);
border-left: 4px solid var(--ctp-yellow);
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.note strong {
color: var(--ctp-yellow);
}
.footer {
margin-top: 50px;
padding-top: 20px;
border-top: 2px solid var(--ctp-surface0);
text-align: center;
color: var(--ctp-overlay1);
font-size: 0.9em;
}
.command-comment {
color: var(--ctp-overlay1);
display: block;
margin-bottom: 6px;
}
.highlight-blue {
color: var(--ctp-blue);
}
.highlight-green {
color: var(--ctp-green);
}
.highlight-yellow {
color: var(--ctp-yellow);
}
.highlight-mauve {
color: var(--ctp-mauve);
}
.highlight-peach {
color: var(--ctp-peach);
}
@media (max-width: 768px) {
.container {
padding: 25px;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
}
</style>
</head>
<body>
<div class="container">
<h1>socktop APT Repository</h1>
<p class="subtitle">
System monitor with remote agent support for Linux systems
</p>
<h2>📦 Quick Installation</h2>
<p>Add this repository to your Debian/Ubuntu system:</p>
<h3>Step 1: Add GPG Key</h3>
<pre><code># Add the repository's GPG signing key
curl -fsSL https://jasonwitty.github.io/socktop/KEY.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/socktop-archive-keyring.gpg</code></pre>
<h3>Step 2: Add Repository</h3>
<pre><code># Add the APT repository to your sources
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</code></pre>
<h3>Step 3: Install</h3>
<pre><code># Update package lists and install
sudo apt update
sudo apt install socktop socktop-agent</code></pre>
<h2>📋 What's Included</h2>
<ul>
<li>
<strong class="highlight-blue">socktop</strong> - Terminal
UI client for monitoring systems
</li>
<li>
<strong class="highlight-mauve">socktop-agent</strong> -
Background agent that reports system metrics
</li>
</ul>
<div class="note">
<strong>Note:</strong> The agent package automatically installs
and configures a systemd service. Enable it with:
<code>sudo systemctl enable --now socktop-agent</code>
</div>
<h2>🏗️ Supported Architectures</h2>
<div>
<span class="badge arch">amd64</span>
<span class="badge arch">arm64</span>
<span class="badge arch">armhf</span>
<span class="badge arch">riscv64</span>
</div>
<h2>🔧 Usage</h2>
<p>After installation:</p>
<pre><code># Start the TUI client
socktop
# Connect to a remote agent
socktop ws://hostname:3000
# Start the agent (if not using systemd)
socktop_agent</code></pre>
<h2>🔗 Links</h2>
<ul>
<li>
<a href="https://github.com/jasonwitty/socktop"
>Source Code on GitHub</a
>
</li>
<li>
<a href="https://github.com/jasonwitty/socktop/issues"
>Report Issues</a
>
</li>
<li><a href="README.md">Repository Documentation</a></li>
</ul>
<div class="footer">
<p>Hosted on GitHub Pages | Packages signed with GPG</p>
<p>
Theme:
<a href="https://github.com/catppuccin/catppuccin"
>Catppuccin Frappe</a
>
</p>
</div>
</div>
<script>
// Attach copy buttons to all <pre> blocks and enable copy-to-clipboard.
document.addEventListener("DOMContentLoaded", function () {
const pres = document.querySelectorAll("pre");
pres.forEach((pre) => {
// Create button
const btn = document.createElement("button");
btn.type = "button";
btn.className = "copy-btn";
btn.setAttribute("aria-label", "Copy code to clipboard");
// Use emoji for simplicity; you can replace with SVG if desired
btn.innerText = "📋";
// Append to pre
pre.appendChild(btn);
// Click handler
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const code = pre.querySelector("code");
const text = code ? code.innerText : pre.innerText;
try {
if (!navigator.clipboard) {
// Fallback method
const textarea =
document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
} else {
await navigator.clipboard.writeText(text);
}
// feedback
btn.classList.add("copied");
const prior = btn.innerText;
btn.innerText = "✓ Copied";
setTimeout(() => {
btn.classList.remove("copied");
btn.innerText = "📋";
}, 1800);
} catch (err) {
btn.innerText = "✖";
setTimeout(() => (btn.innerText = "📋"), 1500);
console.error("Copy failed", err);
}
});
});
});
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
Error: Address already in use (os error 98)
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8433/ws
Error: Address already in use (os error 98)
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
socktop_agent: TLS enabled. Listening on wss://0.0.0.0:8443/ws
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
package socktop;
// All running processes. Sorting is done client-side.
message Processes {
uint64 process_count = 1; // total processes in the system
repeated Process rows = 2; // all processes
}
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3; // 0..100
uint64 mem_bytes = 4; // RSS bytes
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["clippy", "rustfmt"]
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Cross-check Windows build from Linux using the GNU (MinGW) toolchain.
# - Ensures target `x86_64-pc-windows-gnu` is installed
# - Verifies MinGW cross-compiler is available (x86_64-w64-mingw32-gcc)
# - Runs cargo clippy with warnings-as-errors for the Windows target
# - Builds release binaries for the Windows target
echo "[socktop] Windows cross-check: clippy + build (GNU target)"
have() { command -v "$1" >/dev/null 2>&1; }
if ! have rustup; then
echo "error: rustup not found. Install Rust via rustup first (see README)." >&2
exit 1
fi
if ! rustup target list --installed | grep -q '^x86_64-pc-windows-gnu$'; then
echo "+ rustup target add x86_64-pc-windows-gnu"
rustup target add x86_64-pc-windows-gnu
fi
if ! have x86_64-w64-mingw32-gcc; then
echo "error: Missing MinGW cross-compiler (x86_64-w64-mingw32-gcc)." >&2
if have pacman; then
echo "Arch Linux: sudo pacman -S --needed mingw-w64-gcc" >&2
elif have apt-get; then
echo "Debian/Ubuntu: sudo apt-get install -y mingw-w64" >&2
elif have dnf; then
echo "Fedora: sudo dnf install -y mingw64-gcc" >&2
else
echo "Install the mingw-w64 toolchain for your distro, then re-run." >&2
fi
exit 1
fi
CARGO_FLAGS=(--workspace --all-targets --all-features --target x86_64-pc-windows-gnu)
echo "+ cargo clippy ${CARGO_FLAGS[*]} -- -D warnings"
cargo clippy "${CARGO_FLAGS[@]}" -- -D warnings
echo "+ cargo build --release ${CARGO_FLAGS[*]}"
cargo build --release "${CARGO_FLAGS[@]}"
echo "✅ Windows clippy and build completed successfully."
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
# Publish job: "publish new socktop agent version"
# Usage: ./scripts/publish_socktop_agent.sh <new_version>
if [[ ${1:-} == "" ]]; then
echo "Usage: $0 <new_version>" >&2
exit 1
fi
NEW_VERSION="$1"
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
CRATE_DIR="$ROOT_DIR/socktop_agent"
echo "==> Formatting socktop_agent"
(cd "$ROOT_DIR" && cargo fmt -p socktop_agent)
echo "==> Running tests for socktop_agent"
(cd "$ROOT_DIR" && cargo test -p socktop_agent)
echo "==> Running clippy (warnings as errors) for socktop_agent"
(cd "$ROOT_DIR" && cargo clippy -p socktop_agent -- -D warnings)
echo "==> Building release for socktop_agent"
(cd "$ROOT_DIR" && cargo build -p socktop_agent --release)
echo "==> Bumping version to $NEW_VERSION in socktop_agent/Cargo.toml"
sed -i.bak -E "s/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/version = \"$NEW_VERSION\"/" "$CRATE_DIR/Cargo.toml"
rm -f "$CRATE_DIR/Cargo.toml.bak"
echo "==> Committing version bump"
(cd "$ROOT_DIR" && git add -A && git commit -m "socktop_agent: bump version to $NEW_VERSION")
CURRENT_BRANCH=$(cd "$ROOT_DIR" && git rev-parse --abbrev-ref HEAD)
echo "==> Pushing to origin $CURRENT_BRANCH"
(cd "$ROOT_DIR" && git push origin "$CURRENT_BRANCH")
echo "==> Publishing socktop_agent $NEW_VERSION to crates.io"
(cd "$ROOT_DIR" && cargo publish -p socktop_agent)
echo "==> Done: socktop_agent $NEW_VERSION published"
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "socktop"
version = "1.40.0"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Remote system monitor over WebSocket, TUI like top"
edition = "2024"
license = "MIT"
readme = "README.md"
[dependencies]
# socktop connector for agent communication
socktop_connector = { path = "../socktop_connector" }
tokio = { workspace = true }
futures-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
anyhow = { workspace = true }
dirs-next = { workspace = true }
sysinfo = { workspace = true }
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3"
+26
View File
@@ -0,0 +1,26 @@
# socktop (client)
Minimal TUI client for the socktop remote monitoring agent.
Features:
- Connects to a socktop_agent over WebSocket / secure WebSocket
- Displays CPU, memory, swap, disks, network, processes, (optional) GPU metrics
- Selfsigned TLS cert pinning via --tls-ca
- Profile management with saved intervals
- Low CPU usage (request-driven updates)
Quick start:
```
cargo install socktop
socktop ws://HOST:3000/ws
```
With TLS (copy agent cert first):
```
socktop --tls-ca cert.pem wss://HOST:8443/ws
```
Demo mode (spawns a local agent automatically on first run prompt):
```
socktop --demo
```
Full documentation, screenshots, and advanced usage:
https://github.com/jasonwitty/socktop
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
package socktop;
// All running processes. Sorting is done client-side.
message Processes {
uint64 process_count = 1; // total processes in the system
repeated Process rows = 2; // all processes
}
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3; // 0..100
uint64 mem_bytes = 4; // RSS bytes
}
+972
View File
@@ -0,0 +1,972 @@
//! App state and main loop: input handling, fetching metrics, updating history, and drawing.
use std::{
collections::VecDeque,
io,
time::{Duration, Instant},
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
//style::Color, // + add Color
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Rect},
};
use tokio::time::sleep;
use crate::history::{PerCoreHistory, push_capped};
use crate::retry::{RetryTiming, compute_retry_timing};
use crate::types::Metrics;
use crate::ui::cpu::{
PerCoreScrollDrag, draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp,
per_core_content_area, per_core_handle_key, per_core_handle_mouse,
per_core_handle_scrollbar_mouse,
};
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
use crate::ui::processes::{ProcSortBy, processes_handle_key, processes_handle_mouse};
use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
swap::draw_swap,
};
use socktop_connector::{
AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent,
connect_to_socktop_agent_with_tls,
};
// Constants for minimum intervals to ensure reasonable performance
const MIN_METRICS_INTERVAL_MS: u64 = 100;
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
#[derive(Debug, Clone, PartialEq)]
pub enum ConnectionState {
Connected,
Disconnected,
Reconnecting,
}
pub struct App {
// Latest metrics + histories
last_metrics: Option<Metrics>,
// CPU avg history (0..100)
cpu_hist: VecDeque<u64>,
// Per-core history (0..100)
per_core_hist: PerCoreHistory,
// Network totals snapshot + histories of KB/s
last_net_totals: Option<(u64, u64, Instant)>,
rx_hist: VecDeque<u64>,
tx_hist: VecDeque<u64>,
rx_peak: u64,
tx_peak: u64,
// Quit flag
should_quit: bool,
pub per_core_scroll: usize,
pub per_core_drag: Option<PerCoreScrollDrag>, // new: drag state
pub procs_scroll_offset: usize,
pub procs_drag: Option<PerCoreScrollDrag>,
pub procs_sort_by: ProcSortBy,
last_procs_area: Option<ratatui::layout::Rect>,
last_procs_poll: Instant,
last_disks_poll: Instant,
procs_interval: Duration,
disks_interval: Duration,
metrics_interval: Duration,
// For reconnects
ws_url: String,
tls_ca: Option<String>,
verify_hostname: bool,
// Security / status flags
pub is_tls: bool,
pub has_token: bool,
// Modal system
pub modal_manager: crate::ui::modal::ModalManager,
// Connection state tracking
pub connection_state: ConnectionState,
last_connection_attempt: Instant,
original_disconnect_time: Option<Instant>, // Track when we first disconnected
connection_retry_count: u32,
last_auto_retry: Option<Instant>, // Track last automatic retry
replacement_connection: Option<socktop_connector::SocktopConnector>,
}
impl App {
pub fn new() -> Self {
Self {
last_metrics: None,
cpu_hist: VecDeque::with_capacity(600),
per_core_hist: PerCoreHistory::new(60),
last_net_totals: None,
rx_hist: VecDeque::with_capacity(600),
tx_hist: VecDeque::with_capacity(600),
rx_peak: 0,
tx_peak: 0,
should_quit: false,
per_core_scroll: 0,
per_core_drag: None,
procs_scroll_offset: 0,
procs_drag: None,
procs_sort_by: ProcSortBy::CpuDesc,
last_procs_area: None,
last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop
last_disks_poll: Instant::now()
.checked_sub(Duration::from_secs(5))
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
ws_url: String::new(),
tls_ca: None,
verify_hostname: false,
is_tls: false,
has_token: false,
modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(),
original_disconnect_time: None,
connection_retry_count: 0,
last_auto_retry: None,
replacement_connection: None,
}
}
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
metrics_ms.inspect(|&m| {
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
});
procs_ms.inspect(|&p| {
self.procs_interval = Duration::from_millis(p.max(MIN_PROCESSES_INTERVAL_MS));
});
self
}
pub fn with_status(mut self, is_tls: bool, has_token: bool) -> Self {
self.is_tls = is_tls;
self.has_token = has_token;
self
}
/// Show a connection error modal
pub fn show_connection_error(&mut self, message: String) {
if !self.modal_manager.is_active() {
self.connection_state = ConnectionState::Disconnected;
// Set original disconnect time if this is the first disconnect
if self.original_disconnect_time.is_none() {
self.original_disconnect_time = Some(Instant::now());
}
self.modal_manager.push_modal(ModalType::ConnectionError {
message,
disconnected_at: self.original_disconnect_time.unwrap(),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
}
}
/// Attempt to retry the connection
pub async fn retry_connection(&mut self) {
// This method is called from the normal event loop when connection is lost during operation
self.connection_retry_count += 1;
self.last_connection_attempt = Instant::now();
self.connection_state = ConnectionState::Reconnecting;
// Show retrying message
if self.modal_manager.is_active() {
self.modal_manager.pop_modal(); // Remove old modal
}
self.modal_manager.push_modal(ModalType::ConnectionError {
message: "Retrying connection...".to_string(),
disconnected_at: self
.original_disconnect_time
.unwrap_or(self.last_connection_attempt),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
// Actually attempt to reconnect using stored parameters
let tls_ca_ref = self.tls_ca.as_deref();
match self
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
.await
{
Ok(new_ws) => {
// Connection successful! Store the new connection for the event loop to pick up
self.replacement_connection = Some(new_ws);
self.mark_connected();
// The event loop will detect this and restart with the new connection
}
Err(e) => {
// Connection failed, update modal with error
self.modal_manager.pop_modal(); // Remove retrying modal
self.modal_manager.push_modal(ModalType::ConnectionError {
message: format!("Retry failed: {e}"),
disconnected_at: self
.original_disconnect_time
.unwrap_or(self.last_connection_attempt),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
self.connection_state = ConnectionState::Disconnected;
}
}
}
/// Mark connection as successful and dismiss any error modals
pub fn mark_connected(&mut self) {
if self.connection_state != ConnectionState::Connected {
self.connection_state = ConnectionState::Connected;
self.connection_retry_count = 0;
self.original_disconnect_time = None; // Clear the original disconnect time
self.last_auto_retry = None; // Clear auto retry timer
// Remove connection error modal if it exists
if self.modal_manager.is_active() {
self.modal_manager.pop_modal();
}
}
}
/// Compute retry timing using pure policy function.
fn current_retry_timing(&self) -> RetryTiming {
compute_retry_timing(
self.connection_state == ConnectionState::Disconnected,
self.modal_manager.is_active(),
self.original_disconnect_time,
self.last_auto_retry,
Instant::now(),
Duration::from_secs(30),
)
}
/// Check if we should perform an automatic retry (every 30 seconds)
pub fn should_auto_retry(&self) -> bool {
self.current_retry_timing().should_retry_now
}
/// Get seconds until next automatic retry (returns None if inactive)
pub fn seconds_until_next_auto_retry(&self) -> Option<u64> {
self.current_retry_timing().seconds_until_retry
}
/// Perform automatic retry
pub async fn auto_retry_connection(&mut self) {
self.last_auto_retry = Some(Instant::now());
let tls_ca_ref = self.tls_ca.as_deref();
// Increment retry count for auto retries too
self.connection_retry_count += 1;
// Show retrying modal
self.modal_manager.pop_modal();
self.modal_manager.push_modal(ModalType::ConnectionError {
message: "Auto-retrying connection...".to_string(),
disconnected_at: self.original_disconnect_time.unwrap_or(Instant::now()),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
self.connection_state = ConnectionState::Reconnecting;
// Attempt connection
match self
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
.await
{
Ok(new_ws) => {
// Connection successful! Store the new connection for the event loop to pick up
self.replacement_connection = Some(new_ws);
self.mark_connected();
// The event loop will detect this and restart with the new connection
}
Err(e) => {
// Connection failed, update modal with error
self.modal_manager.pop_modal(); // Remove retrying modal
self.modal_manager.push_modal(ModalType::ConnectionError {
message: format!("Auto-retry failed: {e}"),
disconnected_at: self
.original_disconnect_time
.unwrap_or(self.last_connection_attempt),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
self.connection_state = ConnectionState::Disconnected;
}
}
}
pub async fn run(
&mut self,
url: &str,
tls_ca: Option<&str>,
verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> {
self.ws_url = url.to_string();
self.tls_ca = tls_ca.map(|s| s.to_string());
self.verify_hostname = verify_hostname;
// Terminal setup first - so we can show connection error modals
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
// Try to connect to agent
let ws = match self.try_connect(url, tls_ca, verify_hostname).await {
Ok(connector) => connector,
Err(e) => {
// Show initial connection error and enter the error loop until user exits or we connect.
self.show_connection_error(format!("Initial connection failed: {e}"));
if let Err(err) = self
.run_with_connection_error(&mut terminal, url, tls_ca, verify_hostname)
.await
{
// Terminal teardown then propagate error
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return Err(err);
}
// If user chose to exit during error loop, mark quit and teardown.
if self.should_quit || self.connection_state != ConnectionState::Connected {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return Ok(());
}
// We should have a replacement connection after successful retry.
match self.replacement_connection.take() {
Some(conn) => conn,
None => {
// Defensive: no connector despite Connected state; exit gracefully.
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
return Ok(());
}
}
}
};
// Connection successful, mark as connected
self.mark_connected();
// Main loop
let res = self.event_loop(&mut terminal, ws).await;
// Teardown
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
res
}
/// Helper method to attempt connection
async fn try_connect(
&self,
url: &str,
tls_ca: Option<&str>,
verify_hostname: bool,
) -> Result<SocktopConnector, Box<dyn std::error::Error>> {
if let Some(ca_path) = tls_ca {
Ok(connect_to_socktop_agent_with_tls(url, ca_path, verify_hostname).await?)
} else {
Ok(connect_to_socktop_agent(url).await?)
}
}
/// Run the app with a connection error modal from the start
async fn run_with_connection_error<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut Terminal<B>,
_url: &str,
_tls_ca: Option<&str>,
_verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Handle input for modal
while event::poll(Duration::from_millis(10))? {
if let Event::Key(k) = event::read()? {
let action = self.modal_manager.handle_key(k.code);
match action {
ModalAction::ExitApp => {
return Ok(());
}
ModalAction::RetryConnection => {
// Show "Retrying..." message
self.modal_manager.pop_modal(); // Remove old modal
self.modal_manager.push_modal(ModalType::ConnectionError {
message: "Retrying connection...".to_string(),
disconnected_at: self
.original_disconnect_time
.unwrap_or(self.last_connection_attempt),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
// Force a redraw to show the retrying message
terminal.draw(|f| self.draw(f))?;
// Update retry count
self.connection_retry_count += 1;
self.last_connection_attempt = Instant::now();
// Try to reconnect using stored parameters
let tls_ca_ref = self.tls_ca.as_deref();
match self
.try_connect(&self.ws_url, tls_ca_ref, self.verify_hostname)
.await
{
Ok(ws) => {
// Connection successful!
// Show success message briefly
self.modal_manager.pop_modal(); // Remove retrying modal
self.modal_manager.push_modal(ModalType::ConnectionError {
message: "Connection restored! Starting...".to_string(),
disconnected_at: self
.original_disconnect_time
.unwrap_or(self.last_connection_attempt),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
terminal.draw(|f| self.draw(f))?;
sleep(Duration::from_millis(500)).await; // Brief pause to show success
// Explicitly clear all modals first
while self.modal_manager.is_active() {
self.modal_manager.pop_modal();
}
// Mark as connected (this also clears modals but let's be explicit)
self.mark_connected();
// Force a redraw to show the cleared state
terminal.draw(|f| self.draw(f))?;
// Start normal event loop
return self.event_loop(terminal, ws).await;
}
Err(e) => {
// Update modal with new error and retry count
self.modal_manager.pop_modal(); // Remove retrying modal
self.modal_manager.push_modal(ModalType::ConnectionError {
message: format!("Retry failed: {e}"),
disconnected_at: self
.original_disconnect_time
.unwrap_or(self.last_connection_attempt),
retry_count: self.connection_retry_count,
auto_retry_countdown: self.seconds_until_next_auto_retry(),
});
}
}
}
_ => {}
}
}
}
// Check for automatic retry (every 30 seconds)
if self.should_auto_retry() {
self.auto_retry_connection().await;
// If auto-retry succeeded, transition directly into the normal event loop
if let Some(ws) = self.replacement_connection.take() {
// Ensure we are marked connected (auto_retry_connection already does this)
// Start the normal event loop using the newly established connection
return self.event_loop(terminal, ws).await;
}
}
// Update countdown for connection error modal if active
if self.modal_manager.is_active() {
self.modal_manager
.update_connection_error_countdown(self.seconds_until_next_auto_retry());
}
// Draw the modal
terminal.draw(|f| self.draw(f))?;
sleep(Duration::from_millis(50)).await;
}
}
async fn event_loop<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut Terminal<B>,
mut ws: SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Main event loop
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
// Check if we need to restart with a new connection
if let Some(new_ws) = self.replacement_connection.take() {
ws = new_ws;
continue; // Restart the loop with new connection
}
// If we get here and there's no replacement, return the result
return result;
}
}
async fn run_event_loop_iteration<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut Terminal<B>,
ws: &mut SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Input (non-blocking)
while event::poll(Duration::from_millis(10))? {
match event::read()? {
Event::Key(k) => {
// Handle modal input first - if a modal consumes the input, don't process normal keys
if self.modal_manager.is_active() {
let action = self.modal_manager.handle_key(k.code);
match action {
ModalAction::ExitApp => {
self.should_quit = true;
continue; // Skip normal key processing
}
ModalAction::RetryConnection => {
self.retry_connection().await;
// Check if retry succeeded and we have a replacement connection
if self.replacement_connection.is_some() {
// Signal that we want to restart with new connection
// Return from this iteration so the outer loop can restart
return Ok(());
}
continue; // Skip normal key processing
}
ModalAction::Cancel | ModalAction::Dismiss => {
// Modal was dismissed, continue to normal processing
}
ModalAction::Confirm => {
// Handle confirmation action here if needed in the future
}
ModalAction::None => {
// Modal is still active but didn't consume the key
continue; // Skip normal key processing
}
}
}
// Normal key handling (only if no modal is active or modal didn't consume the key)
if matches!(
k.code,
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
) {
self.should_quit = true;
}
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(10),
])
.split(area);
let top = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]);
let content = per_core_content_area(top[1]);
per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize);
let total_rows = self
.last_metrics
.as_ref()
.map(|mm| mm.cpu_per_core.len())
.unwrap_or(0);
per_core_clamp(
&mut self.per_core_scroll,
total_rows,
content.height as usize,
);
if let Some(p_area) = self.last_procs_area {
// page size = visible rows (inner height minus header = 1)
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
processes_handle_key(&mut self.procs_scroll_offset, k, page);
}
}
Event::Mouse(m) => {
// Layout to get areas
let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height);
let rows = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(10),
])
.split(area);
let top = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]);
// Content wheel scrolling
let content = per_core_content_area(top[1]);
per_core_handle_mouse(
&mut self.per_core_scroll,
m,
content,
content.height as usize,
);
// Scrollbar clicks/drag
let total_rows = self
.last_metrics
.as_ref()
.map(|mm| mm.cpu_per_core.len())
.unwrap_or(0);
per_core_handle_scrollbar_mouse(
&mut self.per_core_scroll,
&mut self.per_core_drag,
m,
top[1],
total_rows,
);
// Clamp to bounds
per_core_clamp(
&mut self.per_core_scroll,
total_rows,
content.height as usize,
);
// Processes table: sort by column on header click
if let (Some(mm), Some(p_area)) =
(self.last_metrics.as_ref(), self.last_procs_area)
&& let Some(new_sort) = processes_handle_mouse(
&mut self.procs_scroll_offset,
&mut self.procs_drag,
m,
p_area,
mm.top_processes.len(),
)
{
self.procs_sort_by = new_sort;
}
}
Event::Resize(_, _) => {}
_ => {}
}
}
// Check for automatic retry (every 30 seconds)
if self.should_auto_retry() {
self.auto_retry_connection().await;
// Check if retry succeeded and we have a replacement connection
if self.replacement_connection.is_some() {
// Signal that we want to restart with new connection
return Ok(());
}
}
if self.should_quit {
break;
}
// Fetch and update
match ws.request(AgentRequest::Metrics).await {
Ok(AgentResponse::Metrics(m)) => {
self.mark_connected(); // Mark as connected on successful request
self.update_with_metrics(m);
// Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval {
if let Ok(AgentResponse::Processes(procs)) =
ws.request(AgentRequest::Processes).await
&& let Some(mm) = self.last_metrics.as_mut()
{
mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count);
}
self.last_procs_poll = Instant::now();
}
// Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Ok(AgentResponse::Disks(disks)) =
ws.request(AgentRequest::Disks).await
&& let Some(mm) = self.last_metrics.as_mut()
{
mm.disks = disks;
}
self.last_disks_poll = Instant::now();
}
}
Err(e) => {
// Connection error - show modal if not already shown
let error_message = format!("Failed to fetch metrics: {e}");
self.show_connection_error(error_message);
}
_ => {
// Unexpected response type
self.show_connection_error("Unexpected response from agent".to_string());
}
}
// Update countdown for connection error modal if active
if self.modal_manager.is_active() {
self.modal_manager
.update_connection_error_countdown(self.seconds_until_next_auto_retry());
}
// Draw
terminal.draw(|f| self.draw(f))?;
// Tick rate
sleep(self.metrics_interval).await;
}
Ok(())
}
fn update_with_metrics(&mut self, mut m: Metrics) {
if let Some(prev) = &self.last_metrics {
// Preserve slower fields when the fast payload omits them
if m.disks.is_empty() {
m.disks = prev.disks.clone();
}
if m.top_processes.is_empty() {
m.top_processes = prev.top_processes.clone();
}
// Preserve total processes count across fast updates
if m.process_count.is_none() {
m.process_count = prev.process_count;
}
}
// CPU avg history
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.cpu_hist, v, 600);
// Per-core history (push current samples)
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
self.per_core_hist.push_samples(&m.cpu_per_core);
// NET: sum across all ifaces, compute KB/s via elapsed time
let now = Instant::now();
let rx_total = m.networks.iter().map(|n| n.received).sum::<u64>();
let tx_total = m.networks.iter().map(|n| n.transmitted).sum::<u64>();
let (rx_kb, tx_kb) = if let Some((prx, ptx, pts)) = self.last_net_totals {
let dt = now.duration_since(pts).as_secs_f64().max(1e-6);
let rx = ((rx_total.saturating_sub(prx)) as f64 / dt / 1024.0).round() as u64;
let tx = ((tx_total.saturating_sub(ptx)) as f64 / dt / 1024.0).round() as u64;
(rx, tx)
} else {
(0, 0)
};
self.last_net_totals = Some((rx_total, tx_total, now));
push_capped(&mut self.rx_hist, rx_kb, 600);
push_capped(&mut self.tx_hist, tx_kb, 600);
self.rx_peak = self.rx_peak.max(rx_kb);
self.tx_peak = self.tx_peak.max(tx_kb);
// Store merged snapshot
self.last_metrics = Some(m);
}
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
let area = f.area();
// Root rows: header, top (cpu avg + per-core), memory, swap, bottom
let rows = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Ratio(1, 3), // top row
Constraint::Length(3), // memory (left) + GPU (right, part 1)
Constraint::Length(3), // swap (left) + GPU (right, part 2)
Constraint::Min(10), // bottom: disks + net (left), top procs (right)
])
.split(area);
// Header
draw_header(
f,
rows[0],
self.last_metrics.as_ref(),
self.is_tls,
self.has_token,
self.metrics_interval,
self.procs_interval,
);
// Top row: left CPU avg, right Per-core (full top-right)
let top_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[1]);
draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
draw_per_core_bars(
f,
top_lr[1],
self.last_metrics.as_ref(),
&self.per_core_hist,
self.per_core_scroll,
);
// Memory + Swap rows split into left/right columns
let mem_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[2]);
let swap_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.split(rows[3]);
// Left: Memory + Swap
draw_mem(f, mem_lr[0], self.last_metrics.as_ref());
draw_swap(f, swap_lr[0], self.last_metrics.as_ref());
// Right: GPU spans the same vertical space as Memory + Swap
let gpu_area = ratatui::layout::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,
};
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
// Bottom area: left = Disks + Network, right = Top Processes
let bottom_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(rows[4]);
// Left bottom: Disks + Net stacked (make net panes slightly taller)
let left_stack = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(4), // Disks shrink slightly
Constraint::Length(5), // Download taller
Constraint::Length(5), // Upload taller
])
.split(bottom_lr[0]);
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
draw_net_spark(
f,
left_stack[1],
&format!(
"Download (KB/s) — now: {} | peak: {}",
self.rx_hist.back().copied().unwrap_or(0),
self.rx_peak
),
&self.rx_hist,
ratatui::style::Color::Green,
);
draw_net_spark(
f,
left_stack[2],
&format!(
"Upload (KB/s) — now: {} | peak: {}",
self.tx_hist.back().copied().unwrap_or(0),
self.tx_peak
),
&self.tx_hist,
ratatui::style::Color::Blue,
);
// Right bottom: Top Processes fills the column
let procs_area = bottom_lr[1];
// Cache for input handlers
self.last_procs_area = Some(procs_area);
crate::ui::processes::draw_top_processes(
f,
procs_area,
self.last_metrics.as_ref(),
self.procs_scroll_offset,
self.procs_sort_by,
);
// Render modals on top of everything else
if self.modal_manager.is_active() {
self.modal_manager.render(f);
}
}
}
impl Default for App {
fn default() -> Self {
Self {
last_metrics: None,
cpu_hist: VecDeque::with_capacity(600),
per_core_hist: PerCoreHistory::new(60),
last_net_totals: None,
rx_hist: VecDeque::with_capacity(600),
tx_hist: VecDeque::with_capacity(600),
rx_peak: 0,
tx_peak: 0,
should_quit: false,
per_core_scroll: 0,
per_core_drag: None,
procs_scroll_offset: 0,
procs_drag: None,
procs_sort_by: ProcSortBy::CpuDesc,
last_procs_area: None,
last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop
last_disks_poll: Instant::now()
.checked_sub(Duration::from_secs(5))
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
metrics_interval: Duration::from_millis(500),
ws_url: String::new(),
tls_ca: None,
verify_hostname: false,
is_tls: false,
has_token: false,
modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(),
original_disconnect_time: None,
connection_retry_count: 0,
last_auto_retry: None,
replacement_connection: None,
}
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Small utilities to manage bounded history buffers for charts.
use std::collections::VecDeque;
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
if dq.len() == cap {
dq.pop_front();
}
dq.push_back(v);
}
// Keeps a history deque per core with a fixed capacity
pub struct PerCoreHistory {
pub deques: Vec<VecDeque<u16>>,
cap: usize,
}
impl PerCoreHistory {
pub fn new(cap: usize) -> Self {
Self {
deques: Vec::new(),
cap,
}
}
// Ensure we have one deque per core; resize on CPU topology changes
pub fn ensure_cores(&mut self, n: usize) {
if self.deques.len() == n {
return;
}
self.deques = (0..n).map(|_| VecDeque::with_capacity(self.cap)).collect();
}
// Push a new sample set for all cores (values 0..=100)
pub fn push_samples(&mut self, samples: &[f32]) {
self.ensure_cores(samples.len());
for (i, v) in samples.iter().enumerate() {
let val = v.clamp(0.0, 100.0).round() as u16;
push_capped(&mut self.deques[i], val, self.cap);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Library surface for integration tests and reuse.
pub mod types;
// Re-export connector functionality
pub use socktop_connector::{SocktopConnector, connect_to_socktop_agent};
+433
View File
@@ -0,0 +1,433 @@
//! Entry point for the socktop TUI. Parses args and runs the App.
mod app;
mod history;
mod profiles;
mod retry;
mod types;
mod ui; // pure retry timing logic
use app::App;
use profiles::{ProfileEntry, ProfileRequest, ResolveProfile, load_profiles, save_profiles};
use std::env;
use std::io::{self, Write};
pub(crate) struct ParsedArgs {
url: Option<String>,
tls_ca: Option<String>,
profile: Option<String>,
save: bool,
demo: bool,
dry_run: bool, // hidden test helper: skip connecting
metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>,
verify_hostname: bool,
}
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
let mut it = args.into_iter();
let prog = it.next().unwrap_or_else(|| "socktop".into());
let mut url: Option<String> = None;
let mut tls_ca: Option<String> = None;
let mut profile: Option<String> = None;
let mut save = false;
let mut demo = false;
let mut dry_run = false;
let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = 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"
));
}
"--tls-ca" | "-t" => {
tls_ca = it.next();
}
"--verify-hostname" => {
// opt-in hostname (SAN) verification
// default behavior is to skip it for easier home network usage
// (still pins the provided certificate)
verify_hostname = true;
}
"--profile" | "-P" => {
profile = it.next();
}
"--save" => {
save = true;
}
"--demo" => {
demo = true;
}
"--dry-run" => {
// intentionally undocumented
dry_run = true;
}
"--metrics-interval-ms" => {
metrics_interval_ms = it.next().and_then(|v| v.parse().ok());
}
"--processes-interval-ms" => {
processes_interval_ms = it.next().and_then(|v| v.parse().ok());
}
_ if arg.starts_with("--tls-ca=") => {
if let Some((_, v)) = arg.split_once('=')
&& !v.is_empty()
{
tls_ca = Some(v.to_string());
}
}
_ if arg.starts_with("--profile=") => {
if let Some((_, v)) = arg.split_once('=')
&& !v.is_empty()
{
profile = Some(v.to_string());
}
}
_ if arg.starts_with("--metrics-interval-ms=") => {
if let Some((_, v)) = arg.split_once('=') {
metrics_interval_ms = v.parse().ok();
}
}
_ if arg.starts_with("--processes-interval-ms=") => {
if let Some((_, v)) = arg.split_once('=') {
processes_interval_ms = v.parse().ok();
}
}
_ => {
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]"
));
}
}
}
}
Ok(ParsedArgs {
url,
tls_ca,
profile,
save,
demo,
dry_run,
metrics_interval_ms,
processes_interval_ms,
verify_hostname,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let parsed = match parse_args(env::args()) {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
return Ok(());
}
};
//support version flag (print and exit)
if env::args().any(|a| a == "--version" || a == "-V") {
println!("socktop {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref()).await;
}
let profiles_file = load_profiles();
let req = ProfileRequest {
profile_name: parsed.profile.clone(),
url: parsed.url.clone(),
tls_ca: parsed.tls_ca.clone(),
};
let resolved = req.resolve(&profiles_file);
let mut profiles_mut = profiles_file.clone();
let (url, tls_ca, metrics_interval_ms, processes_interval_ms): (
String,
Option<String>,
Option<u64>,
Option<u64>,
) = match resolved {
ResolveProfile::Direct(u, t) => {
if let Some(name) = parsed.profile.as_ref() {
let existing = profiles_mut.profiles.get(name);
match existing {
None => {
let (mi, pi) = gather_intervals(
parsed.metrics_interval_ms,
parsed.processes_interval_ms,
)?;
profiles_mut.profiles.insert(
name.clone(),
ProfileEntry {
url: u.clone(),
tls_ca: t.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
},
);
let _ = save_profiles(&profiles_mut);
(u, t, mi, pi)
}
Some(entry) => {
let changed = entry.url != u || entry.tls_ca != t;
if changed {
let overwrite = if parsed.save {
true
} else {
prompt_yes_no(&format!(
"Overwrite existing profile '{name}'? [y/N]: "
))
};
if overwrite {
let (mi, pi) = gather_intervals(
parsed.metrics_interval_ms,
parsed.processes_interval_ms,
)?;
profiles_mut.profiles.insert(
name.clone(),
ProfileEntry {
url: u.clone(),
tls_ca: t.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
},
);
let _ = save_profiles(&profiles_mut);
(u, t, mi, pi)
} else {
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
}
} else {
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
}
}
}
} else {
(
u,
t,
parsed.metrics_interval_ms,
parsed.processes_interval_ms,
)
}
}
ResolveProfile::Loaded(u, t) => {
let entry = profiles_mut
.profiles
.get(parsed.profile.as_ref().unwrap())
.unwrap();
(u, t, entry.metrics_interval_ms, entry.processes_interval_ms)
}
ResolveProfile::PromptSelect(mut names) => {
if !names.iter().any(|n: &String| n == "demo") {
names.push("demo".into());
}
eprintln!("Select profile:");
for (i, n) in names.iter().enumerate() {
eprintln!(" {}. {}", i + 1, n);
}
eprint!("Enter number (or blank to abort): ");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_ok() {
if let Ok(idx) = line.trim().parse::<usize>() {
if (1..=names.len()).contains(&idx) {
let name = &names[idx - 1];
if name == "demo" {
return run_demo_mode(parsed.tls_ca.as_deref()).await;
}
if let Some(entry) = profiles_mut.profiles.get(name) {
(
entry.url.clone(),
entry.tls_ca.clone(),
entry.metrics_interval_ms,
entry.processes_interval_ms,
)
} else {
return Ok(());
}
} else {
return Ok(());
}
} else {
return Ok(());
}
} else {
return Ok(());
}
}
ResolveProfile::PromptCreate(name) => {
eprintln!("Profile '{name}' does not exist yet.");
let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?;
if url.trim().is_empty() {
return Ok(());
}
let ca = prompt_string("Enter TLS CA path (or leave blank): ")?;
let ca_opt = if ca.trim().is_empty() {
None
} else {
Some(ca.trim().to_string())
};
let (mi, pi) =
gather_intervals(parsed.metrics_interval_ms, parsed.processes_interval_ms)?;
profiles_mut.profiles.insert(
name.clone(),
ProfileEntry {
url: url.trim().to_string(),
tls_ca: ca_opt.clone(),
metrics_interval_ms: mi,
processes_interval_ms: pi,
},
);
let _ = save_profiles(&profiles_mut);
(url.trim().to_string(), ca_opt, mi, pi)
}
ResolveProfile::None => {
//eprintln!("No URL provided and no profiles to select.");
//first run, no args, no profiles: show welcome message and offer demo mode
if profiles_mut.profiles.is_empty() && parsed.url.is_none() {
eprintln!("Welcome to socktop!");
eprintln!("It looks like this is your first time running the application.");
eprintln!(
"You can connect to a socktop_agent instance to monitor system metrics and processes."
);
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;
} else {
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(());
}
}
return Err("No URL provided and no profiles to select.".into());
}
};
let is_tls = url.starts_with("wss://");
let has_token = url.contains("token=");
let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms)
.with_status(is_tls, has_token);
if parsed.dry_run {
return Ok(());
}
app.run(&url, tls_ca.as_deref(), parsed.verify_hostname)
.await
}
fn prompt_yes_no(prompt: &str) -> bool {
eprint!("{prompt}");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_ok() {
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
} else {
false
}
}
fn prompt_string(prompt: &str) -> io::Result<String> {
eprint!("{prompt}");
let _ = io::stderr().flush();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(line)
}
fn gather_intervals(
arg_metrics: Option<u64>,
arg_procs: Option<u64>,
) -> Result<(Option<u64>, Option<u64>), Box<dyn std::error::Error>> {
let default_metrics = 500u64;
let default_procs = 2000u64;
let metrics = match arg_metrics {
Some(v) => Some(v),
None => {
let inp = prompt_string(&format!(
"Metrics interval ms (default {default_metrics}, Enter for default): "
))?;
let t = inp.trim();
if t.is_empty() {
Some(default_metrics)
} else {
Some(t.parse()?)
}
}
};
let procs = match arg_procs {
Some(v) => Some(v),
None => {
let inp = prompt_string(&format!(
"Processes interval ms (default {default_procs}, Enter for default): "
))?;
let t = inp.trim();
if t.is_empty() {
Some(default_procs)
} else {
Some(t.parse()?)
}
}
};
Ok((metrics, procs))
}
// Demo mode implementation
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws");
let child = spawn_demo_agent(port)?;
let mut app = App::new();
// Demo mode connects to localhost, so disable hostname verification
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
}
struct DemoGuard {
port: u16,
child: std::sync::Arc<std::sync::Mutex<Option<std::process::Child>>>,
}
impl Drop for DemoGuard {
fn drop(&mut self) {
if let Some(mut ch) = self.child.lock().unwrap().take() {
let _ = ch.kill();
}
eprintln!("Stopped demo agent on port {}", self.port);
}
}
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
let candidate = find_agent_executable();
let mut cmd = std::process::Command::new(candidate);
cmd.arg("--port").arg(port.to_string());
cmd.env("SOCKTOP_ENABLE_SSL", "0");
//JW: do not disable GPU and TEMP in demo mode
//cmd.env("SOCKTOP_AGENT_GPU", "0");
//cmd.env("SOCKTOP_AGENT_TEMP", "0");
let child = cmd.spawn()?;
std::thread::sleep(std::time::Duration::from_millis(300));
Ok(DemoGuard {
port,
child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))),
})
}
fn find_agent_executable() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
#[cfg(windows)]
let name = "socktop_agent.exe";
#[cfg(not(windows))]
let name = "socktop_agent";
let candidate = parent.join(name);
if candidate.exists() {
return candidate;
}
}
std::path::PathBuf::from("socktop_agent")
}
+103
View File
@@ -0,0 +1,103 @@
//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca }
//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json)
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileEntry {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls_ca: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metrics_interval_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub processes_interval_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfilesFile {
#[serde(default)]
pub profiles: BTreeMap<String, ProfileEntry>,
#[serde(default)]
pub version: u32,
}
pub fn config_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("socktop")
} else {
dirs_next::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("socktop")
}
}
pub fn profiles_path() -> PathBuf {
config_dir().join("profiles.json")
}
pub fn load_profiles() -> ProfilesFile {
let path = profiles_path();
match fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ProfilesFile::default(),
}
}
pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> {
let path = profiles_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(p).expect("serialize profiles");
fs::write(path, data)
}
pub enum ResolveProfile {
/// Use the provided runtime inputs (not persisted). (url, tls_ca)
Direct(String, Option<String>),
/// Loaded from existing profile entry (url, tls_ca)
Loaded(String, Option<String>),
/// Should prompt user to select among profile names
PromptSelect(Vec<String>),
/// Should prompt user to create a new profile (name)
PromptCreate(String),
/// No profile could be resolved (e.g., missing arguments)
None,
}
pub struct ProfileRequest {
pub profile_name: Option<String>,
pub url: Option<String>,
pub tls_ca: Option<String>,
}
impl ProfileRequest {
pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile {
// Case: only profile name given -> try load
if self.url.is_none() && self.profile_name.is_some() {
let Some(name) = self.profile_name else {
unreachable!("Already checked profile_name.is_some()")
};
let Some(entry) = pf.profiles.get(&name) else {
return ResolveProfile::PromptCreate(name);
};
return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone());
}
// Both provided -> direct (maybe later saved by caller)
if let Some(u) = self.url {
return ResolveProfile::Direct(u, self.tls_ca);
}
// Nothing provided -> maybe prompt select if profiles exist
if self.url.is_none() && self.profile_name.is_none() {
if pf.profiles.is_empty() {
ResolveProfile::None
} else {
ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect())
}
} else {
ResolveProfile::None
}
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Pure retry timing logic (decoupled from App state / UI) for testability.
use std::time::{Duration, Instant};
/// Result of computing retry timing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryTiming {
pub should_retry_now: bool,
/// Seconds until next retry (Some(0) means ready now); None means inactive/no countdown.
pub seconds_until_retry: Option<u64>,
}
/// Compute retry timing given connection state inputs.
///
/// Inputs:
/// - `disconnected`: true when connection_state == Disconnected.
/// - `modal_active`: requires the connection error modal be visible to show countdown / trigger auto retry.
/// - `original_disconnect_time`: time we first noticed disconnect.
/// - `last_auto_retry`: time we last performed an automatic retry.
/// - `now`: current time (injected for determinism / tests).
/// - `interval`: retry interval duration.
pub(crate) fn compute_retry_timing(
disconnected: bool,
modal_active: bool,
original_disconnect_time: Option<Instant>,
last_auto_retry: Option<Instant>,
now: Instant,
interval: Duration,
) -> RetryTiming {
if !disconnected || !modal_active {
return RetryTiming {
should_retry_now: false,
seconds_until_retry: None,
};
}
let baseline = match last_auto_retry.or(original_disconnect_time) {
Some(b) => b,
None => {
return RetryTiming {
should_retry_now: false,
seconds_until_retry: None,
};
}
};
let elapsed = now.saturating_duration_since(baseline);
if elapsed >= interval {
RetryTiming {
should_retry_now: true,
seconds_until_retry: Some(0),
}
} else {
let remaining = interval - elapsed;
RetryTiming {
should_retry_now: false,
seconds_until_retry: Some(remaining.as_secs()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inactive_when_not_disconnected() {
let now = Instant::now();
let rt = compute_retry_timing(false, true, Some(now), None, now, Duration::from_secs(30));
assert!(!rt.should_retry_now);
assert_eq!(rt.seconds_until_retry, None);
}
#[test]
fn countdown_progress_and_ready() {
let base = Instant::now();
let rt1 = compute_retry_timing(
true,
true,
Some(base),
None,
base + Duration::from_secs(10),
Duration::from_secs(30),
);
assert!(!rt1.should_retry_now);
assert_eq!(rt1.seconds_until_retry, Some(20));
let rt2 = compute_retry_timing(
true,
true,
Some(base),
None,
base + Duration::from_secs(30),
Duration::from_secs(30),
);
assert!(rt2.should_retry_now);
assert_eq!(rt2.seconds_until_retry, Some(0));
}
#[test]
fn uses_last_auto_retry_as_baseline() {
let base: Instant = Instant::now();
let last = base + Duration::from_secs(30); // one prior retry
// 10s after last retry => 20s remaining
let rt = compute_retry_timing(
true,
true,
Some(base),
Some(last),
last + Duration::from_secs(10),
Duration::from_secs(30),
);
assert!(!rt.should_retry_now);
assert_eq!(rt.seconds_until_retry, Some(20));
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Types that mirror the agent's JSON schema.
// Re-export commonly used types from socktop_connector
pub use socktop_connector::Metrics;
+393
View File
@@ -0,0 +1,393 @@
//! CPU average sparkline + per-core mini bars.
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::style::Modifier;
use ratatui::style::{Color, Style};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Sparkline},
};
use crate::history::PerCoreHistory;
use crate::types::Metrics;
/// State for dragging the scrollbar thumb
#[derive(Clone, Copy, Debug, Default)]
pub struct PerCoreScrollDrag {
pub active: bool,
pub start_y: u16, // mouse row where drag started
pub start_top: usize, // thumb top (in track rows) at drag start
}
/// Returns the content area for per-core CPU bars, excluding borders and reserving space for scrollbar.
pub fn per_core_content_area(area: Rect) -> Rect {
// Inner minus borders
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
// Reserve 1 column on the right for a gutter and 1 for the scrollbar
Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
}
}
/// Handles key events for per-core CPU bars.
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
match key.code {
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
KeyCode::PageUp => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_sub(step);
}
KeyCode::PageDown => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_add(step);
}
KeyCode::Home => *scroll_offset = 0,
KeyCode::End => *scroll_offset = usize::MAX, // draw() clamps to max
_ => {}
}
}
/// Handles mouse wheel over the content.
pub fn per_core_handle_mouse(
scroll_offset: &mut usize,
mouse: MouseEvent,
content_area: Rect,
page_size: usize,
) {
let inside = mouse.column >= content_area.x
&& mouse.column < content_area.x + content_area.width
&& mouse.row >= content_area.y
&& mouse.row < content_area.y + content_area.height;
if !inside {
return;
}
match mouse.kind {
MouseEventKind::ScrollUp => *scroll_offset = scroll_offset.saturating_sub(1),
MouseEventKind::ScrollDown => *scroll_offset = scroll_offset.saturating_add(1),
// Optional paging via horizontal wheel
MouseEventKind::ScrollLeft => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_sub(step);
}
MouseEventKind::ScrollRight => {
let step = page_size.max(1);
*scroll_offset = scroll_offset.saturating_add(step);
}
_ => {}
}
}
/// Handles mouse interaction with the scrollbar itself (click arrows/page/drag).
pub fn per_core_handle_scrollbar_mouse(
scroll_offset: &mut usize,
drag: &mut Option<PerCoreScrollDrag>,
mouse: MouseEvent,
per_core_area: Rect,
total_rows: usize,
) {
// Geometry
let inner = Rect {
x: per_core_area.x + 1,
y: per_core_area.y + 1,
width: per_core_area.width.saturating_sub(2),
height: per_core_area.height.saturating_sub(2),
};
if inner.height < 3 || inner.width < 1 {
return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
let viewport_rows = content.height as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let mut offset = (*scroll_offset).min(max_off);
// Track and current thumb
let track = (scroll_area.height - 2) as usize;
if track == 0 {
return;
}
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let top_for_offset = |off: usize| -> usize {
if max_off == 0 {
0
} else {
((track - thumb_len) * off + max_off / 2) / max_off
}
};
let thumb_top = top_for_offset(offset);
let inside_scrollbar = mouse.column == scroll_area.x
&& mouse.row >= scroll_area.y
&& mouse.row < scroll_area.y + scroll_area.height;
// Helper to page
let page_up = || offset.saturating_sub(view.max(1));
let page_down = || offset.saturating_add(view.max(1));
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) if inside_scrollbar => {
// Where within the track?
let row = mouse.row;
if row == scroll_area.y {
// Top arrow
offset = offset.saturating_sub(1);
} else if row + 1 == scroll_area.y + scroll_area.height {
// Bottom arrow
offset = offset.saturating_add(1);
} else {
// Inside track
let rel = (row - (scroll_area.y + 1)) as usize;
let thumb_end = thumb_top + thumb_len;
if rel < thumb_top {
// Page up
offset = page_up();
} else if rel >= thumb_end {
// Page down
offset = page_down();
} else {
// Start dragging
*drag = Some(PerCoreScrollDrag {
active: true,
start_y: row,
start_top: thumb_top,
});
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(mut d) = drag.take()
&& d.active
{
let dy = (mouse.row as i32) - (d.start_y as i32);
let new_top = (d.start_top as i32 + dy)
.clamp(0, (track.saturating_sub(thumb_len)) as i32)
as usize;
// Inverse mapping top -> offset
if track > thumb_len {
let denom = track - thumb_len;
offset = if max_off == 0 {
0
} else {
(new_top * max_off + denom / 2) / denom
};
} else {
offset = 0;
}
// Keep dragging
d.start_top = new_top;
d.start_y = mouse.row;
*drag = Some(d);
}
}
MouseEventKind::Up(MouseButton::Left) => {
// End drag
*drag = None;
}
// Also allow wheel scrolling when cursor is over the scrollbar
MouseEventKind::ScrollUp if inside_scrollbar => {
offset = offset.saturating_sub(1);
}
MouseEventKind::ScrollDown if inside_scrollbar => {
offset = offset.saturating_add(1);
}
_ => {}
}
// Clamp and write back
if offset > max_off {
offset = max_off;
}
*scroll_offset = offset;
}
/// Clamp scroll offset to the valid range given content and viewport.
pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_rows: usize) {
let max_offset = total_rows.saturating_sub(viewport_rows);
if *scroll_offset > max_offset {
*scroll_offset = max_offset;
}
}
/// Draws the CPU average sparkline graph.
pub fn draw_cpu_avg_graph(
f: &mut ratatui::Frame<'_>,
area: Rect,
hist: &std::collections::VecDeque<u64>,
m: Option<&Metrics>,
) {
let title = if let Some(mm) = m {
format!("CPU avg (now: {:>5.1}%)", mm.cpu_total)
} else {
"CPU avg".into()
};
let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
let spark = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(title))
.data(&data)
.max(100)
.style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area);
}
/// Draws the per-core CPU bars with sparklines and trends.
pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
per_core_hist: &PerCoreHistory,
scroll_offset: usize,
) {
f.render_widget(
Block::default().borders(Borders::ALL).title("Per-core"),
area,
);
let Some(mm) = m else {
return;
};
// Compute inner rect and content area
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;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
let total_rows = mm.cpu_per_core.len();
let viewport_rows = content.height as usize;
let max_offset = total_rows.saturating_sub(viewport_rows);
let offset = scroll_offset.min(max_offset);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
let constraints: Vec<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect();
let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(content);
for i in 0..show_n {
let idx = offset + i;
let rect = vchunks[i];
let hchunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(6), Constraint::Length(12)])
.split(rect);
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
let older = per_core_hist
.deques
.get(idx)
.and_then(|d| d.iter().rev().nth(20).copied())
.map(|v| v as f32)
.unwrap_or(curr);
let trend = if curr > older + 0.2 {
""
} else if curr + 0.2 < older {
""
} else {
""
};
let fg = match curr {
x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow,
_ => Color::Red,
};
let hist: Vec<u64> = per_core_hist
.deques
.get(idx)
.map(|d| {
let max_points = hchunks[0].width as usize;
let start = d.len().saturating_sub(max_points);
d.iter().skip(start).map(|&v| v as u64).collect()
})
.unwrap_or_default();
let spark = Sparkline::default()
.data(&hist)
.max(100)
.style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]);
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
let line = Line::from(Span::styled(
label,
Style::default().fg(fg).add_modifier(Modifier::BOLD),
));
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
}
// Custom 1-col scrollbar with arrows, track, and exact mapping
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
if scroll_area.height >= 3 {
let track = (scroll_area.height - 2) as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let thumb_top = if max_off == 0 {
0
} else {
((track - thumb_len) * offset + max_off / 2) / max_off
};
// Build lines: top arrow, track (with thumb), bottom arrow
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
for i in 0..track {
if i >= thumb_top && i < thumb_top + thumb_len {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB))));
} else {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK))));
}
}
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
f.render_widget(Paragraph::new(lines), scroll_area);
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Disk cards with per-device gauge and title line.
use crate::types::Metrics;
use crate::ui::util::{disk_icon, human, truncate_middle};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Block, Borders, Gauge},
};
pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
f.render_widget(Block::default().borders(Borders::ALL).title("Disks"), area);
let Some(mm) = m else {
return;
};
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 < 3 {
return;
}
let per_disk_h = 3u16;
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
let constraints: Vec<Constraint> = (0..max_cards)
.map(|_| Constraint::Length(per_disk_h))
.collect();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
for (i, slot) in rows.iter().enumerate() {
let d = &mm.disks[i];
let used = d.total.saturating_sub(d.available);
let ratio = if d.total > 0 {
used as f64 / d.total as f64
} else {
0.0
};
let pct = (ratio * 100.0).round() as u16;
let color = if pct < 70 {
ratatui::style::Color::Green
} else if pct < 90 {
ratatui::style::Color::Yellow
} else {
ratatui::style::Color::Red
};
let title = format!(
"{} {} {} / {} ({}%)",
disk_icon(&d.name),
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
human(used),
human(d.total),
pct
);
let card = Block::default().borders(Borders::ALL).title(title);
f.render_widget(card, *slot);
let inner_card = Rect {
x: slot.x + 1,
y: slot.y + 1,
width: slot.width.saturating_sub(2),
height: slot.height.saturating_sub(2),
};
if inner_card.height == 0 {
continue;
}
let gauge_rect = Rect {
x: inner_card.x,
y: inner_card.y + inner_card.height / 2,
width: inner_card.width,
height: 1,
};
let g = Gauge::default()
.percent(pct)
.gauge_style(Style::default().fg(color));
f.render_widget(g, gauge_rect);
}
}
+123
View File
@@ -0,0 +1,123 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::Span,
widgets::{Block, Borders, Gauge, Paragraph},
};
use crate::types::Metrics;
fn fmt_bytes(b: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let fb = b as f64;
if fb >= GB {
format!("{:.1}G", fb / GB)
} else if fb >= MB {
format!("{:.1}M", fb / MB)
} else if fb >= KB {
format!("{:.1}K", fb / KB)
} else {
format!("{b}B")
}
}
pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let mut area = area;
let block = Block::default().borders(Borders::ALL).title("GPU");
f.render_widget(block, area);
// Guard: need some space inside the block
if area.height <= 2 || area.width <= 2 {
return;
}
// Inner padding consistent with the rest of the app
area.y += 1;
area.height = area.height.saturating_sub(2);
area.x += 1;
area.width = area.width.saturating_sub(2);
let Some(metrics) = m else {
return;
};
let Some(gpus) = metrics.gpus.as_ref() else {
f.render_widget(Paragraph::new("No GPUs"), area);
return;
};
if gpus.is_empty() {
f.render_widget(Paragraph::new("No GPUs"), area);
return;
}
// Show 3 rows per GPU: name, util bar, vram bar.
if area.height < 3 {
return;
}
let per_gpu_rows: u16 = 3;
let max_gpus = (area.height / per_gpu_rows) as usize;
let count = gpus.len().min(max_gpus);
let constraints = vec![Constraint::Length(1); count * per_gpu_rows as usize];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
// Per bar horizontal layout: [gauge] [value]
let split_bar = |r: Rect| {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(8), // gauge column
Constraint::Length(24), // value column
])
.split(r)
};
for i in 0..count {
let g = &gpus[i];
// Row 1: GPU name
let name_text = g.name.as_deref().unwrap_or("GPU");
let name_p = Paragraph::new(Span::raw(name_text)).style(Style::default().fg(Color::Gray));
f.render_widget(name_p, rows[i * 3]);
// Row 2: Utilization bar + right label
let util_cols = split_bar(rows[i * 3 + 1]);
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let util_gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
.ratio(util as f64 / 100.0);
f.render_widget(util_gauge, util_cols[0]);
f.render_widget(
Paragraph::new(Span::raw(format!("util: {util}%")))
.style(Style::default().fg(Color::Gray)),
util_cols[1],
);
// Row 3: VRAM bar + right label
let mem_cols = split_bar(rows[i * 3 + 2]);
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = used as f64 / total as f64;
let mem_pct = (mem_ratio * 100.0).round() as u16;
let mem_gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::LightMagenta))
.label(Span::raw(""))
.ratio(mem_ratio);
f.render_widget(mem_gauge, mem_cols[0]);
let used_s = fmt_bytes(used);
let total_s = fmt_bytes(total);
f.render_widget(
Paragraph::new(Span::raw(format!("vram: {used_s}/{total_s} ({mem_pct}%)")))
.style(Style::default().fg(Color::Gray)),
mem_cols[1],
);
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Top header with hostname and CPU temperature indicator.
use crate::types::Metrics;
use ratatui::{
layout::Rect,
widgets::{Block, Borders},
};
use std::time::Duration;
pub fn draw_header(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
is_tls: bool,
has_token: bool,
metrics_interval: Duration,
procs_interval: Duration,
) {
let base = if let Some(mm) = m {
let temp = mm
.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into());
format!("socktop — host: {} | {}", mm.hostname, temp)
} else {
"socktop — connecting...".into()
};
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
// Token indicator
let tok_txt = if has_token { "🔑 token" } else { "" };
let mi = metrics_interval.as_millis();
let pi = procs_interval.as_millis();
let intervals = format!("{mi}ms metrics | {pi}ms procs");
let mut parts = vec![base, tls_txt.into()];
if !tok_txt.is_empty() {
parts.push(tok_txt.into());
}
parts.push(intervals);
parts.push("(q to quit)".into());
let title = parts.join(" | ");
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
}
+29
View File
@@ -0,0 +1,29 @@
//! Memory gauge.
use crate::types::Metrics;
use crate::ui::util::human;
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Gauge},
};
pub fn draw_mem(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let (used, total, pct) = if let Some(mm) = m {
let pct = if mm.mem_total > 0 {
(mm.mem_used as f64 / mm.mem_total as f64 * 100.0) as u16
} else {
0
};
(mm.mem_used, mm.mem_total, pct)
} else {
(0, 0, 0)
};
let g = Gauge::default()
.block(Block::default().borders(Borders::ALL).title("Memory"))
.gauge_style(Style::default().fg(Color::Magenta))
.percent(pct)
.label(format!("{} / {}", human(used), human(total)));
f.render_widget(g, area);
}
+13
View File
@@ -0,0 +1,13 @@
//! UI module root: exposes drawing functions for individual panels.
pub mod cpu;
pub mod disks;
pub mod gpu;
pub mod header;
pub mod mem;
pub mod modal;
pub mod net;
pub mod processes;
pub mod swap;
pub mod theme;
pub mod util;
+612
View File
@@ -0,0 +1,612 @@
//! Modal window system for socktop TUI application
use std::time::{Duration, Instant};
use super::theme::{
BTN_EXIT_BG_ACTIVE, BTN_EXIT_FG_ACTIVE, BTN_EXIT_FG_INACTIVE, BTN_EXIT_TEXT,
BTN_RETRY_BG_ACTIVE, BTN_RETRY_FG_ACTIVE, BTN_RETRY_FG_INACTIVE, BTN_RETRY_TEXT, ICON_CLUSTER,
ICON_COUNTDOWN_LABEL, ICON_MESSAGE, ICON_OFFLINE_LABEL, ICON_RETRY_LABEL, ICON_WARNING_TITLE,
LARGE_ERROR_ICON, MODAL_AGENT_FG, MODAL_BG, MODAL_BORDER_FG, MODAL_COUNTDOWN_LABEL_FG,
MODAL_DIM_BG, MODAL_FG, MODAL_HINT_FG, MODAL_ICON_PINK, MODAL_OFFLINE_LABEL_FG,
MODAL_RETRY_LABEL_FG, MODAL_TITLE_FG,
};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
#[derive(Debug, Clone)]
pub enum ModalType {
ConnectionError {
message: String,
disconnected_at: Instant,
retry_count: u32,
auto_retry_countdown: Option<u64>,
},
#[allow(dead_code)]
Confirmation {
title: String,
message: String,
confirm_text: String,
cancel_text: String,
},
#[allow(dead_code)]
Info { title: String, message: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum ModalAction {
None,
RetryConnection,
ExitApp,
Confirm,
Cancel,
Dismiss,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ModalButton {
Retry,
Exit,
Confirm,
Cancel,
Ok,
}
#[derive(Debug)]
pub struct ModalManager {
stack: Vec<ModalType>,
active_button: ModalButton,
}
impl ModalManager {
pub fn new() -> Self {
Self {
stack: Vec::new(),
active_button: ModalButton::Retry,
}
}
pub fn is_active(&self) -> bool {
!self.stack.is_empty()
}
pub fn push_modal(&mut self, modal: ModalType) {
self.stack.push(modal);
self.active_button = match self.stack.last() {
Some(ModalType::ConnectionError { .. }) => ModalButton::Retry,
Some(ModalType::Confirmation { .. }) => ModalButton::Confirm,
Some(ModalType::Info { .. }) => ModalButton::Ok,
None => ModalButton::Ok,
};
}
pub fn pop_modal(&mut self) -> Option<ModalType> {
let m = self.stack.pop();
if let Some(next) = self.stack.last() {
self.active_button = match next {
ModalType::ConnectionError { .. } => ModalButton::Retry,
ModalType::Confirmation { .. } => ModalButton::Confirm,
ModalType::Info { .. } => ModalButton::Ok,
};
}
m
}
pub fn update_connection_error_countdown(&mut self, new_countdown: Option<u64>) {
if let Some(ModalType::ConnectionError {
auto_retry_countdown,
..
}) = self.stack.last_mut()
{
*auto_retry_countdown = new_countdown;
}
}
pub fn handle_key(&mut self, key: KeyCode) -> ModalAction {
if !self.is_active() {
return ModalAction::None;
}
match key {
KeyCode::Esc => {
self.pop_modal();
ModalAction::Cancel
}
KeyCode::Enter => self.handle_enter(),
KeyCode::Tab | KeyCode::Right => {
self.next_button();
ModalAction::None
}
KeyCode::BackTab | KeyCode::Left => {
self.prev_button();
ModalAction::None
}
KeyCode::Char('r') | KeyCode::Char('R') => {
if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) {
ModalAction::RetryConnection
} else {
ModalAction::None
}
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
if matches!(self.stack.last(), Some(ModalType::ConnectionError { .. })) {
ModalAction::ExitApp
} else {
ModalAction::None
}
}
_ => ModalAction::None,
}
}
fn handle_enter(&mut self) -> ModalAction {
match (&self.stack.last(), &self.active_button) {
(Some(ModalType::ConnectionError { .. }), ModalButton::Retry) => {
ModalAction::RetryConnection
}
(Some(ModalType::ConnectionError { .. }), ModalButton::Exit) => ModalAction::ExitApp,
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
self.pop_modal();
ModalAction::Dismiss
}
_ => ModalAction::None,
}
}
fn next_button(&mut self) {
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,
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalButton::Confirm,
_ => self.active_button.clone(),
};
}
fn prev_button(&mut self) {
self.next_button();
}
pub fn render(&self, f: &mut Frame) {
if let Some(m) = self.stack.last() {
self.render_background_dim(f);
self.render_modal_content(f, m);
}
}
fn render_background_dim(&self, f: &mut Frame) {
let area = f.area();
f.render_widget(Clear, area);
f.render_widget(
Block::default()
.style(Style::default().bg(MODAL_DIM_BG).fg(MODAL_DIM_BG))
.borders(Borders::NONE),
area,
);
}
fn render_modal_content(&self, f: &mut Frame, modal: &ModalType) {
let area = f.area();
let modal_area = self.centered_rect(70, 50, area);
f.render_widget(Clear, modal_area);
match modal {
ModalType::ConnectionError {
message,
disconnected_at,
retry_count,
auto_retry_countdown,
} => self.render_connection_error(
f,
modal_area,
message,
*disconnected_at,
*retry_count,
*auto_retry_countdown,
),
ModalType::Confirmation {
title,
message,
confirm_text,
cancel_text,
} => self.render_confirmation(f, modal_area, title, message, confirm_text, cancel_text),
ModalType::Info { title, message } => self.render_info(f, modal_area, title, message),
}
}
fn render_connection_error(
&self,
f: &mut Frame,
area: Rect,
message: &str,
disconnected_at: Instant,
retry_count: u32,
auto_retry_countdown: Option<u64>,
) {
let duration_text = format_duration(disconnected_at.elapsed());
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(4),
])
.split(area);
let block = Block::default()
.title(ICON_WARNING_TITLE)
.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).fg(MODAL_FG));
f.render_widget(block, area);
let content_area = chunks[1];
let max_w = content_area.width.saturating_sub(15) as usize;
let clean_message = if message.to_lowercase().contains("hostname verification")
|| message.contains("socktop_connector")
{
"Connection failed - hostname verification disabled".to_string()
} else if message.contains("Failed to fetch metrics:") {
if let Some(p) = message.find(':') {
let ess = message[p + 1..].trim();
if ess.len() > max_w {
format!("{}...", &ess[..max_w.saturating_sub(3)])
} else {
ess.to_string()
}
} else {
"Connection error".to_string()
}
} else if message.starts_with("Retry failed:") {
if let Some(p) = message.find(':') {
let ess = message[p + 1..].trim();
if ess.len() > max_w {
format!("{}...", &ess[..max_w.saturating_sub(3)])
} else {
ess.to_string()
}
} else {
"Retry failed".to_string()
}
} else if message.len() > max_w {
format!("{}...", &message[..max_w.saturating_sub(3)])
} else {
message.to_string()
};
let truncate = |s: &str| {
if s.len() > max_w {
format!("{}...", &s[..max_w.saturating_sub(3)])
} else {
s.to_string()
}
};
let agent_text = truncate("📡 Cannot connect to socktop agent");
let message_text = truncate(&clean_message);
let duration_display = truncate(&duration_text);
let retry_display = truncate(&retry_count.to_string());
let countdown_text = auto_retry_countdown.map(|c| {
if c == 0 {
"Auto retry now...".to_string()
} else {
format!("{c}s")
}
});
// Determine if we have enough space (height + width) to show large centered icon
let icon_max_width = LARGE_ERROR_ICON
.iter()
.map(|l| l.trim().chars().count())
.max()
.unwrap_or(0) as u16;
let large_allowed = content_area.height >= (LARGE_ERROR_ICON.len() as u16 + 8)
&& content_area.width >= icon_max_width + 6; // small margin for borders/padding
let mut icon_lines: Vec<Line> = Vec::new();
if large_allowed {
for &raw in LARGE_ERROR_ICON.iter() {
let trimmed = raw.trim();
icon_lines.push(Line::from(
trimmed
.chars()
.map(|ch| {
if ch == '!' {
Span::styled(
ch.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
} else if ch == '/' || ch == '\\' || ch == '_' {
// keep outline in pink
Span::styled(
ch.to_string(),
Style::default()
.fg(MODAL_ICON_PINK)
.add_modifier(Modifier::BOLD),
)
} else if ch == ' ' {
Span::raw(" ")
} else {
Span::styled(ch.to_string(), Style::default().fg(MODAL_ICON_PINK))
}
})
.collect::<Vec<_>>(),
));
}
icon_lines.push(Line::from("")); // blank spacer line below icon
}
let mut info_lines: Vec<Line> = Vec::new();
if !large_allowed {
info_lines.push(Line::from(vec![Span::styled(
ICON_CLUSTER,
Style::default().fg(MODAL_ICON_PINK),
)]));
info_lines.push(Line::from(""));
}
info_lines.push(Line::from(vec![Span::styled(
&agent_text,
Style::default().fg(MODAL_AGENT_FG),
)]));
info_lines.push(Line::from(""));
info_lines.push(Line::from(vec![
Span::styled(ICON_MESSAGE, Style::default().fg(MODAL_HINT_FG)),
Span::styled(&message_text, Style::default().fg(MODAL_AGENT_FG)),
]));
info_lines.push(Line::from(""));
info_lines.push(Line::from(vec![
Span::styled(
ICON_OFFLINE_LABEL,
Style::default().fg(MODAL_OFFLINE_LABEL_FG),
),
Span::styled(
&duration_display,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]));
info_lines.push(Line::from(vec![
Span::styled(ICON_RETRY_LABEL, Style::default().fg(MODAL_RETRY_LABEL_FG)),
Span::styled(
&retry_display,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]));
if let Some(cd) = &countdown_text {
info_lines.push(Line::from(vec![
Span::styled(
ICON_COUNTDOWN_LABEL,
Style::default().fg(MODAL_COUNTDOWN_LABEL_FG),
),
Span::styled(
cd,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]));
}
let constrained = Rect {
x: content_area.x + 2,
y: content_area.y,
width: content_area.width.saturating_sub(4),
height: content_area.height,
};
if large_allowed {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(icon_lines.len() as u16),
Constraint::Min(0),
])
.split(constrained);
// Center the icon block; each line already trimmed so per-line centering keeps shape
f.render_widget(
Paragraph::new(Text::from(icon_lines))
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
split[0],
);
f.render_widget(
Paragraph::new(Text::from(info_lines))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
split[1],
);
} else {
f.render_widget(
Paragraph::new(Text::from(info_lines))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
constrained,
);
}
let button_area = Rect {
x: chunks[2].x,
y: chunks[2].y,
width: chunks[2].width,
height: chunks[2].height.saturating_sub(1),
};
self.render_connection_error_buttons(f, button_area);
}
fn render_connection_error_buttons(&self, f: &mut Frame, area: Rect) {
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30),
Constraint::Percentage(15),
Constraint::Percentage(10),
Constraint::Percentage(15),
Constraint::Percentage(30),
])
.split(area);
let retry_style = if self.active_button == ModalButton::Retry {
Style::default()
.bg(BTN_RETRY_BG_ACTIVE)
.fg(BTN_RETRY_FG_ACTIVE)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(BTN_RETRY_FG_INACTIVE)
.add_modifier(Modifier::DIM)
};
let exit_style = if self.active_button == ModalButton::Exit {
Style::default()
.bg(BTN_EXIT_BG_ACTIVE)
.fg(BTN_EXIT_FG_ACTIVE)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(BTN_EXIT_FG_INACTIVE)
.add_modifier(Modifier::DIM)
};
f.render_widget(
Paragraph::new(Text::from(Line::from(vec![Span::styled(
BTN_RETRY_TEXT,
retry_style,
)])))
.alignment(Alignment::Center),
button_chunks[1],
);
f.render_widget(
Paragraph::new(Text::from(Line::from(vec![Span::styled(
BTN_EXIT_TEXT,
exit_style,
)])))
.alignment(Alignment::Center),
button_chunks[3],
);
}
fn render_confirmation(
&self,
f: &mut Frame,
area: Rect,
title: &str,
message: &str,
confirm_text: &str,
cancel_text: &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 buttons = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let confirm_style = if self.active_button == ModalButton::Confirm {
Style::default()
.bg(Color::Green)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let cancel_style = if self.active_button == ModalButton::Cancel {
Style::default()
.bg(Color::Red)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
};
f.render_widget(
Paragraph::new(confirm_text)
.style(confirm_style)
.alignment(Alignment::Center),
buttons[0],
);
f.render_widget(
Paragraph::new(cancel_text)
.style(cancel_style)
.alignment(Alignment::Center),
buttons[1],
);
}
fn render_info(&self, f: &mut Frame, area: Rect, title: &str, message: &str) {
let 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],
);
}
fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vert[1])[1]
}
}
fn format_duration(duration: Duration) -> String {
let total = duration.as_secs();
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h}h {m}m {s}s")
} else if m > 0 {
format!("{m}m {s}s")
} else {
format!("{s}s")
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Network sparklines (download/upload).
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Sparkline},
};
use std::collections::VecDeque;
pub fn draw_net_spark(
f: &mut ratatui::Frame<'_>,
area: Rect,
title: &str,
hist: &VecDeque<u64>,
color: Color,
) {
let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points);
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
let spark = Sparkline::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(title.to_string()),
)
.data(&data)
.style(Style::default().fg(color));
f.render_widget(spark, area);
}
+266
View File
@@ -0,0 +1,266 @@
//! Top processes table with per-cell coloring, zebra striping, sorting, and a scrollbar.
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Table},
};
use std::cmp::Ordering;
use crate::types::Metrics;
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
use crate::ui::util::human;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProcSortBy {
#[default]
CpuDesc,
MemDesc,
}
// Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID
Constraint::Percentage(40), // Name
Constraint::Length(8), // CPU %
Constraint::Length(12), // Mem
Constraint::Length(8), // Mem %
];
pub fn draw_top_processes(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
scroll_offset: usize,
sort_by: ProcSortBy,
) {
// Draw outer block and title
let Some(mm) = m else { return };
let total = mm.process_count.unwrap_or(mm.top_processes.len());
let block = Block::default()
.borders(Borders::ALL)
.title(format!("Top Processes ({total} total)"));
f.render_widget(block, area);
// Inner area and content area (reserve 2 columns for scrollbar)
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 < 1 || inner.width < 3 {
return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
// Sort rows (by CPU% or Mem bytes), descending.
let mut idxs: Vec<usize> = (0..mm.top_processes.len()).collect();
match sort_by {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].cpu_usage;
let bb = mm.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].mem_bytes;
let bb = mm.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
// Scrolling
let total_rows = idxs.len();
let header_rows = 1usize;
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
let max_off = total_rows.saturating_sub(viewport_rows);
let offset = scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Build visible rows
let total_mem_bytes = mm.mem_total.max(1);
let peak_cpu = mm
.top_processes
.iter()
.map(|p| p.cpu_usage)
.fold(0.0_f32, f32::max);
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix];
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let cpu_val = p.cpu_usage;
let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow,
_ => Color::Red,
};
let mem_fg = match mem_pct {
x if x < 5.0 => Color::Blue,
x if x < 20.0 => Color::Magenta,
_ => Color::Red,
};
let emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let cpu_str = fmt_cpu_pct(cpu_val);
ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(p.pid.to_string())
.style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(p.name.clone()),
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(human(p.mem_bytes)),
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
.style(Style::default().fg(mem_fg)),
])
.style(emphasis)
});
// Header with sort indicator
let cpu_hdr = match sort_by {
ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %",
};
let mem_hdr = match sort_by {
ProcSortBy::MemDesc => "Mem •",
_ => "Mem",
};
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
// Render table inside content area (no borders here; outer block already drawn)
let table = Table::new(rows_iter, COLS.to_vec())
.header(header)
.column_spacing(1);
f.render_widget(table, content);
// Draw scrollbar like CPU pane
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
if scroll_area.height >= 3 {
let track = (scroll_area.height - 2) as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let thumb_top = if max_off == 0 {
0
} else {
((track - thumb_len) * offset + max_off / 2) / max_off
};
// Build lines: top arrow, track (with thumb), bottom arrow
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
for i in 0..track {
if i >= thumb_top && i < thumb_top + thumb_len {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB))));
} else {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK))));
}
}
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
f.render_widget(Paragraph::new(lines), scroll_area);
}
}
fn fmt_cpu_pct(v: f32) -> String {
format!("{:>5.1}", v.clamp(0.0, 100.0))
}
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
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);
}
/// Handle mouse for content scrolling and scrollbar dragging.
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
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
}
+29
View File
@@ -0,0 +1,29 @@
//! Swap gauge.
use crate::types::Metrics;
use crate::ui::util::human;
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Gauge},
};
pub fn draw_swap(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let (used, total, pct) = if let Some(mm) = m {
let pct = if mm.swap_total > 0 {
(mm.swap_used as f64 / mm.swap_total as f64 * 100.0) as u16
} else {
0
};
(mm.swap_used, mm.swap_total, pct)
} else {
(0, 0, 0)
};
let g = Gauge::default()
.block(Block::default().borders(Borders::ALL).title("Swap"))
.gauge_style(Style::default().fg(Color::Yellow))
.percent(pct)
.label(format!("{} / {}", human(used), human(total)));
f.render_widget(g, area);
}
+53
View File
@@ -0,0 +1,53 @@
//! Shared UI theme constants.
use ratatui::style::Color;
// Scrollbar colors (same look as before)
pub const SB_ARROW: Color = Color::Rgb(170, 170, 180);
pub const SB_TRACK: Color = Color::Rgb(170, 170, 180);
pub const SB_THUMB: Color = Color::Rgb(170, 170, 180);
// Modal palette
pub const MODAL_DIM_BG: Color = Color::Rgb(15, 15, 25);
pub const MODAL_BG: Color = Color::Rgb(26, 26, 46);
pub const MODAL_FG: Color = Color::Rgb(230, 230, 230);
pub const MODAL_TITLE_FG: Color = Color::Rgb(255, 102, 102); // soft red for title text
pub const MODAL_BORDER_FG: Color = Color::Rgb(204, 51, 51); // darker red border
pub const MODAL_ICON_PINK: Color = Color::Rgb(255, 182, 193); // light pink icons line
pub const MODAL_AGENT_FG: Color = Color::Rgb(220, 220, 255); // pale periwinkle
pub const MODAL_HINT_FG: Color = Color::Rgb(255, 215, 0); // gold for message icon
pub const MODAL_OFFLINE_LABEL_FG: Color = Color::Rgb(135, 206, 235); // sky blue label
pub const MODAL_RETRY_LABEL_FG: Color = Color::Rgb(255, 165, 0); // orange label
pub const MODAL_COUNTDOWN_LABEL_FG: Color = Color::Rgb(255, 192, 203); // pink label for countdown
// Buttons
pub const BTN_RETRY_BG_ACTIVE: Color = Color::Rgb(46, 204, 113); // modern green
pub const BTN_RETRY_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
pub const BTN_RETRY_FG_INACTIVE: Color = Color::Rgb(46, 204, 113);
pub const BTN_EXIT_BG_ACTIVE: Color = Color::Rgb(255, 255, 255); // modern red
pub const BTN_EXIT_FG_ACTIVE: Color = Color::Rgb(26, 26, 46);
pub const BTN_EXIT_FG_INACTIVE: Color = Color::Rgb(255, 255, 255);
// Emoji / icon strings (centralized so they can be themed/swapped later)
pub const ICON_WARNING_TITLE: &str = " 🔌 CONNECTION ERROR ";
pub const ICON_CLUSTER: &str = "⚠️";
pub const ICON_MESSAGE: &str = "💭 ";
pub const ICON_OFFLINE_LABEL: &str = "⏱️ Offline for: ";
pub const ICON_RETRY_LABEL: &str = "🔄 Retry attempts: ";
pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
// Large multi-line warning icon
pub const LARGE_ERROR_ICON: &[&str] = &[
" /\\ ",
" / \\ ",
" / !! \\ ",
" / !!!! \\ ",
" / !! \\ ",
" / !!!! \\ ",
" / !! \\ ",
" /______________\\ ",
];
+51
View File
@@ -0,0 +1,51 @@
//! Small UI helpers: human-readable sizes, truncation, icons.
pub fn human(b: u64) -> String {
const K: f64 = 1024.0;
let b = b as f64;
if b < K {
return format!("{b:.0}B");
}
let kb = b / K;
if kb < K {
return format!("{kb:.1}KB");
}
let mb = kb / K;
if mb < K {
return format!("{mb:.1}MB");
}
let gb = mb / K;
if gb < K {
return format!("{gb:.1}GB");
}
let tb = gb / K;
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(':') {
"🗄️"
} else if n.contains("nvme") {
""
} else if n.starts_with("sd") {
"💽"
} else if n.contains("overlay") {
"📦"
} else {
"🖴"
}
}
View File
+75
View File
@@ -0,0 +1,75 @@
//! CLI arg parsing tests for socktop (client)
use std::process::Command;
// We test the parsing by invoking the binary with --help and ensuring the help mentions short and long flags.
// Also directly test the parse_args function via a tiny helper in a doctest-like fashion using a small
// reimplementation here kept in sync with main (compile-time test).
#[test]
fn test_help_mentions_short_and_long_flags() {
let output = Command::new(env!("CARGO_BIN_EXE_socktop"))
.arg("--help")
.output()
.expect("run socktop --help");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
text.contains("--tls-ca")
&& text.contains("-t")
&& text.contains("--profile")
&& text.contains("-P"),
"help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}"
);
}
#[test]
fn test_tlc_ca_arg_long_and_short_parsed() {
// Use --help combined with flags to avoid network and still exercise arg acceptance
let exe = env!("CARGO_BIN_EXE_socktop");
// Long form with help
let out = Command::new(exe)
.args(["--tls-ca", "/tmp/cert.pem", "--help"])
.output()
.expect("run socktop");
assert!(
out.status.success(),
"socktop --tls-ca … --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(text.contains("Usage:"));
// Short form with help
let out2 = Command::new(exe)
.args(["-t", "/tmp/cert.pem", "--help"])
.output()
.expect("run socktop");
assert!(out2.status.success(), "socktop -t … --help did not succeed");
let text2 = format!(
"{}{}",
String::from_utf8_lossy(&out2.stdout),
String::from_utf8_lossy(&out2.stderr)
);
assert!(text2.contains("Usage:"));
// Profile flags with help (should not error)
let out3 = Command::new(exe)
.args(["--profile", "dev", "--help"])
.output()
.expect("run socktop");
assert!(
out3.status.success(),
"socktop --profile dev --help did not succeed"
);
let text3 = format!(
"{}{}",
String::from_utf8_lossy(&out3.stdout),
String::from_utf8_lossy(&out3.stderr)
);
assert!(text3.contains("Usage:"));
}
+46
View File
@@ -0,0 +1,46 @@
//! Tests for modal formatting and duration helper.
use std::time::Duration;
// Bring the format_duration function into scope by duplicating logic (private in module). If desired,
// this could be moved to a shared util module; for now we re-assert expected behavior.
fn format_duration_ref(duration: Duration) -> String {
let total_secs = duration.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{hours}h {minutes}m {seconds}s")
} else if minutes > 0 {
format!("{minutes}m {seconds}s")
} else {
format!("{seconds}s")
}
}
#[test]
fn test_format_duration_boundaries() {
assert_eq!(format_duration_ref(Duration::from_secs(0)), "0s");
assert_eq!(format_duration_ref(Duration::from_secs(59)), "59s");
assert_eq!(format_duration_ref(Duration::from_secs(60)), "1m 0s");
assert_eq!(format_duration_ref(Duration::from_secs(61)), "1m 1s");
assert_eq!(format_duration_ref(Duration::from_secs(3600)), "1h 0m 0s");
assert_eq!(format_duration_ref(Duration::from_secs(3661)), "1h 1m 1s");
}
// Basic test to ensure auto-retry countdown semantics are consistent for initial state.
#[test]
fn test_auto_retry_initial_none() {
// We can't construct App directly without pulling in whole UI; just assert logic mimic.
// For a more thorough test, refactor countdown logic into a pure function.
// This placeholder asserts desired initial semantics: when no disconnect/original time, countdown should be None.
// (When integrated, consider exposing a pure helper returning Option<u64>.)
let modal_active = false; // requirement: must be active for countdown
let disconnected_state = true; // assume disconnected state
let countdown = if disconnected_state && modal_active {
// would compute target
Some(0)
} else {
None
};
assert!(countdown.is_none());
}
+124
View File
@@ -0,0 +1,124 @@
//! Tests for profile load/save and resolution logic (non-interactive paths only)
use std::fs;
use std::sync::Mutex;
// Global lock to serialize tests that mutate process-wide environment variables.
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[allow(dead_code)] // touch crate
fn touch() {
let _ = socktop::types::Metrics {
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 0,
mem_used: 0,
swap_total: 0,
swap_used: 0,
process_count: None,
hostname: String::new(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus: None,
};
}
// We re-import internal modules by copying minimal logic here because profiles.rs isn't public.
// Instead of exposing internals, we simulate profile saving through CLI invocations.
use std::process::Command;
fn run_socktop(args: &[&str]) -> (bool, String) {
let exe = env!("CARGO_BIN_EXE_socktop");
let output = Command::new(exe).args(args).output().expect("run socktop");
let ok = output.status.success();
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
(ok, text)
}
fn config_dir() -> std::path::PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
std::path::PathBuf::from(xdg).join("socktop")
} else {
dirs_next::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("socktop")
}
}
fn profiles_path() -> std::path::PathBuf {
config_dir().join("profiles.json")
}
#[test]
fn test_profile_created_on_first_use() {
let _guard = ENV_LOCK.lock().unwrap();
// Isolate config in a temp dir
let td = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", td.path());
}
// Ensure directory exists fresh
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
let _ = fs::remove_file(profiles_path());
// Provide profile + url => should create profiles.json
let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]);
// We pass --help to exit early after parsing (no network attempt)
let data = fs::read_to_string(profiles_path()).expect("profiles.json created");
assert!(
data.contains("unittest"),
"profiles.json missing profile entry: {data}"
);
}
#[test]
fn test_profile_overwrite_only_when_changed() {
let _guard = ENV_LOCK.lock().unwrap();
let td = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", td.path());
}
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
let _ = fs::remove_file(profiles_path());
// Initial create
let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create
let first = fs::read_to_string(profiles_path()).unwrap();
// Re-run identical (should not duplicate or corrupt)
let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical
let second = fs::read_to_string(profiles_path()).unwrap();
assert_eq!(
first, second,
"Profile file changed despite identical input"
);
// Overwrite with different URL using --save (no prompt path)
let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]);
let third = fs::read_to_string(profiles_path()).unwrap();
assert!(third.contains("two"), "Updated URL not written: {third}");
}
#[test]
fn test_profile_tls_ca_persisted() {
let _guard = ENV_LOCK.lock().unwrap();
let td = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", td.path());
}
std::fs::create_dir_all(td.path().join("socktop")).unwrap();
let _ = fs::remove_file(profiles_path());
let (_ok, _out) = run_socktop(&[
"--profile",
"secureX",
"--tls-ca",
"/tmp/cert.pem",
"wss://host/ws",
"--dry-run",
]);
let data = fs::read_to_string(profiles_path()).unwrap();
assert!(data.contains("secureX"));
assert!(data.contains("cert.pem"));
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "socktop_agent"
version = "1.40.70"
authors = ["Jason Witty <jasonpwitty+socktop@proton.me>"]
description = "Socktop agent daemon. Serves host metrics over WebSocket."
edition = "2024"
license = "MIT"
readme = "README.md"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["ws", "macros"] }
sysinfo = { version = "0.37", features = ["network", "disk", "component"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
futures-util = "0.3.31"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# nvml-wrapper removed (unused; GPU metrics via gfxinfo only now)
gfxinfo = "0.1.2"
once_cell = "1.19"
axum-server = { version = "0.6", features = ["tls-rustls"] }
rustls = "0.23"
rustls-pemfile = "2.1"
rcgen = "0.13" # pure-Rust self-signed cert generation (replaces openssl vendored build)
anyhow = "1"
hostname = "0.3"
prost = { workspace = true }
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "parsing" ] }
[build-dependencies]
prost-build = "0.13"
tonic-build = { version = "0.12", default-features = false, optional = true }
protoc-bin-vendored = "3"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"
tokio-tungstenite = "0.21"
+396
View File
@@ -0,0 +1,396 @@
# socktop_agent (server)
Lightweight ondemand metrics WebSocket server for the socktop TUI.
Highlights:
- Collects system metrics only when requested (keeps idle CPU <1%)
- Optional TLS (selfsigned cert autogenerated & pinned by client)
- JSON for fast metrics / disks; protobuf (optionally gzipped) for processes
- Accurate perprocess CPU% on Linux via /proc jiffies delta
- Optional GPU & temperature metrics (disable via env vars)
- Simple token auth (?token=...) support
Run (no TLS):
```
cargo install socktop_agent
socktop_agent --port 3000
```
Enable TLS:
```
SOCKTOP_ENABLE_SSL=1 socktop_agent --port 8443
# cert/key stored under $XDG_DATA_HOME/socktop_agent/tls
```
Environment toggles:
- SOCKTOP_AGENT_GPU=0 (disable GPU collection)
- SOCKTOP_AGENT_TEMP=0 (disable temperature)
- SOCKTOP_TOKEN=secret (require token param from client)
- SOCKTOP_AGENT_METRICS_TTL_MS=250 (cache fast metrics window)
- SOCKTOP_AGENT_PROCESSES_TTL_MS=1000
- SOCKTOP_AGENT_DISKS_TTL_MS=1000
*NOTE ON ENV vars*
Generally these have been added for debugging purposes. you do not need to configure them, default values are tuned and GPU will deisable itself after the first poll if not available.
Systemd unit example & full docs:
https://github.com/jasonwitty/socktop
## WebSocket API Integration Guide
The socktop_agent exposes a WebSocket API that can be directly integrated with your own applications. This allows you to build custom monitoring dashboards or analysis tools using the agent's metrics.
### WebSocket Endpoint
```
ws://HOST:PORT/ws # Without TLS
wss://HOST:PORT/ws # With TLS
```
With authentication token (if configured):
```
ws://HOST:PORT/ws?token=YOUR_TOKEN
wss://HOST:PORT/ws?token=YOUR_TOKEN
```
### Communication Protocol
All communication uses JSON format for requests and responses, except for the process list which uses Protocol Buffers (protobuf) format with optional gzip compression.
#### Request Types
Send a JSON message with a `type` field to request specific metrics:
```json
{"type": "metrics"} // Request fast-changing metrics (CPU, memory, network)
{"type": "disks"} // Request disk information
{"type": "processes"} // Request process list (returns protobuf)
```
#### Response Formats
1. **Fast Metrics** (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,
"hostname": "myserver",
"cpu_temp_c": 42.5,
"networks": [{"name":"eth0","received":12345678,"transmitted":87654321}],
"gpus": [{"name":"nvidia-0","usage":56.7,"memory_total":8589934592,"memory_used":1073741824,"temp_c":65.0}]
}
```
2. **Disks** (JSON):
```json
[
{"name":"nvme0n1p2","total":512000000000,"available":320000000000},
{"name":"sda1","total":1000000000000,"available":750000000000}
]
```
3. **Processes** (Protocol Buffers):
Processes are returned in Protocol Buffers format, optionally gzip-compressed for large process lists. The protobuf schema is:
```protobuf
syntax = "proto3";
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3;
uint64 mem_bytes = 4;
}
message ProcessList {
uint32 process_count = 1;
repeated Process processes = 2;
}
```
### Example Integration (JavaScript/Node.js)
```javascript
const WebSocket = require('ws');
// Connect to the agent
const ws = new WebSocket('ws://localhost:3000/ws');
ws.on('open', function open() {
console.log('Connected to socktop_agent');
// Request metrics immediately on connection
ws.send(JSON.stringify({type: 'metrics'}));
// Set up regular polling
setInterval(() => {
ws.send(JSON.stringify({type: 'metrics'}));
}, 1000);
// Request processes every 3 seconds
setInterval(() => {
ws.send(JSON.stringify({type: 'processes'}));
}, 3000);
});
ws.on('message', function incoming(data) {
// Check if the response is JSON or binary (protobuf)
try {
const jsonData = JSON.parse(data);
console.log('Received JSON data:', jsonData);
} catch (e) {
console.log('Received binary data (protobuf), length:', data.length);
// Process binary protobuf data with a library like protobufjs
}
});
ws.on('close', function close() {
console.log('Disconnected from socktop_agent');
});
```
### Example Integration (Python)
```python
import json
import asyncio
import websockets
async def monitor_system():
uri = "ws://localhost:3000/ws"
async with websockets.connect(uri) as websocket:
print("Connected to socktop_agent")
# Request initial metrics
await websocket.send(json.dumps({"type": "metrics"}))
# Set up regular polling
while True:
# Request metrics
await websocket.send(json.dumps({"type": "metrics"}))
# Receive and process response
response = await websocket.recv()
# Check if response is JSON or binary (protobuf)
try:
data = json.loads(response)
print(f"CPU: {data['cpu_total']}%, Memory: {data['mem_used']/data['mem_total']*100:.1f}%")
except json.JSONDecodeError:
print(f"Received binary data, length: {len(response)}")
# Process binary protobuf data with a library like protobuf
# Wait before next poll
await asyncio.sleep(1)
asyncio.run(monitor_system())
```
### Notes for Integration
1. **Error Handling**: The WebSocket connection may close unexpectedly; implement reconnection logic in your client.
2. **Rate Limiting**: Avoid excessive polling that could impact the system being monitored. Recommended intervals:
- Metrics: 500ms or slower
- Processes: 2000ms or slower
- Disks: 5000ms or slower
3. **Authentication**: If the agent is configured with a token, always include it in the WebSocket URL.
4. **Protocol Buffers Handling**: For processing the binary process list data, use a Protocol Buffers library for your language and the schema provided in the `proto/processes.proto` file.
5. **Compression**: Process lists may be gzip-compressed. Check if the response starts with the gzip magic bytes (`0x1f, 0x8b`) and decompress if necessary.
## LLM Integration Guide
If you're using an LLM to generate code for integrating with socktop_agent, this section provides structured information to help the model understand the API better.
### API Schema
```yaml
# WebSocket API Schema for socktop_agent
endpoint: ws://HOST:PORT/ws or wss://HOST:PORT/ws (with TLS)
authentication:
type: query parameter
parameter: token
example: ws://HOST:PORT/ws?token=YOUR_TOKEN
requests:
- type: metrics
format: JSON
example: {"type": "metrics"}
description: Fast-changing metrics (CPU, memory, network)
- type: disks
format: JSON
example: {"type": "disks"}
description: Disk information
- type: processes
format: JSON
example: {"type": "processes"}
description: Process list (returns protobuf)
responses:
- request_type: metrics
format: JSON
schema:
cpu_total: float # percentage of total CPU usage
cpu_per_core: [float] # array of per-core CPU usage percentages
mem_total: uint64 # total memory in bytes
mem_used: uint64 # used memory in bytes
swap_total: uint64 # total swap in bytes
swap_used: uint64 # used swap in bytes
hostname: string # system hostname
cpu_temp_c: float? # CPU temperature in Celsius (optional)
networks: [
{
name: string # network interface name
received: uint64 # total bytes received
transmitted: uint64 # total bytes transmitted
}
]
gpus: [
{
name: string # GPU device name
usage: float # GPU usage percentage
memory_total: uint64 # total GPU memory in bytes
memory_used: uint64 # used GPU memory in bytes
temp_c: float # GPU temperature in Celsius
}
]?
- request_type: disks
format: JSON
schema:
[
{
name: string # disk name
total: uint64 # total space in bytes
available: uint64 # available space in bytes
}
]
- request_type: processes
format: Protocol Buffers (optionally gzip-compressed)
schema: See protobuf definition below
```
### Protobuf Schema (processes.proto)
```protobuf
syntax = "proto3";
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3;
uint64 mem_bytes = 4;
}
message ProcessList {
uint32 process_count = 1;
repeated Process processes = 2;
}
```
### Step-by-Step Integration Pseudocode
```
1. Establish WebSocket connection to ws://HOST:PORT/ws
- Add token if required: ws://HOST:PORT/ws?token=YOUR_TOKEN
2. For regular metrics updates:
- Send: {"type": "metrics"}
- Parse JSON response
- Extract CPU, memory, network info
3. For disk information:
- Send: {"type": "disks"}
- Parse JSON response
- Extract disk usage data
4. For process list:
- Send: {"type": "processes"}
- Check if response is binary
- If starts with 0x1f, 0x8b bytes:
- Decompress using gzip
- Parse binary data using protobuf schema
- Extract process information
5. Implement reconnection logic:
- On connection close/error
- Use exponential backoff
6. Respect rate limits:
- metrics: ≥ 500ms interval
- disks: ≥ 5000ms interval
- processes: ≥ 2000ms interval
```
### Common Implementation Patterns
**Pattern 1: Periodic Polling**
```javascript
// Set up separate timers for different metric types
const metricsInterval = setInterval(() => ws.send(JSON.stringify({type: 'metrics'})), 500);
const disksInterval = setInterval(() => ws.send(JSON.stringify({type: 'disks'})), 5000);
const processesInterval = setInterval(() => ws.send(JSON.stringify({type: 'processes'})), 2000);
// Clean up on disconnect
ws.on('close', () => {
clearInterval(metricsInterval);
clearInterval(disksInterval);
clearInterval(processesInterval);
});
```
**Pattern 2: Processing Binary Protobuf Data**
```javascript
// Using protobufjs
const root = protobuf.loadSync('processes.proto');
const ProcessList = root.lookupType('ProcessList');
ws.on('message', function(data) {
if (typeof data !== 'string') {
// Check for gzip compression
if (data[0] === 0x1f && data[1] === 0x8b) {
data = gunzipSync(data); // Use appropriate decompression library
}
// Decode protobuf
const processes = ProcessList.decode(new Uint8Array(data));
console.log(`Total processes: ${processes.process_count}`);
processes.processes.forEach(p => {
console.log(`PID: ${p.pid}, Name: ${p.name}, CPU: ${p.cpu_usage}%`);
});
}
});
```
**Pattern 3: Reconnection Logic**
```javascript
function connect() {
const ws = new WebSocket('ws://localhost:3000/ws');
ws.on('open', () => {
console.log('Connected');
// Start polling
});
ws.on('close', () => {
console.log('Connection lost, reconnecting...');
setTimeout(connect, 1000); // Reconnect after 1 second
});
// Handle other events...
}
connect();
```
+14
View File
@@ -0,0 +1,14 @@
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
// 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");
}
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
package socktop;
// All running processes. Sorting is done client-side.
message Processes {
uint64 process_count = 1; // total processes in the system
repeated Process rows = 2; // all processes
}
message Process {
uint32 pid = 1;
string name = 2;
float cpu_usage = 3; // 0..100
uint64 mem_bytes = 4; // RSS bytes
}
+24
View File
@@ -0,0 +1,24 @@
// gpu.rs
use gfxinfo::active_gpu;
#[derive(Debug, Clone, serde::Serialize)]
pub struct GpuMetrics {
pub name: String,
pub utilization_gpu_pct: u32, // 0..100
pub mem_used_bytes: u64,
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])
}
+91
View File
@@ -0,0 +1,91 @@
//! socktop agent entrypoint: sets up sysinfo handles and serves a WebSocket endpoint at /ws.
mod gpu;
mod metrics;
mod proto;
// sampler module removed (metrics now purely request-driven)
mod state;
mod types;
mod ws;
use axum::{Router, http::StatusCode, routing::get};
use std::net::SocketAddr;
use std::str::FromStr;
mod tls;
use state::AppState;
fn arg_flag(name: &str) -> bool {
std::env::args().any(|a| a == name)
}
fn arg_value(name: &str) -> Option<String> {
let mut it = std::env::args();
while let Some(a) = it.next() {
if a == name {
return it.next();
}
}
None
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
// Version flag (print and exit). Keep before heavy initialization.
if arg_flag("--version") || arg_flag("-V") {
println!("socktop_agent {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let state = AppState::new();
// No background samplers: metrics collected on-demand per websocket request.
// Web app: route /ws to the websocket handler
async fn healthz() -> StatusCode {
println!("/healthz request");
StatusCode::OK
}
let app = Router::new()
.route("/ws", get(ws::ws_handler))
.route("/healthz", get(healthz))
.with_state(state.clone());
let enable_ssl =
arg_flag("--enableSSL") || std::env::var("SOCKTOP_ENABLE_SSL").ok().as_deref() == Some("1");
if enable_ssl {
// Port can be overridden by --port or SOCKTOP_PORT; default to 8443 when SSL
let port = arg_value("--port")
.or_else(|| arg_value("-p"))
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(8443);
let (cert_path, key_path) = tls::ensure_self_signed_cert()?;
let cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
let addr = SocketAddr::from_str(&format!("0.0.0.0:{port}"))?;
println!("socktop_agent: TLS enabled. Listening on wss://{addr}/ws");
axum_server::bind_rustls(addr, cfg)
.serve(app.into_make_service())
.await?;
return Ok(());
}
// Non-TLS HTTP/WS path
let port = arg_value("--port")
.or_else(|| arg_value("-p"))
.or_else(|| std::env::var("SOCKTOP_PORT").ok())
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(3000);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("socktop_agent: Listening on ws://{addr}/ws");
axum_server::bind(addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
// Unit tests for CLI parsing moved to `tests/port_parse.rs`.
+551
View File
@@ -0,0 +1,551 @@
//! Metrics collection using sysinfo for socktop_agent.
use crate::gpu::collect_all_gpus;
use crate::state::AppState;
use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload};
use once_cell::sync::OnceCell;
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(target_os = "linux")]
use std::fs;
#[cfg(target_os = "linux")]
use std::io;
use std::sync::Mutex;
use std::time::Duration as StdDuration;
use std::time::{Duration, Instant};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
use tracing::warn;
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
// Runtime toggles (read once)
fn gpu_enabled() -> bool {
static ON: OnceCell<bool> = OnceCell::new();
*ON.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_GPU")
.map(|v| v != "0")
.unwrap_or(true)
})
}
fn temp_enabled() -> bool {
static ON: OnceCell<bool> = OnceCell::new();
*ON.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_TEMP")
.map(|v| v != "0")
.unwrap_or(true)
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms
const TTL: Duration = Duration::from_millis(1500);
struct TempCache {
at: Option<Instant>,
v: Option<f32>,
}
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
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 {
names: Vec<String>,
infos: Vec<NetworkInfo>,
}
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 {
// TTL (ms) overridable via env, default 250ms
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(250);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_metrics.lock().await;
if cache.is_fresh(ttl)
&& let Some(c) = cache.get()
{
return c.clone();
}
}
let mut sys = state.sys.lock().await;
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sys.refresh_cpu_usage();
sys.refresh_memory();
})) {
warn!("sysinfo selective refresh panicked: {e:?}");
}
// Get or initialize hostname once
let hostname = HOSTNAME.get_or_init(|| state.hostname.clone()).clone();
// Reuse CPU vector to avoid allocation
let cpu_total = sys.global_cpu_usage();
let cpu_per_core = {
let vec_lock = CPU_VEC.get_or_init(|| Mutex::new(Vec::with_capacity(32)));
let mut vec = vec_lock.lock().unwrap();
vec.clear();
vec.extend(sys.cpus().iter().map(|c| c.cpu_usage()));
vec.clone() // Still need to clone but the allocation is reused
};
let mem_total = sys.total_memory();
let mem_used = mem_total.saturating_sub(sys.available_memory());
let swap_total = sys.total_swap();
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() {
let val = {
let mut components = state.components.lock().await;
components.refresh(false);
components.iter().find_map(|c| {
let l = c.label().to_ascii_lowercase();
if l.contains("cpu")
|| l.contains("package")
|| l.contains("tctl")
|| l.contains("tdie")
{
c.temperature()
} else {
None
}
})
};
set_temp(val);
val
} else {
None
};
// Networks with reusable name cache
let networks = {
let mut nets = state.networks.lock().await;
nets.refresh(false);
// Get or initialize network cache
let cache = NETWORK_CACHE.get_or_init(|| {
Mutex::new(NetworkNameCache {
names: Vec::new(),
infos: Vec::with_capacity(4), // Most systems have few network interfaces
})
});
let mut cache = cache.lock().unwrap();
// Collect current network names
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
// Update cached network names if they changed
if cache.names != current_names {
cache.names = current_names;
}
// Reuse NetworkInfo objects
cache.infos.clear();
for (name, data) in nets.iter() {
cache.infos.push(NetworkInfo {
name: name.to_string(), // We'll still clone but avoid Vec reallocation
received: data.total_received(),
transmitted: data.total_transmitted(),
});
}
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) => {
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 {
None
};
let metrics = Metrics {
cpu_total,
cpu_per_core,
mem_total,
mem_used,
swap_total,
swap_used,
hostname,
cpu_temp_c,
disks: Vec::new(),
networks,
top_processes: Vec::new(),
gpus,
};
{
let mut cache = state.cache_metrics.lock().await;
cache.set(metrics.clone());
}
metrics
}
// Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_disks.lock().await;
if cache.is_fresh(ttl)
&& let Some(v) = cache.get()
{
return v.clone();
}
}
let mut disks_list = state.disks.lock().await;
disks_list.refresh(false); // don't drop missing disks
let disks: Vec<DiskInfo> = disks_list
.iter()
.map(|d| DiskInfo {
name: d.name().to_string_lossy().into_owned(),
total: d.total_space(),
available: d.available_space(),
})
.collect();
{
let mut cache = state.cache_disks.lock().await;
cache.set(disks.clone());
}
disks
}
// Linux-only helpers and implementation using /proc deltas for accurate CPU%.
#[cfg(target_os = "linux")]
#[inline]
fn read_total_jiffies() -> io::Result<u64> {
// /proc/stat first line: "cpu user nice system idle iowait irq softirq steal ..."
let s = fs::read_to_string("/proc/stat")?;
if let Some(line) = s.lines().next() {
let mut it = line.split_whitespace();
let _cpu = it.next(); // "cpu"
let mut sum: u64 = 0;
for tok in it.take(8) {
if let Ok(v) = tok.parse::<u64>() {
sum = sum.saturating_add(v);
}
}
return Ok(sum);
}
Err(io::Error::other("no cpu line"))
}
#[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()?;
Some(utime.saturating_add(stime))
}
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
#[cfg(target_os = "linux")]
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
// Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates).
.unwrap_or(1_500);
let ttl = StdDuration::from_millis(ttl_ms);
{
let cache = state.cache_processes.lock().await;
if cache.is_fresh(ttl)
&& let Some(c) = cache.get()
{
return c.clone();
}
}
// Reuse shared System to avoid reallocation; refresh processes fully.
let mut sys_guard = state.sys.lock().await;
let sys = &mut *sys_guard;
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::everything().without_tasks(),
);
let total_count = sys.processes().len();
// Snapshot current per-pid jiffies
let mut current: HashMap<u32, u64> = HashMap::with_capacity(total_count);
for p in sys.processes().values() {
let pid = p.pid().as_u32();
if let Some(j) = read_proc_jiffies(pid) {
current.insert(pid, j);
}
}
let total_now = read_total_jiffies().unwrap_or(0);
// Compute deltas vs last sample
let (last_total, mut last_map) = {
#[cfg(target_os = "linux")]
{
let mut t = state.proc_cpu.lock().await;
let lt = t.last_total;
let lm = std::mem::take(&mut t.last_per_pid);
t.last_total = total_now;
t.last_per_pid = current.clone();
(lt, lm)
}
#[cfg(not(target_os = "linux"))]
{
let _: u64 = total_now; // silence unused warning
(0u64, HashMap::new())
}
};
// On first run or if total delta is tiny, report zeros
if last_total == 0 || total_now <= last_total {
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| ProcessInfo {
pid: p.pid().as_u32(),
name: p.name().to_string_lossy().into_owned(),
cpu_usage: 0.0,
mem_bytes: p.memory(),
})
.collect();
return ProcessesPayload {
process_count: total_count,
top_processes: procs,
};
}
let dt = total_now.saturating_sub(last_total).max(1) as f32;
let procs: Vec<ProcessInfo> = sys
.processes()
.values()
.map(|p| {
let pid = p.pid().as_u32();
let now = current.get(&pid).copied().unwrap_or(0);
let prev = last_map.remove(&pid).unwrap_or(0);
let du = now.saturating_sub(prev) as f32;
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
ProcessInfo {
pid,
name: p.name().to_string_lossy().into_owned(),
cpu_usage: cpu,
mem_bytes: p.memory(),
}
})
.collect();
let payload = ProcessesPayload {
process_count: total_count,
top_processes: procs,
};
{
let mut cache = state.cache_processes.lock().await;
cache.set(payload.clone());
}
payload
}
/// Collect all processes (non-Linux): optimized for reduced allocations and selective updates.
#[cfg(not(target_os = "linux"))]
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// Serve from cache if fresh
{
let cache = state.cache_processes.lock().await;
if cache.is_fresh(StdDuration::from_millis(2_000)) {
// Use fixed TTL for cache check
if let Some(c) = cache.get() {
return c.clone();
}
}
}
// Single efficient refresh with optimized CPU collection
let (total_count, procs) = {
let mut sys = state.sys.lock().await;
let kind = ProcessRefreshKind::nothing().with_memory();
// Optimize refresh strategy based on system load
//if load > 5.0 {
//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());
// } else {
// // For idle systems, just get basic process info
// sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind);
// sys.refresh_cpu_usage();
// }
let total_count = sys.processes().len();
let cpu_count = sys.cpus().len() as f32;
// Reuse allocations via process cache
let mut proc_cache = state.proc_cache.lock().await;
proc_cache.reusable_vec.clear();
// Collect all processes, will sort by CPU later
for p in sys.processes().values() {
let pid = p.pid().as_u32();
// Reuse cached name if available
let name = if let Some(cached) = proc_cache.names.get(&pid) {
cached.clone()
} else {
let new_name = p.name().to_string_lossy().into_owned();
proc_cache.names.insert(pid, new_name.clone());
new_name
};
// 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;
proc_cache.reusable_vec.push(ProcessInfo {
pid,
name,
cpu_usage: total_cpu,
mem_bytes: p.memory(),
});
}
//JW no need to sort here; client does the sorting
// // Sort by CPU usage
// proc_cache.reusable_vec.sort_by(|a, b| {
// b.cpu_usage
// .partial_cmp(&a.cpu_usage)
// .unwrap_or(std::cmp::Ordering::Equal)
// });
// Clean up old process names cache when it grows too large
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
let now = std::time::Instant::now();
proc_cache
.names
.retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid)));
tracing::debug!(
"Cleaned up {} stale process names in {}ms",
proc_cache.names.capacity() - proc_cache.names.len(),
now.elapsed().as_millis()
);
}
// Get all processes, take ownership of the vec (will be replaced with empty vec)
(total_count, std::mem::take(&mut proc_cache.reusable_vec))
};
let payload = ProcessesPayload {
process_count: total_count,
top_processes: procs,
};
{
let mut cache = state.cache_processes.lock().await;
cache.set(payload.clone());
}
payload
}
+5
View File
@@ -0,0 +1,5 @@
// Generated protobuf modules live under OUT_DIR; include them here.
// This module will expose socktop::Processes and socktop::Process types.
pub mod pb {
include!(concat!(env!("OUT_DIR"), "/socktop.rs"));
}
+121
View File
@@ -0,0 +1,121 @@
//! Shared agent state: sysinfo handles and hot JSON cache.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::time::{Duration, Instant};
use sysinfo::{Components, Disks, Networks, System};
use tokio::sync::Mutex;
pub type SharedSystem = Arc<Mutex<System>>;
pub type SharedComponents = Arc<Mutex<Components>>;
pub type SharedDisks = Arc<Mutex<Disks>>;
pub type SharedNetworks = Arc<Mutex<Networks>>;
#[cfg(target_os = "linux")]
#[derive(Default)]
pub struct ProcCpuTracker {
pub last_total: u64,
pub last_per_pid: HashMap<u32, u64>,
}
#[cfg(not(target_os = "linux"))]
pub struct ProcessCache {
pub names: HashMap<u32, String>,
pub reusable_vec: Vec<crate::types::ProcessInfo>,
}
#[cfg(not(target_os = "linux"))]
impl Default for ProcessCache {
fn default() -> Self {
Self {
names: HashMap::with_capacity(1000), // Pre-allocate for typical modern system process count
reusable_vec: Vec::with_capacity(1000),
}
}
}
#[derive(Clone)]
pub struct AppState {
pub sys: SharedSystem,
pub components: SharedComponents,
pub disks: SharedDisks,
pub networks: SharedNetworks,
pub hostname: String,
// For correct per-process CPU% using /proc deltas (Linux only path uses this tracker)
#[cfg(target_os = "linux")]
pub proc_cpu: Arc<Mutex<ProcCpuTracker>>,
// Process name caching and vector reuse for non-Linux to reduce allocations
#[cfg(not(target_os = "linux"))]
pub proc_cache: Arc<Mutex<ProcessCache>>,
// Connection tracking (to allow future idle sleeps if desired)
pub client_count: Arc<AtomicUsize>,
pub auth_token: Option<String>,
// GPU negative cache (probe once). gpu_checked=true after first attempt; gpu_present reflects result.
pub gpu_checked: Arc<AtomicBool>,
pub gpu_present: Arc<AtomicBool>,
// Lightweight on-demand caches (TTL based) to cap CPU under bursty polling.
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
}
#[derive(Clone, Debug)]
pub struct CacheEntry<T> {
pub at: Option<Instant>,
pub value: Option<T>,
}
impl<T> CacheEntry<T> {
pub fn new() -> Self {
Self {
at: None,
value: None,
}
}
pub fn is_fresh(&self, ttl: Duration) -> bool {
self.at.is_some_and(|t| t.elapsed() < ttl) && self.value.is_some()
}
pub fn set(&mut self, v: T) {
self.value = Some(v);
self.at = Some(Instant::now());
}
pub fn get(&self) -> Option<&T> {
self.value.as_ref()
}
}
impl AppState {
pub fn new() -> Self {
let sys = System::new();
let components = Components::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let networks = Networks::new_with_refreshed_list();
Self {
sys: Arc::new(Mutex::new(sys)),
components: Arc::new(Mutex::new(components)),
disks: Arc::new(Mutex::new(disks)),
networks: Arc::new(Mutex::new(networks)),
hostname: System::host_name().unwrap_or_else(|| "unknown".into()),
#[cfg(target_os = "linux")]
proc_cpu: Arc::new(Mutex::new(ProcCpuTracker::default())),
#[cfg(not(target_os = "linux"))]
proc_cache: Arc::new(Mutex::new(ProcessCache::default())),
client_count: Arc::new(AtomicUsize::new(0)),
auth_token: std::env::var("SOCKTOP_TOKEN")
.ok()
.filter(|s| !s.is_empty()),
gpu_checked: Arc::new(AtomicBool::new(false)),
gpu_present: Arc::new(AtomicBool::new(false)),
cache_metrics: Arc::new(Mutex::new(CacheEntry::new())),
cache_disks: Arc::new(Mutex::new(CacheEntry::new())),
cache_processes: Arc::new(Mutex::new(CacheEntry::new())),
}
}
}
+91
View File
@@ -0,0 +1,91 @@
use rcgen::{CertificateParams, DistinguishedName, DnType, IsCa, SanType};
use std::{
fs,
io::Write,
net::{IpAddr, Ipv4Addr},
path::{Path, PathBuf},
};
use time::{Duration, OffsetDateTime};
fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("socktop_agent")
.join("tls")
}
pub fn cert_paths() -> (PathBuf, PathBuf) {
let dir = config_dir();
(dir.join("cert.pem"), dir.join("key.pem"))
}
pub fn ensure_self_signed_cert() -> anyhow::Result<(PathBuf, PathBuf)> {
let (cert_path, key_path) = cert_paths();
if cert_path.exists() && key_path.exists() {
return Ok((cert_path, key_path));
}
fs::create_dir_all(cert_path.parent().unwrap())?;
let hostname = hostname::get()
.ok()
.and_then(|s| s.into_string().ok())
.unwrap_or_else(|| "localhost".to_string());
let mut params = CertificateParams::new(vec![hostname.clone(), "localhost".into()])?;
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V6(
::std::net::Ipv6Addr::LOCALHOST,
)));
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
// Allow operator to provide extra SANs (comma-separated), e.g. IPs or DNS names
if let Ok(extra) = std::env::var("SOCKTOP_AGENT_EXTRA_SANS") {
for raw in extra.split(',') {
let s = raw.trim();
if s.is_empty() {
continue;
}
if let Ok(ip) = s.parse::<IpAddr>() {
params.subject_alt_names.push(SanType::IpAddress(ip));
} else {
match s.to_string().try_into() {
Ok(dns) => params.subject_alt_names.push(SanType::DnsName(dns)),
Err(_) => eprintln!("socktop_agent: ignoring invalid SAN entry: {s}"),
}
}
}
}
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, hostname.clone());
params.distinguished_name = dn;
params.is_ca = IsCa::NoCa;
// Dynamic validity: start slightly in the past to avoid clock skew issues, end ~397 days later
let now = OffsetDateTime::now_utc();
params.not_before = now - Duration::minutes(5);
params.not_after = now + Duration::days(397);
// Generate key pair (default is ECDSA P256 SHA256)
let key_pair = rcgen::KeyPair::generate()?; // defaults to ECDSA P256 SHA256
let cert = params.self_signed(&key_pair)?;
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
let mut f = fs::File::create(&cert_path)?;
f.write_all(cert_pem.as_bytes())?;
let mut k = fs::File::create(&key_path)?;
k.write_all(key_pem.as_bytes())?;
println!(
"socktop_agent: generated self-signed TLS certificate at {}",
cert_path.display()
);
println!("socktop_agent: private key at {}", key_path.display());
Ok((cert_path, key_path))
}
+49
View File
@@ -0,0 +1,49 @@
//! Data types sent to the client over WebSocket.
//! Keep this module minimal and stable — it defines the wire format.
use crate::gpu::GpuMetrics;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct DiskInfo {
pub name: String,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct NetworkInfo {
pub name: String,
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProcessInfo {
pub pid: u32,
pub name: String,
pub cpu_usage: f32,
pub mem_bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Metrics {
pub cpu_total: f32,
pub cpu_per_core: Vec<f32>,
pub mem_total: u64,
pub mem_used: u64,
pub swap_total: u64,
pub swap_used: u64,
pub hostname: String,
pub cpu_temp_c: Option<f32>,
pub disks: Vec<DiskInfo>,
pub networks: Vec<NetworkInfo>,
pub top_processes: Vec<ProcessInfo>,
pub gpus: Option<Vec<GpuMetrics>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProcessesPayload {
pub process_count: usize,
pub top_processes: Vec<ProcessInfo>,
}
+213
View File
@@ -0,0 +1,213 @@
//! WebSocket upgrade and per-connection handler (request-driven).
use axum::{
extract::ws::{Message, WebSocket},
extract::{Query, State, WebSocketUpgrade},
response::Response,
};
use flate2::{Compression, write::GzEncoder};
use futures_util::StreamExt;
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::io::Write;
use tokio::sync::Mutex;
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
const COMPRESSION_THRESHOLD: usize = 768;
// Reusable buffer for compression to avoid allocations
struct CompressionCache {
processes_vec: Vec<pb::Process>,
}
impl CompressionCache {
fn new() -> Self {
Self {
processes_vec: Vec::with_capacity(512), // Typical process count
}
}
}
static COMPRESSION_CACHE: OnceCell<Mutex<CompressionCache>> = OnceCell::new();
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> Response {
// optional auth
if let Some(expected) = state.auth_token.as_ref()
&& q.get("token") != Some(expected)
{
return ws.on_upgrade(|socket| async move {
let _ = socket.close().await;
});
}
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: AppState) {
state
.client_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
while let Some(Ok(msg)) = socket.next().await {
match msg {
Message::Text(ref text) if text == "get_metrics" => {
let m = collect_fast_metrics(&state).await;
let _ = send_json(&mut socket, &m).await;
}
Message::Text(ref text) if text == "get_disks" => {
let d = collect_disks(&state).await;
let _ = send_json(&mut socket, &d).await;
}
Message::Text(ref text) if text == "get_processes" => {
let payload = collect_processes_all(&state).await;
// Map to protobuf message
// Get cached buffers
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
let mut cache = cache.lock().await;
// Reuse process vector to build the list
cache.processes_vec.clear();
cache
.processes_vec
.extend(payload.top_processes.into_iter().map(|p| pb::Process {
pid: p.pid,
name: p.name,
cpu_usage: p.cpu_usage,
mem_bytes: p.mem_bytes,
}));
let pb = pb::Processes {
process_count: payload.process_count as u64,
rows: std::mem::take(&mut cache.processes_vec),
};
let mut buf = Vec::with_capacity(8 * 1024);
if prost::Message::encode(&pb, &mut buf).is_err() {
let _ = socket.send(Message::Close(None)).await;
} else {
// compress if large
if buf.len() <= COMPRESSION_THRESHOLD {
let _ = socket.send(Message::Binary(buf)).await;
} else {
// Create a new encoder for each message to ensure proper gzip headers
let mut encoder =
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
Ok(compressed) => {
let _ = socket.send(Message::Binary(compressed)).await;
}
Err(_) => {
let _ = socket.send(Message::Binary(buf)).await;
}
}
}
}
drop(cache); // Explicit drop to release mutex early
}
Message::Close(_) => break,
_ => {}
}
}
state
.client_count
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
// Small, cheap gzip for larger payloads; send text for small.
async fn send_json<T: serde::Serialize>(ws: &mut WebSocket, value: &T) -> Result<(), axum::Error> {
let json = serde_json::to_string(value).expect("serialize");
if json.len() <= COMPRESSION_THRESHOLD {
return ws.send(Message::Text(json)).await;
}
let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
enc.write_all(json.as_bytes()).ok();
let bin = enc.finish().unwrap_or_else(|_| json.into_bytes());
ws.send(Message::Binary(bin)).await
}
#[cfg(test)]
mod tests {
use super::*;
use prost::Message as ProstMessage;
use sysinfo::System;
#[tokio::test]
async fn test_process_list_not_empty() {
// Initialize system data first to ensure we have processes
let mut sys = System::new_all();
sys.refresh_all();
// Create state and put the refreshed system in it
let state = AppState::new();
{
let mut sys_lock = state.sys.lock().await;
*sys_lock = sys;
}
// Get processes directly using the collection function
let processes = collect_processes_all(&state).await;
// Convert to protobuf message format
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
let mut cache = cache.lock().await;
// Reuse process vector to build the list
cache.processes_vec.clear();
cache
.processes_vec
.extend(processes.top_processes.into_iter().map(|p| pb::Process {
pid: p.pid,
name: p.name,
cpu_usage: p.cpu_usage,
mem_bytes: p.mem_bytes,
}));
// Create the protobuf message
let pb = pb::Processes {
process_count: processes.process_count as u64,
rows: cache.processes_vec.clone(),
};
// Test protobuf encoding/decoding
let mut buf = Vec::new();
prost::Message::encode(&pb, &mut buf).expect("Failed to encode protobuf");
let decoded = pb::Processes::decode(buf.as_slice()).expect("Failed to decode protobuf");
// Print debug info
println!("Process count: {}", pb.process_count);
println!("Process vector length: {}", pb.rows.len());
println!("Encoded size: {} bytes", buf.len());
println!("Decoded process count: {}", decoded.rows.len());
// Print first few processes if available
for (i, process) in pb.rows.iter().take(5).enumerate() {
println!(
"Process {}: {} (PID: {}) CPU: {:.1}% MEM: {} bytes",
i + 1,
process.name,
process.pid,
process.cpu_usage,
process.mem_bytes
);
}
// Validate
assert!(!pb.rows.is_empty(), "Process list should not be empty");
assert!(
pb.process_count > 0,
"Process count should be greater than 0"
);
assert_eq!(
pb.process_count as usize,
pb.rows.len(),
"Process count mismatch with actual rows"
);
}
}
+28
View File
@@ -0,0 +1,28 @@
//! CLI arg parsing tests for socktop_agent (server)
use std::process::Command;
#[test]
fn test_help_and_port_short_long() {
// We verify port flags are accepted by ensuring the process starts (then we kill quickly).
// Use an unlikely port to avoid conflicts.
let exe = env!("CARGO_BIN_EXE_socktop_agent");
// TLS enabled with long --port
let mut child = Command::new(exe)
.args(["--enableSSL", "--port", "9555"])
.spawn()
.expect("spawn agent");
// Give it a moment to bind
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = child.kill();
let _ = child.wait();
// TLS enabled with short -p
let mut child2 = Command::new(exe)
.args(["--enableSSL", "-p", "9556"])
.spawn()
.expect("spawn agent");
std::thread::sleep(std::time::Duration::from_millis(150));
let _ = child2.kill();
let _ = child2.wait();
}
+40
View File
@@ -0,0 +1,40 @@
//! Unit test for port parsing logic moved out of `main.rs`.
fn parse_port<I: IntoIterator<Item = String>>(args: I, default_port: u16) -> u16 {
let mut it = args.into_iter();
let _ = it.next(); // program name
let mut long: Option<String> = None;
let mut short: Option<String> = None;
while let Some(a) = it.next() {
match a.as_str() {
"--port" => long = it.next(),
"-p" => short = it.next(),
_ if a.starts_with("--port=") => {
if let Some((_, v)) = a.split_once('=') {
long = Some(v.to_string());
}
}
_ => {}
}
}
long.or(short)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(default_port)
}
#[test]
fn port_long_short_and_assign() {
assert_eq!(
parse_port(vec!["agent".into(), "--port".into(), "9001".into()], 8443),
9001
);
assert_eq!(
parse_port(vec!["agent".into(), "-p".into(), "9002".into()], 8443),
9002
);
assert_eq!(
parse_port(vec!["agent".into(), "--port=9003".into()], 8443),
9003
);
assert_eq!(parse_port(vec!["agent".into()], 8443), 8443);
}

Some files were not shown because too many files have changed in this diff Show More