Compare commits

..

27 Commits

Author SHA1 Message Date
jason 5a04dae471 ci(deb): restrict APT publish to v* release tags
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Previously the workflow built and published on every push to master
and feature/debian-packaging in addition to v* tags. That meant the
gh-pages APT repo got overwritten on every commit with same-version
.debs, causing apt clients to see a phantom "update available" each
time and burning ~5-10 min of cross-compile CI per merge.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 12:55:43 -07:00
jason fd82cc234b Fix postinst message box alignment
Build Debian Packages / Build .deb for ${{ matrix.target }} (amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (arm64, aarch64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (armhf, armv7-unknown-linux-gnueabihf) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (riscv64, riscv64gc-unknown-linux-gnu) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2025-11-24 14:02:39 -08:00
jason 92c503ad84 change build dep
Build Debian Packages / Build .deb for ${{ matrix.target }} (amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (arm64, aarch64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (armhf, armv7-unknown-linux-gnueabihf) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (riscv64, riscv64gc-unknown-linux-gnu) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2025-11-24 11:56:15 -08:00
jason f5d267c08a fix secrets path
Build Debian Packages / Build .deb for ${{ matrix.target }} (amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (arm64, aarch64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (armhf, armv7-unknown-linux-gnueabihf) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (riscv64, riscv64gc-unknown-linux-gnu) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2025-11-24 10:20:59 -08:00
jason 4eddb19d59 fix secrets path 2025-11-23 17:04:27 -08:00
jason 745a681de7 copy output to apt repo
Build Debian Packages / Build .deb for ${{ matrix.target }} (amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (arm64, aarch64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (armhf, armv7-unknown-linux-gnueabihf) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (riscv64, riscv64gc-unknown-linux-gnu) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Publish to APT Repository (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2025-11-23 16:01:14 -08:00
jason a9366d069d apt repo github page
Build Debian Packages / Build .deb for ${{ matrix.target }} (amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (arm64, aarch64-unknown-linux-gnu) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (armhf, armv7-unknown-linux-gnueabihf) (push) Has been cancelled
Build Debian Packages / Build .deb for ${{ matrix.target }} (riscv64, riscv64gc-unknown-linux-gnu) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
Build Debian Packages / Combine all .deb packages (push) Has been cancelled
Build Debian Packages / Create GitHub Release (push) Has been cancelled
2025-11-22 20:11:18 -08:00
jason bf1b4f70c3 security.ubuntu.com` aNNOYING 2025-11-22 15:28:35 -08:00
jason f73e198a66 fixe for ARM64 build. 2025-11-22 14:37:54 -08:00
jason f7b095eb4a Add ARM64 as a foreign architecture 2025-11-22 14:27:10 -08:00
jason 180186e2cc add ports.ubuntu.com to sources 2025-11-22 12:55:51 -08:00
jason cc167f71d3 set correct x-compile lib gcc-aarch64-linux-gnu for arm64 builds. 2025-11-22 12:28:34 -08:00
jason dd1dbdf29b add gcc-arm-linux-gnueabihf linker fore armv7 2025-11-22 11:31:32 -08:00
jason 524af0d123 add .cargo to gitignore to elimicate issue with riscv64-linux-gnu-gcc linker in config.toml 2025-11-22 11:24:32 -08:00
jason 619c288e9e add RISC-V GCC compiler 2025-11-22 11:20:19 -08:00
jason e04e344e2c specify correct package name 2025-11-21 20:44:20 -08:00
jason 8c531f9981 install aarch64-linux-gnu-gcc build dep 2025-11-21 20:15:40 -08:00
jason 7fe302a3e2 specify correct package names. 2025-11-21 16:11:27 -08:00
jason 31c2b59fd4 feature gate GPU stats for arm v7 2025-11-21 15:23:44 -08:00
jason f9462a1633 Make GPU support optional to enable RISC-V builds without libdrm
- Add 'gpu' feature flag (enabled by default)
- Make gfxinfo dependency optional
- Provide no-op GPU metrics when gpu feature disabled
- Disable GPU support for RISC-V builds in CI (libdrm unavailable)
- All other architectures (amd64, arm64, armhf) still get GPU support
2025-11-21 00:58:44 -08:00
jason 512913e897 Fix package rename script - cargo-deb already includes architecture in filename 2025-11-21 00:50:54 -08:00
jason 64b641368c Fix CI build by installing libdrm development dependencies 2025-11-21 00:46:20 -08:00
jason f4b6faffaa Add helpful post-install message to guide users on enabling socktop-agent service 2025-11-21 00:41:41 -08:00
jason 4b9d11dc9e hotfix for issue with socktop agent not creating ssl certificate on first launch after upgrade of axum server version. 2025-11-21 00:26:34 -08:00
jason c80f8cc363 fix unit test, move to macro cargo_bin! 2025-11-21 00:26:34 -08:00
jason ed3d43ff7a Add summary documentation for debian packaging 2025-11-20 23:39:26 -08:00
jason 350611b3b1 Add Debian packaging support with cargo-deb
- Add cargo-deb metadata to socktop and socktop_agent Cargo.toml
- Create systemd service file for socktop_agent
- Add postinst/postrm maintainer scripts for user/group management
- Create GitHub Actions workflow to build .deb packages for AMD64 and ARM64
- Add comprehensive documentation in docs/DEBIAN_PACKAGING.md
- Packages will be available as artifacts on every push
- Automatic GitHub releases for version tags
2025-11-20 23:39:26 -08:00
26 changed files with 1215 additions and 3610 deletions
Generated
+277 -891
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -24,9 +24,8 @@ serde_json = "1.0"
sysinfo = "0.37" sysinfo = "0.37"
# CLI UI # CLI UI
ratatui = "0.30" ratatui = "0.28"
crossterm = "0.29" crossterm = "0.27"
unicode-width = "0.2"
# web server (remote-agent) # web server (remote-agent)
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws"] }
-29
View File
@@ -5,8 +5,6 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
- Linux agent: near-zero CPU when idle (request-driven, no always-on sampler) - Linux agent: near-zero CPU when idle (request-driven, no always-on sampler)
- TUI: smooth graphs, sortable process table, scrollbars, readable colors - TUI: smooth graphs, sortable process table, scrollbars, readable colors
[socktop.io](https://www.socktop.io)
<img src="./docs/socktop_demo.apng" width="100%"> <img src="./docs/socktop_demo.apng" width="100%">
--- ---
@@ -31,8 +29,6 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
- Only top-level processes listed (threads hidden) — matches btop/top - Only top-level processes listed (threads hidden) — matches btop/top
- Optional GPU metrics (can be disabled) - Optional GPU metrics (can be disabled)
- Optional auth token for the agent - Optional auth token for the agent
- Compact layout for small windows: automatically drops the panes that no longer fit so
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
--- ---
@@ -215,8 +211,6 @@ socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
# shorthand: # shorthand:
socktop -t /path/to/cert.pem wss://HOST:8443/ws socktop -t /path/to/cert.pem wss://HOST:8443/ws
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget # Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
# force the small-window layout at any terminal size (normally automatic):
socktop --compact ws://HOST:3000/ws
``` ```
Intervals (client-driven): Intervals (client-driven):
@@ -228,29 +222,6 @@ The agent stays idle unless queried. When queried, it collects just whats nee
--- ---
## Compact mode
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
are the first things to collapse — exactly the panes you are most likely watching. Once
the window is too short for the Disks pane to show even one disk, socktop switches to a
compact layout:
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
- **Memory and Swap move side by side** into the row Disks vacated.
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
with no GPU the pane disappears entirely.
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
below the size where they used to vanish.
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
layout at any window size:
```bash
socktop --compact ws://HOST:3000/ws
```
---
## Connection Profiles (Named) ## Connection Profiles (Named)
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later. You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Sync this repo to the 'gitea' remote as a mirror.
# - Mirrors ALL refs (branches, tags) and prunes removed ones.
# - This makes the Gitea repo match GitHub exactly.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Error: not inside a git repo" >&2
exit 1
fi
if ! git remote get-url gitea >/dev/null 2>&1; then
echo "Missing 'gitea' remote. Add it with:" >&2
echo " git remote add gitea https://gt.wittyoneoff.com/jason/socktop.git" >&2
exit 1
fi
echo "Fetching from origin (pruning)..."
git fetch origin --prune --tags
echo "Pushing mirror to gitea..."
git push gitea --mirror
echo "Done: Gitea should now match origin (GitHub)."
-1
View File
@@ -20,7 +20,6 @@ serde_json = { workspace = true }
url = { workspace = true } url = { workspace = true }
ratatui = { workspace = true } ratatui = { workspace = true }
crossterm = { workspace = true } crossterm = { workspace = true }
unicode-width = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
dirs-next = { workspace = true } dirs-next = { workspace = true }
sysinfo = { workspace = true } sysinfo = { workspace = true }
+213 -258
View File
@@ -15,7 +15,7 @@ use ratatui::{
//style::Color, // + add Color //style::Color, // + add Color
Terminal, Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
layout::Rect, layout::{Constraint, Direction, Rect},
}; };
use tokio::time::{sleep, timeout}; use tokio::time::{sleep, timeout};
@@ -27,18 +27,13 @@ use crate::ui::cpu::{
per_core_content_area, per_core_handle_key, per_core_handle_mouse, per_core_content_area, per_core_handle_key, per_core_handle_mouse,
per_core_handle_scrollbar_mouse, per_core_handle_scrollbar_mouse,
}; };
use crate::ui::layout::{AppLayout, compute as compute_layout};
use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::modal::{ModalAction, ModalManager, ModalType};
use crate::ui::processes::{ use crate::ui::processes::{
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection, ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection,
processes_handle_mouse_with_selection, processes_handle_mouse_with_selection,
}; };
use crate::ui::{ use crate::ui::{
disks::draw_disks, disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
gpu::{draw_gpu, draw_gpu_compact},
header::{HeaderState, build_header, draw_header},
mem::draw_mem,
net::draw_net_spark,
swap::draw_swap, swap::draw_swap,
}; };
@@ -51,15 +46,6 @@ use socktop_connector::{
const MIN_METRICS_INTERVAL_MS: u64 = 100; const MIN_METRICS_INTERVAL_MS: u64 = 100;
const MIN_PROCESSES_INTERVAL_MS: u64 = 200; const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
/// Drop duplicate-name entries from a disks payload (the agent occasionally
/// reports a partition twice). Done once when fresh disk data arrives so the
/// per-frame draw path doesn't have to rebuild a HashSet.
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(disks.len());
disks.retain(|d| seen.insert(d.name.clone()));
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ConnectionState { pub enum ConnectionState {
Connected, Connected,
@@ -71,9 +57,8 @@ pub struct App {
// Latest metrics + histories // Latest metrics + histories
last_metrics: Option<Metrics>, last_metrics: Option<Metrics>,
// CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame // CPU avg history (0..100)
cpu_hist: VecDeque<u64>, cpu_hist: VecDeque<u64>,
cpu_hist_sum: u64,
// Per-core history (0..100) // Per-core history (0..100)
per_core_hist: PerCoreHistory, per_core_hist: PerCoreHistory,
@@ -104,17 +89,6 @@ pub struct App {
pub process_search_active: bool, pub process_search_active: bool,
pub process_search_query: String, pub process_search_query: String,
// Cached filtered + sorted process indices. Refreshed lazily when any of
// (metrics, sort order, search query) changes — input handlers, the draw
// path, and auto-scroll all read from this slice so we avoid rebuilding
// an indices Vec on every event.
procs_filtered: Vec<usize>,
procs_filter_dirty: bool,
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
// parallel to `last_metrics.top_processes`.
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
procs_row_peak_cpu: f32,
last_procs_poll: Instant, last_procs_poll: Instant,
last_disks_poll: Instant, last_disks_poll: Instant,
procs_interval: Duration, procs_interval: Duration,
@@ -125,7 +99,6 @@ pub struct App {
pub process_details: Option<socktop_connector::ProcessMetricsResponse>, pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
pub journal_entries: Option<socktop_connector::JournalResponse>, pub journal_entries: Option<socktop_connector::JournalResponse>,
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples) pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples) pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples) pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples) pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
@@ -146,20 +119,6 @@ pub struct App {
pub is_tls: bool, pub is_tls: bool,
pub has_token: bool, pub has_token: bool,
// --compact: pin the compact layout regardless of window size. Without it the
// layout switches on its own once the window is too short for the Disks pane.
force_compact: bool,
// Cached title strings — only rebuilt when source values change so the
// diff renderer can suppress redraws on idle frames.
header_title: String,
header_intervals_text: String,
header_key: (String, bool, bool, u128, u128, u16),
net_dl_title: String,
net_dl_key: (u64, u64),
net_ul_title: String,
net_ul_key: (u64, u64),
// Modal system // Modal system
pub modal_manager: crate::ui::modal::ModalManager, pub modal_manager: crate::ui::modal::ModalManager,
@@ -177,7 +136,6 @@ impl App {
Self { Self {
last_metrics: None, last_metrics: None,
cpu_hist: VecDeque::with_capacity(600), cpu_hist: VecDeque::with_capacity(600),
cpu_hist_sum: 0,
per_core_hist: PerCoreHistory::new(60), per_core_hist: PerCoreHistory::new(60),
last_net_totals: None, last_net_totals: None,
rx_hist: VecDeque::with_capacity(600), rx_hist: VecDeque::with_capacity(600),
@@ -196,10 +154,6 @@ impl App {
prev_selected_process_pid: None, prev_selected_process_pid: None,
process_search_active: false, process_search_active: false,
process_search_query: String::new(), process_search_query: String::new(),
procs_filtered: Vec::new(),
procs_filter_dirty: true,
procs_row_cache: Vec::new(),
procs_row_peak_cpu: 0.0,
last_procs_poll: Instant::now() last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2)) .checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop .unwrap_or_else(Instant::now), // trigger immediately on first loop
@@ -212,7 +166,6 @@ impl App {
process_details: None, process_details: None,
journal_entries: None, journal_entries: None,
process_cpu_history: VecDeque::with_capacity(600), process_cpu_history: VecDeque::with_capacity(600),
process_cpu_history_sum: 0.0,
process_mem_history: VecDeque::with_capacity(600), process_mem_history: VecDeque::with_capacity(600),
process_io_read_history: VecDeque::with_capacity(600), process_io_read_history: VecDeque::with_capacity(600),
process_io_write_history: VecDeque::with_capacity(600), process_io_write_history: VecDeque::with_capacity(600),
@@ -233,14 +186,6 @@ impl App {
verify_hostname: false, verify_hostname: false,
is_tls: false, is_tls: false,
has_token: false, has_token: false,
force_compact: false,
header_title: String::new(),
header_intervals_text: String::new(),
header_key: (String::new(), false, false, u128::MAX, u128::MAX, u16::MAX),
net_dl_title: String::new(),
net_dl_key: (u64::MAX, u64::MAX),
net_ul_title: String::new(),
net_ul_key: (u64::MAX, u64::MAX),
modal_manager: ModalManager::new(), modal_manager: ModalManager::new(),
connection_state: ConnectionState::Disconnected, connection_state: ConnectionState::Disconnected,
last_connection_attempt: Instant::now(), last_connection_attempt: Instant::now(),
@@ -251,23 +196,6 @@ impl App {
} }
} }
/// Pins the compact layout at any window size (`--compact`).
pub fn with_compact(mut self, force_compact: bool) -> Self {
self.force_compact = force_compact;
self
}
/// Pane rects for the current frame. The draw path and the mouse/key hit-testing
/// paths all go through here so they cannot disagree about where a pane is.
fn layout(&self, area: Rect) -> AppLayout {
let has_gpu = self
.last_metrics
.as_ref()
.and_then(|m| m.gpus.as_ref())
.is_some_and(|g| !g.is_empty());
compute_layout(area, self.force_compact, has_gpu)
}
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self { pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
metrics_ms.inspect(|&m| { metrics_ms.inspect(|&m| {
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS)); self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
@@ -537,10 +465,7 @@ impl App {
_url: &str, _url: &str,
_tls_ca: Option<&str>, _tls_ca: Option<&str>,
_verify_hostname: bool, _verify_hostname: bool,
) -> Result<(), Box<dyn std::error::Error>> ) -> Result<(), Box<dyn std::error::Error>> {
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop { loop {
// Handle input for modal // Handle input for modal
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
@@ -647,10 +572,7 @@ impl App {
&mut self, &mut self,
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
mut ws: SocktopConnector, mut ws: SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> ) -> Result<(), Box<dyn std::error::Error>> {
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop { loop {
// Main event loop // Main event loop
let result = self.run_event_loop_iteration(terminal, &mut ws).await; let result = self.run_event_loop_iteration(terminal, &mut ws).await;
@@ -670,10 +592,7 @@ impl App {
&mut self, &mut self,
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
ws: &mut SocktopConnector, ws: &mut SocktopConnector,
) -> Result<(), Box<dyn std::error::Error>> ) -> Result<(), Box<dyn std::error::Error>> {
where
<B as ratatui::backend::Backend>::Error: 'static,
{
loop { loop {
// Input (non-blocking) // Input (non-blocking)
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
@@ -746,7 +665,6 @@ impl App {
// Exit search mode // Exit search mode
self.process_search_active = false; self.process_search_active = false;
self.process_search_query.clear(); self.process_search_query.clear();
self.invalidate_procs_filter();
continue; continue;
} }
KeyCode::Enter => { KeyCode::Enter => {
@@ -754,24 +672,27 @@ impl App {
self.process_search_active = false; self.process_search_active = false;
// Auto-select first filtered result // Auto-select first filtered result
let first = self.procs_filter().first().copied(); if let Some(m) = self.last_metrics.as_ref() {
if let (Some(first_idx), Some(m)) = let idxs = get_filtered_sorted_indices(
(first, self.last_metrics.as_ref()) m,
{ &self.process_search_query,
self.selected_process_index = Some(first_idx); self.procs_sort_by,
self.selected_process_pid = );
Some(m.top_processes[first_idx].pid); if !idxs.is_empty() {
let first_idx = idxs[0];
self.selected_process_index = Some(first_idx);
self.selected_process_pid =
Some(m.top_processes[first_idx].pid);
}
} }
continue; continue;
} }
KeyCode::Backspace => { KeyCode::Backspace => {
self.process_search_query.pop(); self.process_search_query.pop();
self.invalidate_procs_filter();
continue; continue;
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
self.process_search_query.push(c); self.process_search_query.push(c);
self.invalidate_procs_filter();
continue; continue;
} }
KeyCode::Up | KeyCode::Down => { KeyCode::Up | KeyCode::Down => {
@@ -807,7 +728,6 @@ impl App {
self.process_search_query.clear(); self.process_search_query.clear();
self.selected_process_pid = None; self.selected_process_pid = None;
self.selected_process_index = None; self.selected_process_index = None;
self.invalidate_procs_filter();
continue; continue;
} }
@@ -824,12 +744,21 @@ impl App {
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
let layout = self.layout(area); let rows = ratatui::layout::Layout::default()
let content = per_core_content_area(layout.per_core); .direction(Direction::Vertical)
.constraints([
// Refresh the filtered+sorted index cache once before we Constraint::Length(1),
// borrow individual fields of `self`. Constraint::Ratio(1, 3),
let _ = self.procs_filter(); 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]);
// First try process selection (only handles arrows if a process is selected) // First try process selection (only handles arrows if a process is selected)
let process_handled = if self.last_procs_area.is_some() { let process_handled = if self.last_procs_area.is_some() {
@@ -838,7 +767,8 @@ impl App {
selected_process_index: &mut self.selected_process_index, selected_process_index: &mut self.selected_process_index,
key: k, key: k,
metrics: self.last_metrics.as_ref(), metrics: self.last_metrics.as_ref(),
filtered_indices: &self.procs_filtered, sort_by: self.procs_sort_by,
search_query: &self.process_search_query,
}) })
} else { } else {
false false
@@ -856,9 +786,14 @@ impl App {
// Auto-scroll to keep selected process visible // Auto-scroll to keep selected process visible
if let (Some(selected_idx), Some(p_area)) = if let (Some(selected_idx), Some(p_area)) =
(self.selected_process_index, self.last_procs_area) (self.selected_process_index, self.last_procs_area)
&& self.last_metrics.is_some() && let Some(m) = self.last_metrics.as_ref()
{ {
let idxs = &self.procs_filtered; // Get filtered and sorted indices (same as display)
let idxs = get_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
);
// Find the display position of the selected process in filtered list // Find the display position of the selected process in filtered list
if let Some(display_pos) = if let Some(display_pos) =
@@ -923,10 +858,23 @@ impl App {
// Layout to get areas // Layout to get areas
let sz = terminal.size()?; let sz = terminal.size()?;
let area = Rect::new(0, 0, sz.width, sz.height); let area = Rect::new(0, 0, sz.width, sz.height);
let layout = self.layout(area); 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 // Content wheel scrolling
let content = per_core_content_area(layout.per_core); let content = per_core_content_area(top[1]);
per_core_handle_mouse( per_core_handle_mouse(
&mut self.per_core_scroll, &mut self.per_core_scroll,
m, m,
@@ -944,7 +892,7 @@ impl App {
&mut self.per_core_scroll, &mut self.per_core_scroll,
&mut self.per_core_drag, &mut self.per_core_drag,
m, m,
layout.per_core, top[1],
total_rows, total_rows,
); );
@@ -955,17 +903,11 @@ impl App {
content.height as usize, content.height as usize,
); );
// Refresh filter cache before partial borrows of self.
let _ = self.procs_filter();
let search_box_visible =
self.process_search_active || !self.process_search_query.is_empty();
// Processes table: sort by column on header click and handle row selection // Processes table: sort by column on header click and handle row selection
if let (Some(_mm), Some(p_area)) = if let (Some(mm), Some(p_area)) =
(self.last_metrics.as_ref(), self.last_procs_area) (self.last_metrics.as_ref(), self.last_procs_area)
{ {
use crate::ui::processes::ProcessMouseParams; use crate::ui::processes::ProcessMouseParams;
let total_rows = self.procs_filtered.len();
if let Some(new_sort) = if let Some(new_sort) =
processes_handle_mouse_with_selection(ProcessMouseParams { processes_handle_mouse_with_selection(ProcessMouseParams {
scroll_offset: &mut self.procs_scroll_offset, scroll_offset: &mut self.procs_scroll_offset,
@@ -974,14 +916,13 @@ impl App {
drag: &mut self.procs_drag, drag: &mut self.procs_drag,
mouse: m, mouse: m,
area: p_area, area: p_area,
total_rows, total_rows: mm.top_processes.len(),
metrics: self.last_metrics.as_ref(), metrics: self.last_metrics.as_ref(),
search_box_visible, sort_by: self.procs_sort_by,
filtered_indices: &self.procs_filtered, search_query: &self.process_search_query,
}) })
{ {
self.procs_sort_by = new_sort; self.procs_sort_by = new_sort;
self.invalidate_procs_filter();
} }
} }
@@ -1018,36 +959,22 @@ impl App {
// Only poll processes every 2s // Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval { if self.last_procs_poll.elapsed() >= self.procs_interval {
let mut updated = false;
if let Ok(AgentResponse::Processes(procs)) = if let Ok(AgentResponse::Processes(procs)) =
ws.request(AgentRequest::Processes).await ws.request(AgentRequest::Processes).await
&& let Some(mm) = self.last_metrics.as_mut() && let Some(mm) = self.last_metrics.as_mut()
{ {
mm.top_processes = procs.top_processes; mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count); mm.process_count = Some(procs.process_count);
updated = true;
}
if updated {
self.invalidate_procs_filter();
// Rebuild the pre-formatted row cache for the next
// ~N frames. Done once per poll, not per frame.
if let Some(mm) = self.last_metrics.as_ref() {
self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache(
mm,
&mut self.procs_row_cache,
);
}
} }
self.last_procs_poll = Instant::now(); self.last_procs_poll = Instant::now();
} }
// Only poll disks every 5s // Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval { if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Ok(AgentResponse::Disks(mut disks)) = if let Ok(AgentResponse::Disks(disks)) =
ws.request(AgentRequest::Disks).await ws.request(AgentRequest::Disks).await
&& let Some(mm) = self.last_metrics.as_mut() && let Some(mm) = self.last_metrics.as_mut()
{ {
dedup_disks(&mut disks);
mm.disks = disks; mm.disks = disks;
} }
self.last_disks_poll = Instant::now(); self.last_disks_poll = Instant::now();
@@ -1073,14 +1000,7 @@ impl App {
Ok(Ok(AgentResponse::ProcessMetrics(details))) => { Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
// Update history for sparklines // Update history for sparklines
let cpu_usage = details.process.cpu_usage; let cpu_usage = details.process.cpu_usage;
let evicted_cpu = push_capped( push_capped(&mut self.process_cpu_history, cpu_usage, 600);
&mut self.process_cpu_history,
cpu_usage,
600,
);
self.process_cpu_history_sum = self.process_cpu_history_sum
+ cpu_usage
- evicted_cpu.unwrap_or(0.0);
let mem_bytes = details.process.mem_bytes; let mem_bytes = details.process.mem_bytes;
push_capped(&mut self.process_mem_history, mem_bytes, 600); push_capped(&mut self.process_mem_history, mem_bytes, 600);
@@ -1185,37 +1105,11 @@ impl App {
Ok(()) Ok(())
} }
/// Mark the filtered-process cache stale. Call this whenever
/// `procs_sort_by`, `process_search_query`, or the top_processes content
/// changes — the cache is rebuilt lazily on the next read.
pub fn invalidate_procs_filter(&mut self) {
self.procs_filter_dirty = true;
}
/// Lazily refresh and return the cached filtered+sorted process indices.
/// Empty slice when there are no metrics yet.
pub fn procs_filter(&mut self) -> &[usize] {
if self.procs_filter_dirty {
self.procs_filtered.clear();
if let Some(m) = self.last_metrics.as_ref() {
crate::ui::processes::fill_filtered_sorted_indices(
m,
&self.process_search_query,
self.procs_sort_by,
&mut self.procs_filtered,
);
}
self.procs_filter_dirty = false;
}
&self.procs_filtered
}
/// Clear process details when modal is closed or selection changes /// Clear process details when modal is closed or selection changes
pub fn clear_process_details(&mut self) { pub fn clear_process_details(&mut self) {
self.process_details = None; self.process_details = None;
self.journal_entries = None; self.journal_entries = None;
self.process_cpu_history.clear(); self.process_cpu_history.clear();
self.process_cpu_history_sum = 0.0;
self.process_mem_history.clear(); self.process_mem_history.clear();
self.process_io_read_history.clear(); self.process_io_read_history.clear();
self.process_io_write_history.clear(); self.process_io_write_history.clear();
@@ -1226,24 +1120,23 @@ impl App {
} }
fn update_with_metrics(&mut self, mut m: Metrics) { fn update_with_metrics(&mut self, mut m: Metrics) {
if let Some(prev) = self.last_metrics.as_mut() { if let Some(prev) = &self.last_metrics {
// Preserve slower fields when the fast payload omits them. // Preserve slower fields when the fast payload omits them
// prev is about to be dropped so we can move its Vecs instead of cloning.
if m.disks.is_empty() { if m.disks.is_empty() {
m.disks = std::mem::take(&mut prev.disks); m.disks = prev.disks.clone();
} }
if m.top_processes.is_empty() { if m.top_processes.is_empty() {
m.top_processes = std::mem::take(&mut prev.top_processes); m.top_processes = prev.top_processes.clone();
} }
// Preserve total processes count across fast updates
if m.process_count.is_none() { if m.process_count.is_none() {
m.process_count = prev.process_count; m.process_count = prev.process_count;
} }
} }
// CPU avg history with running sum // CPU avg history
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64; let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
let evicted = push_capped(&mut self.cpu_hist, v, 600); push_capped(&mut self.cpu_hist, v, 600);
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
// Per-core history (push current samples) // Per-core history (push current samples)
self.per_core_hist.ensure_cores(m.cpu_per_core.len()); self.per_core_hist.ensure_cores(m.cpu_per_core.len());
@@ -1273,106 +1166,112 @@ impl App {
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) { pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
let area = f.area(); let area = f.area();
let l = self.layout(area);
// Header — refresh cached strings only when their inputs change so the // Root rows: header, top (cpu avg + per-core), memory, swap, bottom
// ratatui diff renderer can suppress repaints on idle frames. The wording now let rows = ratatui::layout::Layout::default()
// depends on the row width too, so that is part of the key. .direction(Direction::Vertical)
{ .constraints([
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str()); Constraint::Length(1), // header
let state = HeaderState { Constraint::Ratio(1, 3), // top row
hostname, Constraint::Length(3), // memory (left) + GPU (right, part 1)
is_tls: self.is_tls, Constraint::Length(3), // swap (left) + GPU (right, part 2)
has_token: self.has_token, Constraint::Min(10), // bottom: disks + net (left), top procs (right)
metrics_ms: self.metrics_interval.as_millis(), ])
procs_ms: self.procs_interval.as_millis(), .split(area);
};
let key = (
hostname.unwrap_or("").to_string(),
self.is_tls,
self.has_token,
state.metrics_ms,
state.procs_ms,
l.header.width,
);
if self.header_key != key {
let (title, intervals) = build_header(state, l.header.width);
self.header_title = title;
self.header_intervals_text = intervals;
self.header_key = key;
}
}
draw_header(f, l.header, &self.header_title, &self.header_intervals_text);
draw_cpu_avg_graph( // Header
draw_header(
f, f,
l.cpu, rows[0],
&mut self.cpu_hist,
self.cpu_hist_sum,
self.last_metrics.as_ref(), 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( draw_per_core_bars(
f, f,
l.per_core, top_lr[1],
self.last_metrics.as_ref(), self.last_metrics.as_ref(),
&mut self.per_core_hist, &self.per_core_hist,
self.per_core_scroll, self.per_core_scroll,
); );
// Memory + Swap: stacked vertically in the normal layout, side by side in the // Memory + Swap rows split into left/right columns
// row Disks vacates in compact mode. let mem_lr = ratatui::layout::Layout::default()
draw_mem(f, l.mem, self.last_metrics.as_ref()); .direction(Direction::Horizontal)
draw_swap(f, l.swap, self.last_metrics.as_ref()); .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]);
// GPU: a panel beside Memory/Swap normally, a single full-width line in compact // Left: Memory + Swap
// mode, and absent entirely when the host reports no GPU while compact. draw_mem(f, mem_lr[0], self.last_metrics.as_ref());
if let Some(gpu_area) = l.gpu { draw_swap(f, swap_lr[0], self.last_metrics.as_ref());
if l.mode.is_compact() {
draw_gpu_compact(f, gpu_area, self.last_metrics.as_ref());
} else {
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
}
}
if let Some(disks_area) = l.disks { // Right: GPU spans the same vertical space as Memory + Swap
draw_disks(f, disks_area, self.last_metrics.as_ref()); 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());
// Net titles only change when the throughput or peak changes. // Bottom area: left = Disks + Network, right = Top Processes
let rx_now = self.rx_hist.back().copied().unwrap_or(0); let bottom_lr = ratatui::layout::Layout::default()
let rx_key = (rx_now, self.rx_peak); .direction(Direction::Horizontal)
if self.net_dl_key != rx_key { .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak); .split(rows[4]);
self.net_dl_key = rx_key;
} // 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( draw_net_spark(
f, f,
l.download, left_stack[1],
&self.net_dl_title, &format!(
&mut self.rx_hist, "Download (KB/s) — now: {} | peak: {}",
self.rx_hist.back().copied().unwrap_or(0),
self.rx_peak
),
&self.rx_hist,
ratatui::style::Color::Green, ratatui::style::Color::Green,
); );
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
let tx_key = (tx_now, self.tx_peak);
if self.net_ul_key != tx_key {
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
self.net_ul_key = tx_key;
}
draw_net_spark( draw_net_spark(
f, f,
l.upload, left_stack[2],
&self.net_ul_title, &format!(
&mut self.tx_hist, "Upload (KB/s) — now: {} | peak: {}",
self.tx_hist.back().copied().unwrap_or(0),
self.tx_peak
),
&self.tx_hist,
ratatui::style::Color::Blue, ratatui::style::Color::Blue,
); );
// Right bottom: Top Processes fills the column // Right bottom: Top Processes fills the column
let procs_area = l.procs; let procs_area = bottom_lr[1];
// Cache for input handlers // Cache for input handlers
self.last_procs_area = Some(procs_area); self.last_procs_area = Some(procs_area);
// Refresh the filter cache before partial borrows of self.
let _ = self.procs_filter();
crate::ui::processes::draw_top_processes( crate::ui::processes::draw_top_processes(
f, f,
procs_area, procs_area,
@@ -1384,9 +1283,6 @@ impl App {
selected_process_index: self.selected_process_index, selected_process_index: self.selected_process_index,
search_query: &self.process_search_query, search_query: &self.process_search_query,
search_active: self.process_search_active, search_active: self.process_search_active,
filtered_indices: &self.procs_filtered,
cached_rows: &self.procs_row_cache,
peak_cpu: self.procs_row_peak_cpu,
}, },
); );
@@ -1400,7 +1296,6 @@ impl App {
journal: self.journal_entries.as_ref(), journal: self.journal_entries.as_ref(),
history: ProcessHistoryData { history: ProcessHistoryData {
cpu: &self.process_cpu_history, cpu: &self.process_cpu_history,
cpu_sum: self.process_cpu_history_sum,
mem: &self.process_mem_history, mem: &self.process_mem_history,
io_read: &self.process_io_read_history, io_read: &self.process_io_read_history,
io_write: &self.process_io_write_history, io_write: &self.process_io_write_history,
@@ -1415,6 +1310,66 @@ impl App {
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
Self::new() 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,
selected_process_pid: None,
selected_process_index: None,
prev_selected_process_pid: None,
process_search_active: false,
process_search_query: String::new(),
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),
process_details: None,
journal_entries: None,
process_cpu_history: VecDeque::with_capacity(600),
process_mem_history: VecDeque::with_capacity(600),
process_io_read_history: VecDeque::with_capacity(600),
process_io_write_history: VecDeque::with_capacity(600),
last_io_read_bytes: None,
last_io_write_bytes: None,
max_process_mem_bytes: 0,
process_details_unsupported: false,
last_process_details_poll: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
last_journal_poll: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
process_details_interval: Duration::from_millis(500),
journal_interval: Duration::from_secs(5),
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,
}
} }
} }
+7 -16
View File
@@ -2,25 +2,16 @@
use std::collections::VecDeque; use std::collections::VecDeque;
/// Push a value into a capped deque. Returns the evicted front element if any. pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
/// Callers maintaining a running sum can use this to update the sum without if dq.len() == cap {
/// re-iterating the whole deque. dq.pop_front();
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> { }
let evicted = if dq.len() == cap {
dq.pop_front()
} else {
None
};
dq.push_back(v); dq.push_back(v);
evicted
} }
// Keeps a history deque per core with a fixed capacity. // Keeps a history deque per core with a fixed capacity
// Storage is u64 so sparkline rendering can hand the slice directly to
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
// allocation or widening conversion.
pub struct PerCoreHistory { pub struct PerCoreHistory {
pub deques: Vec<VecDeque<u64>>, pub deques: Vec<VecDeque<u16>>,
cap: usize, cap: usize,
} }
@@ -44,7 +35,7 @@ impl PerCoreHistory {
pub fn push_samples(&mut self, samples: &[f32]) { pub fn push_samples(&mut self, samples: &[f32]) {
self.ensure_cores(samples.len()); self.ensure_cores(samples.len());
for (i, v) in samples.iter().enumerate() { for (i, v) in samples.iter().enumerate() {
let val = v.clamp(0.0, 100.0).round() as u64; let val = v.clamp(0.0, 100.0).round() as u16;
push_capped(&mut self.deques[i], val, self.cap); push_capped(&mut self.deques[i], val, self.cap);
} }
} }
+12 -77
View File
@@ -22,7 +22,6 @@ pub(crate) struct ParsedArgs {
metrics_interval_ms: Option<u64>, metrics_interval_ms: Option<u64>,
processes_interval_ms: Option<u64>, processes_interval_ms: Option<u64>,
verify_hostname: bool, verify_hostname: bool,
compact: bool,
} }
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> { pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
@@ -37,12 +36,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
let mut metrics_interval_ms: Option<u64> = None; let mut metrics_interval_ms: Option<u64> = None;
let mut processes_interval_ms: Option<u64> = None; let mut processes_interval_ms: Option<u64> = None;
let mut verify_hostname = false; let mut verify_hostname = false;
let mut compact = false;
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
match arg.as_str() { match arg.as_str() {
"-h" | "--help" => { "-h" | "--help" => {
return Err(format!( return Err(format!(
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n" "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
)); ));
} }
"--tls-ca" | "-t" => { "--tls-ca" | "-t" => {
@@ -63,11 +61,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
"--demo" => { "--demo" => {
demo = true; demo = true;
} }
"--compact" => {
// Force the small-window layout at any terminal size. Without it the
// layout switches on its own once the window gets too short.
compact = true;
}
"--dry-run" => { "--dry-run" => {
// intentionally undocumented // intentionally undocumented
dry_run = true; dry_run = true;
@@ -107,7 +100,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
url = Some(arg); url = Some(arg);
} else { } else {
return Err(format!( return Err(format!(
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]" "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
)); ));
} }
} }
@@ -123,7 +116,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
metrics_interval_ms, metrics_interval_ms,
processes_interval_ms, processes_interval_ms,
verify_hostname, verify_hostname,
compact,
}) })
} }
@@ -144,7 +136,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await; return run_demo_mode(parsed.tls_ca.as_deref()).await;
} }
let profiles_file = load_profiles(); let profiles_file = load_profiles();
@@ -249,7 +241,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if (1..=names.len()).contains(&idx) { if (1..=names.len()).contains(&idx) {
let name = &names[idx - 1]; let name = &names[idx - 1];
if name == "demo" { if name == "demo" {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await; return run_demo_mode(parsed.tls_ca.as_deref()).await;
} }
if let Some(entry) = profiles_mut.profiles.get(name) { if let Some(entry) = profiles_mut.profiles.get(name) {
( (
@@ -309,7 +301,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
eprintln!("If you don't have an agent running, you can try the demo mode."); eprintln!("If you don't have an agent running, you can try the demo mode.");
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") { if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await; return run_demo_mode(parsed.tls_ca.as_deref()).await;
} else { } else {
eprintln!("Aborting. You can run 'socktop --help' for usage information."); eprintln!("Aborting. You can run 'socktop --help' for usage information.");
return Ok(()); return Ok(());
@@ -323,8 +315,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let has_token = url.contains("token="); let has_token = url.contains("token=");
let mut app = App::new() let mut app = App::new()
.with_intervals(metrics_interval_ms, processes_interval_ms) .with_intervals(metrics_interval_ms, processes_interval_ms)
.with_status(is_tls, has_token) .with_status(is_tls, has_token);
.with_compact(parsed.compact);
if parsed.dry_run { if parsed.dry_run {
return Ok(()); return Ok(());
} }
@@ -388,23 +379,11 @@ fn gather_intervals(
} }
// Demo mode implementation // Demo mode implementation
async fn run_demo_mode( async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
_tls_ca: Option<&str>,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let port = 3231; let port = 3231;
let url = format!("ws://127.0.0.1:{port}/ws"); let url = format!("ws://127.0.0.1:{port}/ws");
let child = match spawn_demo_agent(port) { let child = spawn_demo_agent(port)?;
Ok(child) => child, let mut app = App::new();
// The agent ships as its own binary, so a missing one is a setup problem,
// not a crash: tell the user how to fix it instead of dumping an io error.
Err(e @ DemoAgentError::NotFound(_)) => {
eprintln!("{e}");
return Ok(());
}
Err(e) => return Err(e.into()),
};
let mut app = App::new().with_compact(compact);
// Demo mode connects to localhost, so disable hostname verification // Demo mode connects to localhost, so disable hostname verification
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } } tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
} }
@@ -420,50 +399,9 @@ impl Drop for DemoGuard {
eprintln!("Stopped demo agent on port {}", self.port); eprintln!("Stopped demo agent on port {}", self.port);
} }
} }
#[derive(Debug)] fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
enum DemoAgentError {
/// The socktop_agent executable could not be located.
NotFound(std::path::PathBuf),
Io(std::io::Error),
}
impl std::fmt::Display for DemoAgentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(candidate) => write!(
f,
"Could not start demo mode: '{}' was not found{}.\n\
\n\
Demo mode runs a local agent, which is shipped as a separate binary\n\
and is not installed alongside the socktop TUI. Install it with:\n\
\n cargo install socktop_agent\n\n\
then run socktop again. See {} for other install options.",
candidate.display(),
// A bare file name means find_agent_executable() fell back to a PATH lookup.
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
" on your PATH"
} else {
""
},
env!("CARGO_PKG_HOMEPAGE"),
),
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
}
}
}
impl std::error::Error for DemoAgentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NotFound(_) => None,
Self::Io(e) => Some(e),
}
}
}
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
let candidate = find_agent_executable(); let candidate = find_agent_executable();
let mut cmd = std::process::Command::new(&candidate); let mut cmd = std::process::Command::new(candidate);
cmd.arg("--port").arg(port.to_string()); cmd.arg("--port").arg(port.to_string());
cmd.env("SOCKTOP_ENABLE_SSL", "0"); cmd.env("SOCKTOP_ENABLE_SSL", "0");
@@ -471,10 +409,7 @@ fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
//cmd.env("SOCKTOP_AGENT_GPU", "0"); //cmd.env("SOCKTOP_AGENT_GPU", "0");
//cmd.env("SOCKTOP_AGENT_TEMP", "0"); //cmd.env("SOCKTOP_AGENT_TEMP", "0");
let child = cmd.spawn().map_err(|e| match e.kind() { let child = cmd.spawn()?;
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
_ => DemoAgentError::Io(e),
})?;
std::thread::sleep(std::time::Duration::from_millis(300)); std::thread::sleep(std::time::Duration::from_millis(300));
Ok(DemoGuard { Ok(DemoGuard {
port, port,
+88 -331
View File
@@ -7,17 +7,11 @@ use ratatui::style::{Color, Style};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span}, text::{Line, Span},
widgets::{ widgets::{Block, Borders, Paragraph, Sparkline},
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
},
}; };
use crate::history::PerCoreHistory; use crate::history::PerCoreHistory;
use crate::types::Metrics; use crate::types::Metrics;
use crate::ui::fit::{cols, pick_pair};
/// Columns kept clear between the CPU title and the temperature readout.
const TITLE_GAP: u16 = 2;
/// State for dragging the scrollbar thumb /// State for dragging the scrollbar thumb
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
@@ -139,9 +133,11 @@ pub fn per_core_handle_scrollbar_mouse(
} }
let thumb_len = (track * view).div_ceil(total).max(1).min(track); let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let top_for_offset = |off: usize| -> usize { let top_for_offset = |off: usize| -> usize {
((track - thumb_len) * off + max_off / 2) if max_off == 0 {
.checked_div(max_off) 0
.unwrap_or(0) } else {
((track - thumb_len) * off + max_off / 2) / max_off
}
}; };
let thumb_top = top_for_offset(offset); let thumb_top = top_for_offset(offset);
@@ -194,9 +190,11 @@ pub fn per_core_handle_scrollbar_mouse(
// Inverse mapping top -> offset // Inverse mapping top -> offset
if track > thumb_len { if track > thumb_len {
let denom = track - thumb_len; let denom = track - thumb_len;
offset = (new_top * max_off + denom / 2) offset = if max_off == 0 {
.checked_div(denom) 0
.unwrap_or(0); } else {
(new_top * max_off + denom / 2) / denom
};
} else { } else {
offset = 0; offset = 0;
} }
@@ -236,51 +234,62 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
} }
/// Draws the CPU average sparkline graph. /// Draws the CPU average sparkline graph.
///
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
/// fold the (up to 600-element) deque on every frame.
pub fn draw_cpu_avg_graph( pub fn draw_cpu_avg_graph(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
hist: &mut std::collections::VecDeque<u64>, hist: &std::collections::VecDeque<u64>,
hist_sum: u64,
m: Option<&Metrics>, m: Option<&Metrics>,
) { ) {
let avg_cpu = if hist.is_empty() { // Calculate average CPU over the monitoring period
0.0 let avg_cpu = if !hist.is_empty() {
let sum: u64 = hist.iter().sum();
sum as f64 / hist.len() as f64
} else { } else {
hist_sum as f64 / hist.len() as f64 0.0
}; };
let (title, top_right_info) = cpu_title_for_width( let title = if let Some(mm) = m {
m.map(|mm| mm.cpu_total), format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
avg_cpu, } else {
m.and_then(|mm| mm.cpu_temp_c), "CPU avg".into()
area.width, };
);
// Build the top-right info (CPU temp and polling intervals)
let top_right_info = if let Some(mm) = m {
mm.cpu_temp_c
.map(|t| {
let icon = if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
};
format!("CPU Temp: {t:.1}°C {icon}")
})
.unwrap_or_else(|| "CPU Temp: N/A".into())
} else {
String::new()
};
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
// us skip the per-frame Vec allocation .collect() used to do.
let max_points = area.width.saturating_sub(2) as usize; let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points); let start = hist.len().saturating_sub(max_points);
let slice = &hist.make_contiguous()[start..]; let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
// Render the sparkline with title on left
let spark = Sparkline::default() let spark = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(title)) .block(Block::default().borders(Borders::ALL).title(title))
.data(slice) .data(&data)
.max(100) .max(100)
.style(Style::default().fg(Color::Cyan)); .style(Style::default().fg(Color::Cyan));
f.render_widget(spark, area); f.render_widget(spark, area);
// Temperature overlays the top border, right-aligned inside the corner. The title // Render the top-right info as text overlay in the top-right corner
// above is sized so the two cannot collide.
if !top_right_info.is_empty() { if !top_right_info.is_empty() {
let w = cols(&top_right_info);
let info_area = Rect { let info_area = Rect {
x: area.x + area.width.saturating_sub(w + 1), x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
y: area.y, y: area.y,
width: w, width: top_right_info.len() as u16 + 1,
height: 1, height: 1,
}; };
let info_line = Line::from(Span::raw(top_right_info)); let info_line = Line::from(Span::raw(top_right_info));
@@ -288,73 +297,12 @@ pub fn draw_cpu_avg_graph(
} }
} }
/// Health glyph for a CPU temperature.
fn temp_icon(t: f32) -> &'static str {
if t < 50.0 {
"😎"
} else if t < 85.0 {
"⚠️"
} else {
"🔥"
}
}
/// Chooses the CPU pane's title and its right-aligned temperature readout for a pane
/// `width` columns wide.
///
/// Both are painted onto the pane's top border, so without a shared budget the
/// temperature simply overwrites the tail of the title on a narrow pane. Detail is given
/// up in this order: the `CPU Temp:` label, then the `now:`/`avg:` labels, then the
/// average reading, then the decimal on the temperature, and only last the temperature
/// itself — the readings are what the pane is for, but a thermal warning is worth more
/// than a second decimal place.
fn cpu_title_for_width(
cpu_now: Option<f32>,
avg_cpu: f64,
temp_c: Option<f32>,
width: u16,
) -> (String, String) {
let Some(now) = cpu_now else {
return ("CPU avg".into(), String::new());
};
// Two borders, plus a column of breathing room at each end of the title.
let budget = width.saturating_sub(4);
let labelled = format!("CPU (now: {now:>5.1}% | avg: {avg_cpu:>5.1}%)");
let bare = format!("CPU ({now:.1}% | {avg_cpu:.1}%)");
let now_only = format!("CPU ({now:.1}%)");
let (temp_labelled, temp_plain, temp_coarse) = match temp_c {
Some(t) => {
let icon = temp_icon(t);
(
format!("CPU Temp: {t:.1}°C {icon}"),
format!("{t:.1}°C {icon}"),
format!("{t:.0}°C {icon}"),
)
}
None => ("CPU Temp: N/A".into(), "N/A".into(), "N/A".into()),
};
let ladder = [
(labelled.as_str(), temp_labelled.as_str()),
(labelled.as_str(), temp_plain.as_str()),
(bare.as_str(), temp_plain.as_str()),
(bare.as_str(), temp_coarse.as_str()),
(now_only.as_str(), temp_coarse.as_str()),
(now_only.as_str(), ""),
];
let (title, temp) = pick_pair(budget, TITLE_GAP, &ladder);
(title.to_string(), temp.to_string())
}
/// Draws the per-core CPU bars with sparklines and trends. /// Draws the per-core CPU bars with sparklines and trends.
pub fn draw_per_core_bars( pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
m: Option<&Metrics>, m: Option<&Metrics>,
per_core_hist: &mut PerCoreHistory, per_core_hist: &PerCoreHistory,
scroll_offset: usize, scroll_offset: usize,
) { ) {
f.render_widget( f.render_widget(
@@ -399,7 +347,7 @@ pub fn draw_per_core_bars(
let rect = vchunks[i]; let rect = vchunks[i];
let hchunks = Layout::default() let hchunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Min(6), Constraint::Length(13)]) .constraints([Constraint::Min(6), Constraint::Length(12)])
.split(rect); .split(rect);
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0); let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
@@ -410,17 +358,12 @@ pub fn draw_per_core_bars(
.map(|v| v as f32) .map(|v| v as f32)
.unwrap_or(curr); .unwrap_or(curr);
// Trend indicator. Various Unicode glyphs we tried for the "flat"
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
// coverage; combined with the next column being `100.0` they read as
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
// for the flat case — no character, no fallback, no confusion.
let trend = if curr > older + 0.2 { let trend = if curr > older + 0.2 {
"" ""
} else if curr + 0.2 < older { } else if curr + 0.2 < older {
"" ""
} else { } else {
" " ""
}; };
let fg = match curr { let fg = match curr {
@@ -429,24 +372,24 @@ pub fn draw_per_core_bars(
_ => Color::Red, _ => Color::Red,
}; };
// Borrow the per-core deque mutably so we can hand a contiguous slice let hist: Vec<u64> = per_core_hist
// to Sparkline without allocating a fresh Vec each frame. .deques
if let Some(d) = per_core_hist.deques.get_mut(idx) { .get(idx)
let max_points = hchunks[0].width as usize; .map(|d| {
let start = d.len().saturating_sub(max_points); let max_points = hchunks[0].width as usize;
let slice = &d.make_contiguous()[start..]; let start = d.len().saturating_sub(max_points);
let spark = Sparkline::default() d.iter().skip(start).map(|&v| v as u64).collect()
.data(slice) })
.max(100) .unwrap_or_default();
.style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]);
}
// Hard space between the trend mark and the number — even if the let spark = Sparkline::default()
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks .data(&hist)
// them, this space prevents the trend mark from visually joining .max(100)
// `100.0` to look like a negative value. .style(Style::default().fg(fg));
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
f.render_widget(spark, hchunks[0]);
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
let line = Line::from(Span::styled( let line = Line::from(Span::styled(
label, label,
Style::default().fg(fg).add_modifier(Modifier::BOLD), Style::default().fg(fg).add_modifier(Modifier::BOLD),
@@ -454,224 +397,38 @@ pub fn draw_per_core_bars(
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]); f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
} }
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the // Custom 1-col scrollbar with arrows, track, and exact mapping
// content fits in the viewport, matching the previous behaviour.
let scroll_area = Rect { let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1), x: inner.x + inner.width.saturating_sub(1),
y: inner.y, y: inner.y,
width: 1, width: 1,
height: inner.height, height: inner.height,
}; };
let max_off = total_rows.saturating_sub(viewport_rows); if scroll_area.height >= 3 {
if scroll_area.height >= 3 && max_off > 0 { let track = (scroll_area.height - 2) as usize;
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) let total = total_rows.max(1);
.begin_symbol(Some("")) let view = viewport_rows.clamp(1, total);
.end_symbol(Some("")) let max_off = total.saturating_sub(view);
.thumb_symbol("")
.track_symbol(Some(""))
.thumb_style(Style::default().fg(SB_THUMB))
.track_style(Style::default().fg(SB_TRACK))
.begin_style(Style::default().fg(SB_ARROW))
.end_style(Style::default().fg(SB_ARROW));
let mut state = ScrollbarState::new(max_off).position(offset);
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
}
}
#[cfg(test)] let thumb_len = (track * view).div_ceil(total).max(1).min(track);
mod title_tests { let thumb_top = if max_off == 0 {
use super::*; 0
} else {
((track - thumb_len) * offset + max_off / 2) / max_off
};
/// The defect this replaces: the temperature was painted over the title's tail on a // Build lines: top arrow, track (with thumb), bottom arrow
/// narrow pane. Whatever the width, the two must fit side by side on the border. let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
#[test] lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
fn title_and_temperature_never_overlap() { for i in 0..track {
for width in 0..=200u16 { if i >= thumb_top && i < thumb_top + thumb_len {
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width); lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB))));
let budget = width.saturating_sub(4); } else {
if temp.is_empty() { lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK))));
continue;
}
assert!(
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
);
}
}
/// The current CPU reading is the one thing the pane must always show.
#[test]
fn the_current_reading_always_survives() {
for width in 20..=200u16 {
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
assert!(
title.contains("3.4"),
"width {width}: lost the reading ({title:?})"
);
}
}
/// The ladder from the design: temp label, then now/avg labels, then the average,
/// then the temperature's decimal, then the temperature.
#[test]
fn detail_is_dropped_in_priority_order() {
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
let (title, temp) = at(80);
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
// The "CPU Temp:" label goes first; the readings keep their labels.
let (title, temp) = at(50);
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
assert_eq!(temp, "43.0°C 😎");
// Then the now:/avg: labels.
let (title, temp) = at(40);
assert_eq!(title, "CPU (0.7% | 1.3%)");
assert_eq!(temp, "43.0°C 😎");
// Then the temperature's decimal.
let (title, temp) = at(31);
assert_eq!(title, "CPU (0.7% | 1.3%)");
assert_eq!(temp, "43°C 😎");
// Then the average reading.
let (title, temp) = at(26);
assert_eq!(title, "CPU (0.7%)");
assert_eq!(temp, "43°C 😎");
// Last of all, the temperature itself.
let (title, temp) = at(15);
assert_eq!(title, "CPU (0.7%)");
assert_eq!(temp, "");
}
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
/// reading at every tier that shows a temperature at all.
#[test]
fn the_thermal_glyph_tracks_the_temperature() {
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
for width in 26..=80u16 {
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
assert!(
temp.contains(icon),
"width {width} at {t}°C: expected {icon} in {temp:?}"
);
} }
} }
} lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
/// An agent that reports no temperature must not leave a stray label behind. f.render_widget(Paragraph::new(lines), scroll_area);
#[test]
fn a_missing_temperature_degrades_to_nothing() {
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
assert_eq!(temp, "CPU Temp: N/A");
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
assert_eq!(temp, "");
}
/// Before the first payload arrives there are no readings to show.
#[test]
fn no_metrics_yet_shows_the_placeholder() {
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
assert_eq!(title, "CPU avg");
assert!(temp.is_empty());
}
}
#[cfg(test)]
mod render_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use socktop_connector::Metrics;
fn fake_metrics(cores: Vec<f32>) -> Metrics {
Metrics {
cpu_total: 0.0,
cpu_per_core: cores,
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus: None,
process_count: Some(0),
}
}
fn dump(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
/// coverage. When a core sat at exactly 100% the label rendered as
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
/// literal space for the flat case AND insert a hard space between every
/// trend mark and the number, so no glyph substitution can produce a
/// "-100" substring. We assert that across flat AND transitioning cores.
#[test]
fn percore_label_never_renders_as_negative() {
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
let mut hist = PerCoreHistory::new(60);
hist.ensure_cores(4);
// First sample: history is empty, no trend on first frame.
hist.push_samples(&m.cpu_per_core);
// Second sample: identical values → flat trend (the user's complaint).
hist.push_samples(&m.cpu_per_core);
let backend = TestBackend::new(120, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
})
.unwrap();
let out = dump(&terminal);
eprintln!("---flat 100% render---\n{out}");
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
// Decreasing trend at saturation: hist was high, current drops a bit.
let mut hist2 = PerCoreHistory::new(60);
hist2.ensure_cores(4);
for _ in 0..25 {
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
}
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
hist2.push_samples(&m2.cpu_per_core);
let backend = TestBackend::new(120, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
})
.unwrap();
let out = dump(&terminal);
eprintln!("---decreasing render---\n{out}");
assert!(
!out.contains("-100"),
"found '-100' in decreasing-trend render"
);
assert!(
!out.contains("-80"),
"found '-80' in decreasing-trend render"
);
} }
} }
+10 -5
View File
@@ -24,11 +24,16 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
return; return;
} }
// Deduplication is performed once on the App side when fresh disk data // Filter duplicates by keeping first occurrence of each unique name
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it let mut seen_names = std::collections::HashSet::new();
// here would rebuild a HashSet ~10x per refresh for no reason). let unique_disks: Vec<_> = mm
.disks
.iter()
.filter(|d| seen_names.insert(d.name.clone()))
.collect();
let per_disk_h = 3u16; let per_disk_h = 3u16;
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize; let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize;
let constraints: Vec<Constraint> = (0..max_cards) let constraints: Vec<Constraint> = (0..max_cards)
.map(|_| Constraint::Length(per_disk_h)) .map(|_| Constraint::Length(per_disk_h))
@@ -39,7 +44,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
.split(inner); .split(inner);
for (i, slot) in rows.iter().enumerate() { for (i, slot) in rows.iter().enumerate() {
let d = &mm.disks[i]; let d = unique_disks[i];
let used = d.total.saturating_sub(d.available); let used = d.total.saturating_sub(d.available);
let ratio = if d.total > 0 { let ratio = if d.total > 0 {
used as f64 / d.total as f64 used as f64 / d.total as f64
-133
View File
@@ -1,133 +0,0 @@
//! Fitting text to the columns actually available.
//!
//! Several panes paint two independent pieces of text onto one row — a left title and a
//! right-aligned readout. Nothing reserves space for the right piece, so on a narrow
//! terminal the right one is simply painted over the tail of the left one and the title
//! is clobbered mid-word. The helpers here let a caller measure in real terminal columns
//! and pick the richest wording that still fits, so the two never overlap.
//!
//! Note that `str::len()` is a byte count and must not be used for this: `⏱` is three
//! bytes wide but one column, and `🔒` is four bytes but two columns.
use unicode_width::UnicodeWidthStr;
/// Terminal columns `s` occupies, saturating at `u16::MAX`.
pub fn cols(s: &str) -> u16 {
UnicodeWidthStr::width(s).min(u16::MAX as usize) as u16
}
/// Shortens `s` to at most `max` columns, marking the cut with `…`.
///
/// Cuts on character boundaries and accounts for wide characters, so the result never
/// exceeds `max` columns and never splits a multi-byte character.
pub fn truncate_cols(s: &str, max: u16) -> String {
if cols(s) <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
// Reserve one column for the ellipsis.
let budget = max.saturating_sub(1);
let mut used = 0u16;
let mut out = String::new();
for ch in s.chars() {
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
if used + w > budget {
break;
}
used += w;
out.push(ch);
}
out.push('…');
out
}
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
/// with at least `gap` columns between them.
///
/// Candidates are ordered most- to least-detailed; the last one is the floor and is
/// returned even if it does not fit, so callers always get something to render.
pub fn pick_pair<'a>(
width: u16,
gap: u16,
candidates: &[(&'a str, &'a str)],
) -> (&'a str, &'a str) {
let fits = |left: &str, right: &str| {
let needed = cols(left)
.saturating_add(cols(right))
.saturating_add(if right.is_empty() { 0 } else { gap });
needed <= width
};
for &(left, right) in candidates {
if fits(left, right) {
return (left, right);
}
}
candidates.last().copied().unwrap_or(("", ""))
}
#[cfg(test)]
mod tests {
use super::*;
/// The bug these helpers exist to prevent: byte length overstates the width of the
/// glyphs socktop puts in its header, which is what pushed the right-hand text into
/// the title in the first place.
#[test]
fn cols_counts_columns_not_bytes() {
assert_eq!(cols("abc"), 3);
// Stopwatch: 3 bytes, 1 column.
assert_eq!("".len(), 3);
assert_eq!(cols(""), 1);
// Lock: 4 bytes, 2 columns.
assert_eq!("🔒".len(), 4);
assert_eq!(cols("🔒"), 2);
assert_eq!(cols("⏱ 500ms metrics | 2000ms procs"), 30);
}
#[test]
fn truncate_respects_the_column_budget() {
assert_eq!(truncate_cols("cachyos-gaming", 20), "cachyos-gaming");
assert_eq!(truncate_cols("cachyos-gaming", 14), "cachyos-gaming");
assert_eq!(truncate_cols("cachyos-gaming", 10), "cachyos-g…");
assert_eq!(cols(&truncate_cols("cachyos-gaming", 10)), 10);
assert_eq!(truncate_cols("cachyos-gaming", 1), "");
assert_eq!(truncate_cols("cachyos-gaming", 0), "");
}
/// Truncation must never land mid-character or overrun the budget on wide glyphs.
#[test]
fn truncate_handles_wide_and_multibyte_characters() {
for max in 0..12u16 {
let out = truncate_cols("🔒🔒🔒 TLS", max);
assert!(cols(&out) <= max, "{out:?} exceeds {max} columns");
assert!(out.chars().all(|c| c != '\u{fffd}'), "{out:?} split a char");
}
// A wide glyph that cannot fit beside the ellipsis is dropped whole.
assert_eq!(truncate_cols("🔒ab", 2), "");
}
#[test]
fn pick_pair_takes_the_richest_that_fits() {
let candidates = [
("full left text", "full right text"),
("left text", "right text"),
("left", "right"),
];
assert_eq!(pick_pair(80, 2, &candidates), candidates[0]);
assert_eq!(pick_pair(24, 2, &candidates), candidates[1]);
assert_eq!(pick_pair(12, 2, &candidates), candidates[2]);
// Below the floor the last candidate is still returned.
assert_eq!(pick_pair(1, 2, &candidates), candidates[2]);
}
/// The gap is what keeps the two pieces from touching; it must not be charged when
/// there is no right-hand piece to separate.
#[test]
fn pick_pair_only_charges_the_gap_when_both_sides_are_present() {
let candidates = [("0123456789", "x"), ("0123456789", "")];
assert_eq!(pick_pair(11, 2, &candidates), candidates[1]);
assert_eq!(pick_pair(13, 2, &candidates), candidates[0]);
}
}
-206
View File
@@ -121,209 +121,3 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
); );
} }
} }
/// One-line GPU strip for compact mode: no device name (it is the first thing to lose
/// value when rows are scarce), just utilisation and VRAM on the single content row
/// between the block borders. Only the first GPU fits; the title says so when there are
/// more.
pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let gpus = m.and_then(|mm| mm.gpus.as_ref());
let count = gpus.map(|g| g.len()).unwrap_or(0);
let title = if count > 1 {
format!("GPU (1/{count})")
} else {
"GPU".to_string()
};
f.render_widget(Block::default().borders(Borders::ALL).title(title), area);
if area.height < 3 || area.width <= 2 {
return;
}
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width - 2,
height: 1,
};
let Some(g) = gpus.and_then(|v| v.first()) else {
f.render_widget(Paragraph::new("No GPUs"), inner);
return;
};
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = if total > 0 {
(used as f64 / total as f64).clamp(0.0, 1.0)
} else {
0.0
};
let util_label = format!("util: {util}%");
let mem_label = format!(
"vram: {}/{} ({}%)",
fmt_bytes(used),
fmt_bytes(total),
(mem_ratio * 100.0).round() as u16
);
// Bars are sized explicitly rather than left to stretch: an idle bar renders as
// empty cells, so a full-width one turns into a long blank run between two labels.
const MIN_GAUGE_W: u16 = 6;
const MAX_GAUGE_W: u16 = 24;
let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side
let gauge_w = inner
.width
.saturating_sub(labels_w)
.min(2 * MAX_GAUGE_W)
.div_euclid(2);
// Too narrow for bars worth drawing: keep the numbers, drop the bars.
if gauge_w < MIN_GAUGE_W {
f.render_widget(
Paragraph::new(Span::raw(format!("{util_label} {mem_label}")))
.style(Style::default().fg(Color::Gray)),
inner,
);
return;
}
// Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous
// on a single line: with an idle bar rendering empty, the next pair's fill ends up
// flush against the previous pair's text and reads as belonging to it.
let mut x = inner.x;
let mut place = |w: u16| {
let r = Rect {
x,
y: inner.y,
width: w,
height: 1,
};
x += w;
r
};
let util_rect = place(util_label.len() as u16 + 2);
let util_bar = place(gauge_w);
let mem_rect = place(mem_label.len() as u16 + 2);
let mem_bar = place(gauge_w);
let label = |text: &str| {
Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray))
};
f.render_widget(label(&util_label), util_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
.ratio(util as f64 / 100.0),
util_bar,
);
f.render_widget(label(&mem_label), mem_rect);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::LightMagenta))
.label(Span::raw(""))
.ratio(mem_ratio),
mem_bar,
);
}
#[cfg(test)]
mod render_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use socktop_connector::{GpuInfo, Metrics};
fn gpu(name: &str) -> GpuInfo {
GpuInfo {
name: Some(name.into()),
vendor: None,
utilization: Some(42.0),
mem_used: Some(4_724_464_025),
mem_total: Some(17_070_817_280),
temp: None,
}
}
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
Metrics {
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 1024,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![],
gpus,
process_count: Some(0),
}
}
fn render(width: u16, m: &Metrics) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
terminal
.draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m)))
.unwrap();
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area().height {
for x in 0..buf.area().width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
/// Compact mode drops the device name — the row is one line and the numbers are
/// what the space is for.
#[test]
fn compact_strip_omits_the_device_name() {
let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")]));
let out = render(80, &m);
assert!(
!out.contains("NVIDIA"),
"name leaked into compact strip:\n{out}"
);
assert!(out.contains("util: 42%"), "{out}");
assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}");
}
/// A second GPU cannot fit on one line, so the title has to say the strip is partial
/// rather than silently showing only the first card.
#[test]
fn multiple_gpus_are_flagged_in_the_title() {
let one = render(80, &metrics(Some(vec![gpu("a")])));
assert!(one.contains("GPU") && !one.contains("1/"), "{one}");
let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")])));
assert!(two.contains("GPU (1/2)"), "{two}");
}
/// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must
/// never drop the numbers.
#[test]
fn narrow_strip_keeps_the_numbers() {
let m = metrics(Some(vec![gpu("a")]));
for width in [20u16, 30, 40, 47, 48, 80, 200] {
let out = render(width, &m);
if width >= 40 {
assert!(out.contains("util: 42%"), "width {width}:\n{out}");
}
// No panic, and the block always closes on the last row.
assert_eq!(out.lines().count(), 3, "width {width}");
}
}
#[test]
fn missing_gpu_payload_does_not_panic() {
assert!(render(80, &metrics(None)).contains("No GPUs"));
assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs"));
}
}
+35 -212
View File
@@ -1,232 +1,55 @@
//! Top header with hostname, connection status and polling intervals. //! Top header with hostname and CPU temperature indicator.
//!
//! The row carries two pieces of text — session identity on the left, polling intervals
//! on the right — and both matter. Rather than let the right one overwrite the left when
//! they no longer both fit, the header drops detail in priority order: the hostname and
//! the intervals are what survive longest, because they are what tells you *which* host
//! you are looking at and how fresh the numbers are.
use crate::ui::fit::{cols, pick_pair, truncate_cols}; use crate::types::Metrics;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
}; };
use std::time::Duration;
/// Columns kept clear between the left and right halves. pub fn draw_header(
const GAP: u16 = 2; f: &mut ratatui::Frame<'_>,
/// Never shorten the hostname below this before dropping the intervals instead. area: Rect,
const HOSTNAME_FLOOR: u16 = 8; m: Option<&Metrics>,
is_tls: bool,
/// Session state the header renders. has_token: bool,
#[derive(Clone, Copy)] metrics_interval: Duration,
pub struct HeaderState<'a> { procs_interval: Duration,
pub hostname: Option<&'a str>, ) {
pub is_tls: bool, let base = if let Some(mm) = m {
pub has_token: bool, format!("socktop — host: {}", mm.hostname)
pub metrics_ms: u128,
pub procs_ms: u128,
}
/// Builds the left and right halves of the header for a row `width` columns wide.
///
/// Detail is dropped in this order as the row narrows: the key hints, then the TLS/token
/// badges, then the `socktop — host:` prefix (leaving the bare hostname), then the
/// `metrics`/`procs` words, and only then is the hostname itself shortened. The two
/// halves are always sized to sit side by side, so neither can paint over the other.
///
/// Callers cache the result and rebuild it only when the state or the width changes.
pub fn build_header(state: HeaderState<'_>, width: u16) -> (String, String) {
let host = state.hostname.unwrap_or("connecting...");
let tls = if state.is_tls {
"🔒 TLS"
} else { } else {
"🔒✗ TLS" "socktop — connecting...".into()
}; };
let badges = if state.has_token { // TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
format!("{tls} | 🔑 token") let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
} else { // Token indicator
tls.to_string() let tok_txt = if has_token { "🔑 token" } else { "" };
}; let mut parts = vec![base, tls_txt.into()];
if !tok_txt.is_empty() {
let named = format!("socktop — host: {host}"); parts.push(tok_txt.into());
let with_badges = format!("{named} | {badges}");
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
let intervals = format!(
"{}ms metrics | {}ms procs",
state.metrics_ms, state.procs_ms
);
let intervals_short = format!("{}ms | {}ms", state.metrics_ms, state.procs_ms);
// Richest first. The bare hostname is reached before the intervals lose their
// labels, and the hostname is only shortened once nothing else is left to give.
let ladder = [
(with_keys.as_str(), intervals.as_str()),
(with_badges.as_str(), intervals.as_str()),
(named.as_str(), intervals.as_str()),
(host, intervals.as_str()),
(host, intervals_short.as_str()),
];
let (left, right) = pick_pair(width, GAP, &ladder);
if cols(left) + cols(right) + GAP <= width {
return (left.to_string(), right.to_string());
} }
parts.push("(a: about, h: help, q: quit)".into());
let title = parts.join(" | ");
// Past the floor of the ladder: shorten the hostname, and give up the intervals only // Render the block with left-aligned title
// if even a stub of a hostname will not fit beside them.
let room = width
.saturating_sub(cols(&intervals_short))
.saturating_sub(GAP);
if room >= HOSTNAME_FLOOR {
return (truncate_cols(host, room), intervals_short);
}
(truncate_cols(host, width), String::new())
}
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
if intervals.is_empty() { // Render polling intervals on the right side
return; let mi = metrics_interval.as_millis();
} let pi = procs_interval.as_millis();
let intervals_width = cols(intervals); let intervals = format!("{mi}ms metrics | {pi}ms procs");
if area.width >= intervals_width { let intervals_width = intervals.len() as u16;
if area.width > intervals_width + 2 {
let right_area = Rect { let right_area = Rect {
x: area.x + area.width - intervals_width, x: area.x + area.width.saturating_sub(intervals_width + 1),
y: area.y, y: area.y,
width: intervals_width, width: intervals_width,
height: 1, height: 1,
}; };
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area); let intervals_line = Line::from(Span::raw(intervals));
} f.render_widget(Paragraph::new(intervals_line), right_area);
}
#[cfg(test)]
mod tests {
use super::*;
fn state(hostname: Option<&str>) -> HeaderState<'_> {
HeaderState {
hostname,
is_tls: false,
has_token: false,
metrics_ms: 500,
procs_ms: 2000,
}
}
/// The defect this replaces: the two halves were painted independently, so below
/// ~105 columns the right half landed on top of the title. Whatever the width, they
/// must now fit side by side.
#[test]
fn halves_never_overlap_at_any_width() {
for width in 0..=200u16 {
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
let used = cols(&left) + cols(&right);
if right.is_empty() {
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
} else {
assert!(
used + GAP <= width,
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
);
}
}
}
/// Hostname and intervals are the two things worth keeping; everything else is
/// context that can go.
#[test]
fn hostname_and_intervals_survive_longest() {
for width in 34..=200u16 {
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
assert!(
left.contains("cachyos-gaming"),
"width {width}: lost the hostname ({left:?})"
);
assert!(
right.contains("500ms") && right.contains("2000ms"),
"width {width}: lost the intervals ({right:?})"
);
}
}
/// The ladder from the design: key hints, then badges, then the prefix, then the
/// interval labels, then the hostname itself.
#[test]
fn detail_is_dropped_in_priority_order() {
let s = state(Some("cachyos-gaming"));
let (left, right) = build_header(s, 120);
assert_eq!(
left,
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
);
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
// Key hints go first.
let (left, _) = build_header(s, 80);
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
// Then the badges.
let (left, _) = build_header(s, 70);
assert_eq!(left, "socktop — host: cachyos-gaming");
// Then the prefix, leaving the bare hostname.
let (left, right) = build_header(s, 50);
assert_eq!(left, "cachyos-gaming");
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
// Then the interval labels.
let (left, right) = build_header(s, 34);
assert_eq!(left, "cachyos-gaming");
assert_eq!(right, "⏱ 500ms | 2000ms");
// Only then is the hostname itself shortened.
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
let (left, right) = build_header(s, 30);
assert_eq!(left, "cachyos-gam…");
assert_eq!(right, "⏱ 500ms | 2000ms");
}
/// A long hostname must not push the intervals off the row.
#[test]
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
for width in 30..=100u16 {
let (left, right) = build_header(state(Some(long)), width);
assert!(!right.is_empty(), "width {width}: intervals were dropped");
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
}
}
/// Widths too small for both: the hostname is the last thing standing.
#[test]
fn hostname_is_the_final_survivor() {
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
assert!(right.is_empty(), "intervals should have been dropped");
assert!(!left.is_empty());
assert!(cols(&left) <= 20);
}
#[test]
fn tls_and_token_badges_appear_when_there_is_room() {
let s = HeaderState {
hostname: Some("host"),
is_tls: true,
has_token: true,
metrics_ms: 500,
procs_ms: 2000,
};
let (left, _) = build_header(s, 200);
assert!(left.contains("🔒 TLS"), "{left}");
assert!(left.contains("🔑 token"), "{left}");
}
#[test]
fn a_missing_hostname_reads_as_connecting() {
let (left, _) = build_header(state(None), 120);
assert!(left.contains("connecting"), "{left}");
} }
} }
-352
View File
@@ -1,352 +0,0 @@
//! Root layout computation, shared by the draw path and the input hit-testing paths.
//!
//! Two modes:
//!
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
//! network graphs next to the process table.
//!
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
//! by side into the space it vacated, the GPU collapses to a single full-width line
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
//! long before the rest of the panes stop being useful.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Which of the two layouts [`compute`] produced.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayoutMode {
Normal,
Compact,
}
impl LayoutMode {
pub fn is_compact(self) -> bool {
matches!(self, LayoutMode::Compact)
}
}
/// Rows the Disks pane needs before it can show one disk card: the card itself is
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
const DISKS_MIN_H: u16 = 5;
/// Header line.
const HEADER_H: u16 = 1;
/// Memory and Swap gauges: 1 content row between borders.
const GAUGE_H: u16 = 3;
/// A network graph at its preferred height.
const NET_H: u16 = 5;
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
// borders) before the network graphs are allowed to shrink, because restoring the CPU
// panes is the entire point of the mode.
const TOP_MIN_H: u16 = 5;
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
#[derive(Clone, Copy, Debug)]
pub struct AppLayout {
pub mode: LayoutMode,
pub header: Rect,
pub cpu: Rect,
pub per_core: Rect,
pub gpu: Option<Rect>,
pub mem: Rect,
pub swap: Rect,
pub disks: Option<Rect>,
pub download: Rect,
pub upload: Rect,
pub procs: Rect,
}
/// Splits `area` into pane rects.
///
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
if force_compact {
return compact(area, has_gpu);
}
let normal = normal(area);
match normal.disks {
Some(d) if d.height >= DISKS_MIN_H => normal,
_ => compact(area, has_gpu),
}
}
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(dir)
.constraints(constraints)
.split(area)
}
/// 66/34 split used by every full-width row in the normal layout.
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
split(
area,
Direction::Horizontal,
&[Constraint::Percentage(66), Constraint::Percentage(34)],
)
}
fn normal(area: Rect) -> AppLayout {
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H), // header
Constraint::Ratio(1, 3), // top row
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
],
);
let top = left_right(rows[1]);
let mem_lr = left_right(rows[2]);
let swap_lr = left_right(rows[3]);
// GPU spans the same vertical space as Memory + Swap.
let gpu = Rect {
x: mem_lr[1].x,
y: mem_lr[1].y,
width: mem_lr[1].width,
height: mem_lr[1].height + swap_lr[1].height,
};
let bottom = split(
rows[4],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Min(4), // disks absorbs the slack
Constraint::Length(NET_H), // download
Constraint::Length(NET_H), // upload
],
);
AppLayout {
mode: LayoutMode::Normal,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: Some(gpu),
mem: mem_lr[0],
swap: swap_lr[0],
disks: Some(left_stack[0]),
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
// Give the top row its floor first, then share any surplus with the bottom so the
// process table keeps growing with the window instead of staying pinned at 13 rows.
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
(top, avail - top)
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
(TOP_MIN_H, avail - TOP_MIN_H)
} else {
// Smaller than both floors: the network graphs are already at their minimum, so
// the top row takes what is left (panes clip below this point).
let bottom = BOTTOM_MIN_H.min(avail);
(avail - bottom, bottom)
};
let rows = split(
area,
Direction::Vertical,
&[
Constraint::Length(HEADER_H),
Constraint::Length(top_h),
Constraint::Length(gpu_h),
Constraint::Length(bottom_h),
],
);
let top = left_right(rows[1]);
let bottom = split(
rows[3],
Direction::Horizontal,
&[Constraint::Percentage(60), Constraint::Percentage(40)],
);
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
let left_stack = split(
bottom[0],
Direction::Vertical,
&[
Constraint::Length(GAUGE_H),
Constraint::Fill(1),
Constraint::Fill(1),
],
);
let gauges = split(
left_stack[0],
Direction::Horizontal,
&[Constraint::Percentage(50), Constraint::Percentage(50)],
);
AppLayout {
mode: LayoutMode::Compact,
header: rows[0],
cpu: top[0],
per_core: top[1],
gpu: has_gpu.then_some(rows[2]),
mem: gauges[0],
swap: gauges[1],
disks: None,
download: left_stack[1],
upload: left_stack[2],
procs: bottom[1],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn area(w: u16, h: u16) -> Rect {
Rect::new(0, 0, w, h)
}
/// The height where the normal layout still fits a full disk card. Below it the CPU
/// panes are the ones that collapse, which is what compact mode exists to prevent.
#[test]
fn tall_window_stays_normal() {
let l = compute(area(120, 40), false, true);
assert_eq!(l.mode, LayoutMode::Normal);
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
}
#[test]
fn short_window_switches_to_compact() {
let l = compute(area(120, 24), false, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
/// The switch happens exactly when Disks can no longer show one card, and never
/// oscillates: every height above the crossover is normal, every height below is
/// compact.
#[test]
fn mode_is_monotonic_in_height() {
let mut first_normal = None;
for h in 10..=60u16 {
let mode = compute(area(120, h), false, true).mode;
match (mode, first_normal) {
(LayoutMode::Normal, None) => first_normal = Some(h),
(LayoutMode::Compact, Some(prev)) => {
panic!("height {h} went back to compact after normal at {prev}")
}
_ => {}
}
}
assert!(first_normal.is_some(), "never reached the normal layout");
}
#[test]
fn force_compact_overrides_a_tall_window() {
let l = compute(area(200, 80), true, true);
assert_eq!(l.mode, LayoutMode::Compact);
assert!(l.disks.is_none());
}
#[test]
fn compact_drops_the_gpu_row_without_a_gpu() {
let with = compute(area(120, 24), true, true);
let without = compute(area(120, 24), true, false);
assert!(with.gpu.is_some());
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
assert!(without.gpu.is_none());
// The rows a GPU-less host saves are shared between the CPU panes and the
// bottom half, and none of them are left as a gap.
assert!(without.cpu.height > with.cpu.height);
assert!(without.procs.height > with.procs.height);
assert_eq!(without.procs.y + without.procs.height, 24);
}
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
/// content rows inside their borders.
#[test]
fn compact_keeps_the_cpu_panes_drawable() {
for h in 18..=32u16 {
let l = compute(area(120, h), false, true);
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
assert!(
l.cpu.height >= TOP_MIN_H,
"height {h}: cpu pane only {} rows",
l.cpu.height
);
assert_eq!(l.per_core.height, l.cpu.height);
}
}
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
/// left the top row with no drawable interior at all.
#[test]
fn compact_beats_the_fixed_layout_at_18_rows() {
let compact = compute(area(120, 18), false, true);
let fixed = normal(area(120, 18));
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
assert!(compact.cpu.height > fixed.cpu.height);
}
#[test]
fn compact_panes_tile_the_area_without_gaps() {
for h in 16..=32u16 {
for has_gpu in [true, false] {
let l = compute(area(120, h), true, has_gpu);
assert_eq!(l.header.y, 0);
assert_eq!(l.cpu.y, l.header.y + l.header.height);
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
let after_cpu = l.cpu.y + l.cpu.height;
let bottom_y = match l.gpu {
Some(g) => {
assert_eq!(g.y, after_cpu);
assert_eq!(g.width, 120, "gpu strip spans the full width");
g.y + g.height
}
None => after_cpu,
};
assert_eq!(l.mem.y, bottom_y);
// Memory and Swap sit side by side on one row.
assert_eq!(l.swap.y, l.mem.y);
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
assert_eq!(l.mem.height, GAUGE_H);
assert_eq!(l.download.y, l.mem.y + l.mem.height);
assert_eq!(l.upload.y, l.download.y + l.download.height);
assert_eq!(l.procs.y, bottom_y);
}
}
}
/// A degenerate size must not panic or produce rects outside the frame.
#[test]
fn tiny_windows_stay_inside_the_frame() {
for h in 0..=16u16 {
for w in [0u16, 1, 20, 80] {
let l = compute(area(w, h), false, true);
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
}
}
}
}
}
-2
View File
@@ -2,10 +2,8 @@
pub mod cpu; pub mod cpu;
pub mod disks; pub mod disks;
pub mod fit;
pub mod gpu; pub mod gpu;
pub mod header; pub mod header;
pub mod layout;
pub mod mem; pub mod mem;
pub mod modal; pub mod modal;
pub mod modal_connection; pub mod modal_connection;
+5 -6
View File
@@ -41,12 +41,11 @@ impl ModalManager {
]) ])
.split(area); .split(area);
let block = Block::default() let block = Block::default()
.title( .title(ICON_WARNING_TITLE)
Line::from(ICON_WARNING_TITLE).style( .title_style(
Style::default() Style::default()
.fg(MODAL_TITLE_FG) .fg(MODAL_TITLE_FG)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
),
) )
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER_FG)) .border_style(Style::default().fg(MODAL_BORDER_FG))
+26 -39
View File
@@ -60,7 +60,6 @@ impl ModalManager {
main_chunks[0], main_chunks[0],
&details.process, &details.process,
data.history.cpu, data.history.cpu,
data.history.cpu_sum,
); );
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata) // Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
@@ -420,7 +419,7 @@ impl ModalManager {
) )
.header(header) .header(header)
.block(block) .block(block)
.row_highlight_style(Style::default()); .highlight_style(Style::default());
f.render_widget(table, area); f.render_widget(table, area);
@@ -565,13 +564,8 @@ impl ModalManager {
return; return;
} }
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation // Create a 2D grid to represent the plot
// instead of `plot_height` inner Vec<char>s like the old version did. let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
let mut plot_grid: Vec<char> = vec![' '; plot_width * plot_height];
let cell = |grid: &[char], x: usize, y: usize| grid[y * plot_width + x];
let put = |grid: &mut [char], x: usize, y: usize, ch: char| {
grid[y * plot_width + x] = ch;
};
// Plot main process // Plot main process
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize; let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
@@ -579,7 +573,7 @@ impl ModalManager {
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize, ((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
); );
if main_x < plot_width && main_y < plot_height { if main_x < plot_width && main_y < plot_height {
put(&mut plot_grid, main_x, main_y, '●'); plot_grid[main_y][main_x] = '●'; // Main process marker
} }
// Plot threads (use different marker) // Plot threads (use different marker)
@@ -593,13 +587,13 @@ impl ModalManager {
); );
if thread_x < plot_width && thread_y < plot_height { if thread_x < plot_width && thread_y < plot_height {
let ch = cell(&plot_grid, thread_x, thread_y); if plot_grid[thread_y][thread_x] == ' ' {
let next = match ch { plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle)
' ' => '○', } else if plot_grid[thread_y][thread_x] == '○' {
'○' => '◎', plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point
_ => '◉', } else {
}; plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point
put(&mut plot_grid, thread_x, thread_y, next); }
} }
} }
@@ -614,34 +608,28 @@ impl ModalManager {
); );
if child_x < plot_width && child_y < plot_height { if child_x < plot_width && child_y < plot_height {
let ch = cell(&plot_grid, child_x, child_y); if plot_grid[child_y][child_x] == ' ' {
let next = if ch == ' ' { '•' } else { '◉' }; plot_grid[child_y][child_x] = '•'; // Child process marker
put(&mut plot_grid, child_x, child_y, next); } else {
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
}
} }
} }
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis // Render the plot
// labels + axis title + (top) Y-axis title + legend + spacing. let mut lines = Vec::new();
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
// Y-axis labels and plot content // Add Y-axis labels and plot content
let mut row_buf = String::with_capacity(plot_width); for (i, row) in plot_grid.iter().enumerate() {
for y in 0..plot_height { let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64)); // Always format with 4 characters width, right-aligned, to prevent axis shifting
// 4-char fixed-width label so the axis doesn't shift as digits change.
let y_label = if y_value >= 100.0 { let y_label = if y_value >= 100.0 {
format!("{y_value:>4.0}") format!("{y_value:>4.0}")
} else { } else {
format!("{y_value:>4.1}") format!("{y_value:>4.1}")
}; };
// Build the row's char slice into a reusable String buffer. let plot_content: String = row.iter().collect();
row_buf.clear();
let start = y * plot_width;
row_buf.extend(plot_grid[start..start + plot_width].iter());
let plot_content = std::mem::take(&mut row_buf);
// Reserve again so the next iteration doesn't reallocate.
row_buf.reserve(plot_width);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(y_label, Style::default()), Span::styled(y_label, Style::default()),
@@ -845,7 +833,6 @@ impl ModalManager {
area: Rect, area: Rect,
process: &socktop_connector::DetailedProcessInfo, process: &socktop_connector::DetailedProcessInfo,
cpu_history: &std::collections::VecDeque<f32>, cpu_history: &std::collections::VecDeque<f32>,
cpu_history_sum: f32,
) { ) {
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%) // Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
let top_chunks = Layout::default() let top_chunks = Layout::default()
@@ -856,7 +843,7 @@ impl ModalManager {
]) ])
.split(area); .split(area);
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum); self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history);
self.render_thread_scatter_plot(f, top_chunks[1], process); self.render_thread_scatter_plot(f, top_chunks[1], process);
} }
@@ -866,7 +853,6 @@ impl ModalManager {
area: Rect, area: Rect,
process: &socktop_connector::DetailedProcessInfo, process: &socktop_connector::DetailedProcessInfo,
cpu_history: &std::collections::VecDeque<f32>, cpu_history: &std::collections::VecDeque<f32>,
cpu_history_sum: f32,
) { ) {
// Normalize CPU to 0-100% by dividing by thread count // Normalize CPU to 0-100% by dividing by thread count
// This shows per-core utilization rather than total utilization across all cores // This shows per-core utilization rather than total utilization across all cores
@@ -878,7 +864,8 @@ impl ModalManager {
let avg_cpu = if cpu_history.is_empty() { let avg_cpu = if cpu_history.is_empty() {
0.0 0.0
} else { } else {
normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count) let total: f32 = cpu_history.iter().sum();
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
}; };
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)"); let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
-2
View File
@@ -5,8 +5,6 @@ use std::time::Instant;
/// History data for process metrics rendering /// History data for process metrics rendering
pub struct ProcessHistoryData<'a> { pub struct ProcessHistoryData<'a> {
pub cpu: &'a std::collections::VecDeque<f32>, pub cpu: &'a std::collections::VecDeque<f32>,
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
pub cpu_sum: f32,
pub mem: &'a std::collections::VecDeque<u64>, pub mem: &'a std::collections::VecDeque<u64>,
pub io_read: &'a std::collections::VecDeque<u64>, pub io_read: &'a std::collections::VecDeque<u64>,
pub io_write: &'a std::collections::VecDeque<u64>, pub io_write: &'a std::collections::VecDeque<u64>,
+3 -3
View File
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
title: &str, title: &str,
hist: &mut VecDeque<u64>, hist: &VecDeque<u64>,
color: Color, color: Color,
) { ) {
let max_points = area.width.saturating_sub(2) as usize; let max_points = area.width.saturating_sub(2) as usize;
let start = hist.len().saturating_sub(max_points); let start = hist.len().saturating_sub(max_points);
let slice = &hist.make_contiguous()[start..]; let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
let spark = Sparkline::default() let spark = Sparkline::default()
.block( .block(
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
.borders(Borders::ALL) .borders(Borders::ALL)
.title(title.to_string()), .title(title.to_string()),
) )
.data(slice) .data(&data)
.style(Style::default().fg(color)); .style(Style::default().fg(color));
f.render_widget(spark, area); f.render_widget(spark, area);
} }
+187 -613
View File
@@ -5,8 +5,8 @@ use ratatui::style::Modifier;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style}, style::{Color, Style},
text::Span, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table}, widgets::{Block, Borders, Paragraph, Table},
}; };
use std::cmp::Ordering; use std::cmp::Ordering;
@@ -16,17 +16,18 @@ use crate::ui::theme::{
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW, PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
SB_THUMB, SB_TRACK, SB_THUMB, SB_TRACK,
}; };
use crate::ui::util::human;
/// Simple fuzzy matching: returns true if all characters in needle appear in /// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
/// haystack in order, ASCII-case-insensitive. Lowercase normalization is done
/// on the fly so we don't allocate two `String`s per haystack like the old
/// version did (this runs once per process per frame).
fn fuzzy_match(haystack: &str, needle: &str) -> bool { fn fuzzy_match(haystack: &str, needle: &str) -> bool {
if needle.is_empty() { if needle.is_empty() {
return true; return true;
} }
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase()); let haystack_lower = haystack.to_lowercase();
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) { let needle_lower = needle.to_lowercase();
let mut haystack_chars = haystack_lower.chars();
for needle_char in needle_lower.chars() {
if !haystack_chars.any(|c| c == needle_char) { if !haystack_chars.any(|c| c == needle_char) {
return false; return false;
} }
@@ -34,37 +35,36 @@ fn fuzzy_match(haystack: &str, needle: &str) -> bool {
true true
} }
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first /// Get filtered and sorted process indices based on search query and sort order
/// and reused across calls so callers can amortize the allocation. This is pub fn get_filtered_sorted_indices(
/// the underlying helper for the App-side cached slice.
pub fn fill_filtered_sorted_indices(
metrics: &Metrics, metrics: &Metrics,
search_query: &str, search_query: &str,
sort_by: ProcSortBy, sort_by: ProcSortBy,
out: &mut Vec<usize>, ) -> Vec<usize> {
) { // Filter processes by search query (fuzzy match)
out.clear(); let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
out.reserve(metrics.top_processes.len()); (0..metrics.top_processes.len()).collect()
if search_query.is_empty() {
out.extend(0..metrics.top_processes.len());
} else { } else {
out.extend( (0..metrics.top_processes.len())
(0..metrics.top_processes.len()) .filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)), .collect()
); };
}
// Sort filtered rows
match sort_by { match sort_by {
ProcSortBy::CpuDesc => out.sort_by(|&a, &b| { ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].cpu_usage; let aa = metrics.top_processes[a].cpu_usage;
let bb = metrics.top_processes[b].cpu_usage; let bb = metrics.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}), }),
ProcSortBy::MemDesc => out.sort_by(|&a, &b| { ProcSortBy::MemDesc => filtered_idxs.sort_by(|&a, &b| {
let aa = metrics.top_processes[a].mem_bytes; let aa = metrics.top_processes[a].mem_bytes;
let bb = metrics.top_processes[b].mem_bytes; let bb = metrics.top_processes[b].mem_bytes;
bb.cmp(&aa) bb.cmp(&aa)
}), }),
} }
filtered_idxs
} }
/// Parameters for drawing the top processes table /// Parameters for drawing the top processes table
@@ -76,16 +76,6 @@ pub struct ProcessDisplayParams<'a> {
pub selected_process_index: Option<usize>, pub selected_process_index: Option<usize>,
pub search_query: &'a str, pub search_query: &'a str,
pub search_active: bool, pub search_active: bool,
/// Precomputed filtered + sorted indices into `metrics.top_processes`.
/// Maintained on the App side so the draw path never recomputes the list.
pub filtered_indices: &'a [usize],
/// Pre-formatted strings for each row of `metrics.top_processes`.
/// Indexed the same as `metrics.top_processes`. Empty when no procs poll
/// has run yet (the draw path falls back to fast inline formatting).
pub cached_rows: &'a [CachedRow],
/// Peak cpu_usage from the most recent cache build; used to bold the
/// busiest process. -1.0 if no cache.
pub peak_cpu: f32,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -95,122 +85,14 @@ pub enum ProcSortBy {
MemDesc, MemDesc,
} }
/// Pre-formatted strings for one row of the process table. Built once per // Keep the original header widths here so drawing and hit-testing match.
/// `Processes` poll (cadence ~2s) and reused by every draw frame in between const COLS: [Constraint; 5] = [
/// so the diff renderer can suppress repaints when nothing changed. Constraint::Length(8), // PID
#[derive(Debug, Clone)] Constraint::Percentage(40), // Name
pub struct CachedRow { Constraint::Length(8), // CPU %
pub pid_str: String, Constraint::Length(12), // Mem
pub cpu_str: String, Constraint::Length(8), // Mem %
pub mem_str: String, ];
pub mem_pct_str: String,
pub mem_pct: f64,
pub cpu_val: f32,
}
/// Build a fresh row cache parallel to `metrics.top_processes`. Reuses `out`'s
/// allocation when possible. Also returns the peak cpu_usage observed, which
/// the draw path uses to bold the busiest process.
pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
out.clear();
out.reserve(metrics.top_processes.len());
let total = metrics.mem_total.max(1);
let mut peak = 0.0_f32;
for p in &metrics.top_processes {
let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0;
let cpu_val = p.cpu_usage;
if cpu_val > peak {
peak = cpu_val;
}
out.push(CachedRow {
pid_str: p.pid.to_string(),
cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)),
mem_str: crate::ui::util::human(p.mem_bytes),
mem_pct_str: format!("{mem_pct:.2}%"),
mem_pct,
cpu_val,
});
}
peak
}
const PID_W: u16 = 8;
const CPU_W: u16 = 8;
const MEM_W: u16 = 12;
const MEM_PCT_W: u16 = 8;
/// Columns the Name field needs to identify anything. Every other column is only added
/// once Name already has this much, so Name can no longer be squeezed to nothing.
const NAME_MIN_W: u16 = 8;
/// `Table::column_spacing`.
const COL_SPACING: u16 = 1;
/// Which process columns fit in the pane, and where they sit.
///
/// The table used to hand the layout solver a fixed, over-constrained set, so on a narrow
/// pane the solver crushed the percentage-sized Name column to nothing while the fixed
/// PID and Mem % columns kept their full width — losing the one field that identifies the
/// process while keeping the ones that do not.
///
/// Columns are now added in priority order as the pane widens, so they are shed in
/// reverse as it narrows: Name is unconditional, then CPU %, then Mem, then PID, and
/// Mem % last (it is derivable from Mem, so it is the least costly to lose).
///
/// Both the draw path and the header-click hit-testing build this from the same width, so
/// a sort click always lands on the column the user can actually see.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcColumns {
pub pid: bool,
pub cpu: bool,
pub mem: bool,
pub mem_pct: bool,
}
impl ProcColumns {
pub fn for_width(width: u16) -> Self {
// Each tier is the previous one plus a column and the gap before it.
let with_cpu = NAME_MIN_W + COL_SPACING + CPU_W;
let with_mem = with_cpu + COL_SPACING + MEM_W;
let with_pid = with_mem + COL_SPACING + PID_W;
let with_mem_pct = with_pid + COL_SPACING + MEM_PCT_W;
Self {
cpu: width >= with_cpu,
mem: width >= with_mem,
pid: width >= with_pid,
mem_pct: width >= with_mem_pct,
}
}
/// Column constraints in render order. Name takes whatever the others leave.
pub fn constraints(&self) -> Vec<Constraint> {
let mut c = Vec::with_capacity(5);
if self.pid {
c.push(Constraint::Length(PID_W));
}
c.push(Constraint::Fill(1)); // Name
if self.cpu {
c.push(Constraint::Length(CPU_W));
}
if self.mem {
c.push(Constraint::Length(MEM_W));
}
if self.mem_pct {
c.push(Constraint::Length(MEM_PCT_W));
}
c
}
/// Position of the CPU % column, which is clickable to sort. `None` when too narrow
/// to render it.
pub fn cpu_index(&self) -> Option<usize> {
self.cpu.then(|| 1 + usize::from(self.pid))
}
/// Position of the Mem column, which is clickable to sort.
pub fn mem_index(&self) -> Option<usize> {
self.mem
.then(|| 1 + usize::from(self.pid) + usize::from(self.cpu))
}
}
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) { pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
// Draw outer block and title // Draw outer block and title
@@ -277,7 +159,8 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
height: inner.height, height: inner.height,
}; };
let idxs = params.filtered_indices; // Get filtered and sorted indices
let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by);
// Scrolling // Scrolling
let total_rows = idxs.len(); let total_rows = idxs.len();
@@ -287,62 +170,19 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
let offset = params.scroll_offset.min(max_off); let offset = params.scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows); let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Use the App-side cache when available so we avoid allocating ~5 strings // Build visible rows
// per row every frame. Falls back to inline formatting (slow path) when
// the cache hasn't been built yet — e.g. the very first frame before the
// initial procs poll completes.
let cache_ok = params.cached_rows.len() == mm.top_processes.len();
let total_mem_bytes = mm.mem_total.max(1); let total_mem_bytes = mm.mem_total.max(1);
let peak_cpu = if cache_ok { let peak_cpu = mm
params.peak_cpu .top_processes
} else { .iter()
mm.top_processes .map(|p| p.cpu_usage)
.iter() .fold(0.0_f32, f32::max);
.map(|p| p.cpu_usage)
.fold(0.0_f32, f32::max)
};
let columns = ProcColumns::for_width(content.width);
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| { let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix]; let p = &mm.top_processes[ix];
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let ( let cpu_val = p.cpu_usage;
cpu_val,
mem_pct,
pid_span,
name_span,
cpu_span_text,
mem_span_text,
mem_pct_span_text,
) = if cache_ok {
let row = &params.cached_rows[ix];
(
row.cpu_val,
row.mem_pct,
Span::raw(row.pid_str.as_str()),
Span::raw(p.name.as_str()),
row.cpu_str.as_str(),
row.mem_str.as_str(),
row.mem_pct_str.as_str(),
)
} else {
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
// SLOW path: only the very first frame before the cache exists.
// We leak the formatted strings via Box::leak'd statics? No —
// simpler: emit empty placeholders. Cache will exist within
// ~500ms and the diff renderer fills it in.
(
p.cpu_usage,
mem_pct,
Span::raw(""),
Span::raw(""),
"",
"",
"",
)
};
let cpu_fg = match cpu_val { let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green, x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow, x if x < 60.0 => Color::Yellow,
@@ -360,14 +200,16 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
Style::default() Style::default()
}; };
// Check if this process is selected - prioritize PID matching
let is_selected = if let Some(selected_pid) = params.selected_process_pid { let is_selected = if let Some(selected_pid) = params.selected_process_pid {
selected_pid == p.pid selected_pid == p.pid
} else if let Some(selected_idx) = params.selected_process_index { } else if let Some(selected_idx) = params.selected_process_index {
selected_idx == ix selected_idx == ix // ix is the absolute index in the sorted list
} else { } else {
false false
}; };
// Apply selection highlighting
if is_selected { if is_selected {
emphasis = emphasis emphasis = emphasis
.bg(PROCESS_SELECTION_BG) .bg(PROCESS_SELECTION_BG)
@@ -375,29 +217,18 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
.add_modifier(Modifier::BOLD); .add_modifier(Modifier::BOLD);
} }
let mut cells = Vec::with_capacity(5); let cpu_str = fmt_cpu_pct(cpu_val);
if columns.pid {
cells.push( ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)), ratatui::widgets::Cell::from(p.pid.to_string())
); .style(Style::default().fg(Color::DarkGray)),
} ratatui::widgets::Cell::from(p.name.clone()),
cells.push(ratatui::widgets::Cell::from(name_span)); ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
if columns.cpu { ratatui::widgets::Cell::from(human(p.mem_bytes)),
cells.push( ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
ratatui::widgets::Cell::from(Span::raw(cpu_span_text)) .style(Style::default().fg(mem_fg)),
.style(Style::default().fg(cpu_fg)), ])
); .style(emphasis)
}
if columns.mem {
cells.push(ratatui::widgets::Cell::from(Span::raw(mem_span_text)));
}
if columns.mem_pct {
cells.push(
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
.style(Style::default().fg(mem_fg)),
);
}
ratatui::widgets::Row::new(cells).style(emphasis)
}); });
// Header with sort indicator // Header with sort indicator
@@ -409,30 +240,16 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
ProcSortBy::MemDesc => "Mem •", ProcSortBy::MemDesc => "Mem •",
_ => "Mem", _ => "Mem",
}; };
let mut header_cells = Vec::with_capacity(5); let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
if columns.pid {
header_cells.push("PID");
}
header_cells.push("Name");
if columns.cpu {
header_cells.push(cpu_hdr);
}
if columns.mem {
header_cells.push(mem_hdr);
}
if columns.mem_pct {
header_cells.push("Mem %");
}
let header = ratatui::widgets::Row::new(header_cells).style(
Style::default() Style::default()
.fg(Color::Cyan) .fg(Color::Cyan)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
); );
// Render table inside content area (no borders here; outer block already drawn) // Render table inside content area (no borders here; outer block already drawn)
let table = Table::new(rows_iter, columns.constraints()) let table = Table::new(rows_iter, COLS.to_vec())
.header(header) .header(header)
.column_spacing(COL_SPACING); .column_spacing(1);
f.render_widget(table, content); f.render_widget(table, content);
// Draw tooltip if a process is selected // Draw tooltip if a process is selected
@@ -476,29 +293,45 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
} }
} }
// Scrollbar (ratatui built-in). Skip drawing when content fits in viewport. // Draw scrollbar like CPU pane
let scroll_area = Rect { let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1), x: inner.x + inner.width.saturating_sub(1),
y: inner.y, y: inner.y,
width: 1, width: 1,
height: inner.height, height: inner.height,
}; };
let max_off_for_bar = total_rows.saturating_sub(viewport_rows); if scroll_area.height >= 3 {
if scroll_area.height >= 3 && max_off_for_bar > 0 { let track = (scroll_area.height - 2) as usize;
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) let total = total_rows.max(1);
.begin_symbol(Some("")) let view = viewport_rows.clamp(1, total);
.end_symbol(Some("")) let max_off = total.saturating_sub(view);
.thumb_symbol("")
.track_symbol(Some("")) let thumb_len = (track * view).div_ceil(total).max(1).min(track);
.thumb_style(Style::default().fg(SB_THUMB)) let thumb_top = if max_off == 0 {
.track_style(Style::default().fg(SB_TRACK)) 0
.begin_style(Style::default().fg(SB_ARROW)) } else {
.end_style(Style::default().fg(SB_ARROW)); ((track - thumb_len) * offset + max_off / 2) / max_off
let mut state = ScrollbarState::new(max_off_for_bar).position(offset); };
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
// 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) /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
/// Parameters for process key event handling /// Parameters for process key event handling
pub struct ProcessKeyParams<'a> { pub struct ProcessKeyParams<'a> {
@@ -506,7 +339,8 @@ pub struct ProcessKeyParams<'a> {
pub selected_process_index: &'a mut Option<usize>, pub selected_process_index: &'a mut Option<usize>,
pub key: crossterm::event::KeyEvent, pub key: crossterm::event::KeyEvent,
pub metrics: Option<&'a Metrics>, pub metrics: Option<&'a Metrics>,
pub filtered_indices: &'a [usize], pub sort_by: ProcSortBy,
pub search_query: &'a str,
} }
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
@@ -522,71 +356,87 @@ pub fn processes_handle_key(
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
let move_selection = |delta: isize,
sel_idx: &mut Option<usize>,
sel_pid: &mut Option<u32>,
metrics: Option<&Metrics>,
idxs: &[usize]| {
let Some(m) = metrics else { return };
if idxs.is_empty() {
*sel_idx = None;
*sel_pid = None;
return;
}
if sel_idx.is_none() || sel_pid.is_none() {
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
return;
}
let current_idx = sel_idx.unwrap();
match idxs.iter().position(|&idx| idx == current_idx) {
Some(pos) => {
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
if new_pos != pos {
let new_idx = idxs[new_pos];
*sel_idx = Some(new_idx);
*sel_pid = Some(m.top_processes[new_idx].pid);
}
}
None => {
// Current selection no longer in filtered list
let first_idx = idxs[0];
*sel_idx = Some(first_idx);
*sel_pid = Some(m.top_processes[first_idx].pid);
}
}
};
match params.key.code { match params.key.code {
KeyCode::Up => { KeyCode::Up => {
move_selection( // Navigate through filtered and sorted results
-1, if let Some(m) = params.metrics {
params.selected_process_index, let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
params.selected_process_pid,
params.metrics, if idxs.is_empty() {
params.filtered_indices, // No filtered results, clear selection
); *params.selected_process_index = None;
true *params.selected_process_pid = None;
} else if params.selected_process_index.is_none()
|| params.selected_process_pid.is_none()
{
// No selection - select the first process in filtered/sorted order
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
} else if let Some(current_idx) = *params.selected_process_index {
// Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
if pos > 0 {
// Move up in filtered/sorted list
let new_idx = idxs[pos - 1];
*params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
}
} else {
// Current selection not in filtered list, select first result
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
}
}
true // Handled
} }
KeyCode::Down => { KeyCode::Down => {
move_selection( // Navigate through filtered and sorted results
1, if let Some(m) = params.metrics {
params.selected_process_index, let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
params.selected_process_pid,
params.metrics, if idxs.is_empty() {
params.filtered_indices, // No filtered results, clear selection
); *params.selected_process_index = None;
true *params.selected_process_pid = None;
} else if params.selected_process_index.is_none()
|| params.selected_process_pid.is_none()
{
// No selection - select the first process in filtered/sorted order
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
} else if let Some(current_idx) = *params.selected_process_index {
// Find current position in filtered/sorted list
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
if pos + 1 < idxs.len() {
// Move down in filtered/sorted list
let new_idx = idxs[pos + 1];
*params.selected_process_index = Some(new_idx);
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
}
} else {
// Current selection not in filtered list, select first result
let first_idx = idxs[0];
*params.selected_process_index = Some(first_idx);
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
}
}
}
true // Handled
} }
KeyCode::Char('x') | KeyCode::Char('X') KeyCode::Char('x') | KeyCode::Char('X') => {
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() => // Unselect any selected process
{ if params.selected_process_pid.is_some() || params.selected_process_index.is_some() {
*params.selected_process_pid = None; *params.selected_process_pid = None;
*params.selected_process_index = None; *params.selected_process_index = None;
true true // Handled
} else {
false // No selection to clear
}
} }
KeyCode::Char('x') | KeyCode::Char('X') => false,
KeyCode::Enter => { KeyCode::Enter => {
// Signal that Enter was pressed with a selection // Signal that Enter was pressed with a selection
params.selected_process_pid.is_some() // Return true if we have a selection to handle params.selected_process_pid.is_some() // Return true if we have a selection to handle
@@ -644,24 +494,15 @@ pub fn processes_handle_mouse(
&& mouse.column < header_area.x + header_area.width; && mouse.column < header_area.x + header_area.width;
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split the header the same way the draw path did, so a click lands on the // Split header into the same columns
// column actually on screen even when PID has been dropped.
let columns = ProcColumns::for_width(header_area.width);
let cols = Layout::default() let cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints(columns.constraints()) .constraints(COLS.to_vec())
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
.split(header_area); .split(header_area);
if let Some(cpu) = columns.cpu_index().map(|i| cols[i]) if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
&& mouse.column >= cpu.x
&& mouse.column < cpu.x + cpu.width
{
return Some(ProcSortBy::CpuDesc); return Some(ProcSortBy::CpuDesc);
} }
if let Some(mem) = columns.mem_index().map(|i| cols[i]) if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
&& mouse.column >= mem.x
&& mouse.column < mem.x + mem.width
{
return Some(ProcSortBy::MemDesc); return Some(ProcSortBy::MemDesc);
} }
} }
@@ -685,11 +526,8 @@ pub struct ProcessMouseParams<'a> {
pub area: Rect, pub area: Rect,
pub total_rows: usize, pub total_rows: usize,
pub metrics: Option<&'a Metrics>, pub metrics: Option<&'a Metrics>,
/// True when the on-screen search box is currently being drawn (active pub sort_by: ProcSortBy,
/// edit mode OR a non-empty filter is showing). The caller computes this pub search_query: &'a str,
/// from the same condition as the draw path.
pub search_box_visible: bool,
pub filtered_indices: &'a [usize],
} }
/// Enhanced mouse handler that also manages process selection /// Enhanced mouse handler that also manages process selection
@@ -707,13 +545,9 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
} }
// Calculate content area - must match draw_top_processes exactly! // Calculate content area - must match draw_top_processes exactly!
// If a search box is being drawn (active edit mode OR a filter showing), // If search is active or query exists, content starts after search box (3 lines)
// content starts 3 rows below. let search_active = !params.search_query.is_empty();
let content_start_y = if params.search_box_visible { let content_start_y = if search_active { inner.y + 3 } else { inner.y };
inner.y + 3
} else {
inner.y
};
let content = Rect { let content = Rect {
x: inner.x, x: inner.x,
@@ -721,7 +555,7 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
width: inner.width.saturating_sub(2), width: inner.width.saturating_sub(2),
height: inner height: inner
.height .height
.saturating_sub(if params.search_box_visible { 3 } else { 0 }), .saturating_sub(if search_active { 3 } else { 0 }),
}; };
// Scrollbar interactions (click arrows/page/drag) // Scrollbar interactions (click arrows/page/drag)
@@ -753,24 +587,15 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
&& params.mouse.column < header_area.x + header_area.width; && params.mouse.column < header_area.x + header_area.width;
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) { if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split the header the same way the draw path did, so a click lands on the // Split header into the same columns
// column actually on screen even when PID has been dropped.
let columns = ProcColumns::for_width(header_area.width);
let cols = Layout::default() let cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints(columns.constraints()) .constraints(COLS.to_vec())
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
.split(header_area); .split(header_area);
if let Some(cpu) = columns.cpu_index().map(|i| cols[i]) if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width {
&& params.mouse.column >= cpu.x
&& params.mouse.column < cpu.x + cpu.width
{
return Some(ProcSortBy::CpuDesc); return Some(ProcSortBy::CpuDesc);
} }
if let Some(mem) = columns.mem_index().map(|i| cols[i]) if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width {
&& params.mouse.column >= mem.x
&& params.mouse.column < mem.x + mem.width
{
return Some(ProcSortBy::MemDesc); return Some(ProcSortBy::MemDesc);
} }
} }
@@ -787,8 +612,12 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
{ {
let clicked_row = (params.mouse.row - data_start_row) as usize; let clicked_row = (params.mouse.row - data_start_row) as usize;
// Find the actual process using the same filtering/sorting logic as the drawing code
if let Some(m) = params.metrics { if let Some(m) = params.metrics {
let idxs = params.filtered_indices; // Use the same filtered and sorted indices as display
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
// Calculate which process was actually clicked based on filtered/sorted order
let visible_process_position = *params.scroll_offset + clicked_row; let visible_process_position = *params.scroll_offset + clicked_row;
if visible_process_position < idxs.len() { if visible_process_position < idxs.len() {
let actual_process_index = idxs[visible_process_position]; let actual_process_index = idxs[visible_process_position];
@@ -807,258 +636,3 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
); );
None None
} }
#[cfg(test)]
mod column_tests {
use super::*;
use ratatui::layout::{Direction, Layout, Rect};
fn name_width(w: u16) -> u16 {
let c = ProcColumns::for_width(w);
let rects = Layout::default()
.direction(Direction::Horizontal)
.constraints(c.constraints())
.spacing(COL_SPACING)
.split(Rect::new(0, 0, w, 1));
rects[usize::from(c.pid)].width
}
/// The complaint this fixes: on a narrow pane the Name column was the first thing to
/// disappear, leaving a table of numbers with nothing to identify the process. Name
/// must now be the last column standing, at every width that can render anything.
#[test]
fn name_is_never_the_column_that_gets_dropped() {
for width in NAME_MIN_W..=200u16 {
assert!(
name_width(width) >= 1,
"width {width}: Name was squeezed to nothing"
);
}
}
/// Columns are shed in reverse priority order, so a narrower pane can never show a
/// column that a wider one hid.
#[test]
fn columns_are_shed_in_priority_order() {
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
assert!(!c.mem_pct || c.pid, "width {width}: Mem % outlived PID");
assert!(!c.pid || c.mem, "width {width}: PID outlived Mem");
assert!(!c.mem || c.cpu, "width {width}: Mem outlived CPU %");
}
}
/// Columns come back as the pane widens and never flap.
#[test]
fn columns_are_monotonic_in_width() {
let mut prev = ProcColumns::for_width(0);
for width in 1..=200u16 {
let c = ProcColumns::for_width(width);
for (was, now, name) in [
(prev.cpu, c.cpu, "CPU %"),
(prev.mem, c.mem, "Mem"),
(prev.pid, c.pid, "PID"),
(prev.mem_pct, c.mem_pct, "Mem %"),
] {
assert!(!was || now, "width {width}: {name} vanished as it widened");
}
prev = c;
}
}
/// The tiers, from a comfortable pane down to a very narrow one.
#[test]
fn narrow_panes_shed_columns_in_order() {
let full = ProcColumns::for_width(48);
assert_eq!(full.constraints().len(), 5);
assert!(full.pid && full.cpu && full.mem && full.mem_pct);
// Mem % goes first.
let c = ProcColumns::for_width(45);
assert!(c.pid && c.mem && !c.mem_pct);
// Then PID.
let c = ProcColumns::for_width(35);
assert!(!c.pid && c.cpu && c.mem);
// Then Mem, leaving the name and its CPU load.
let c = ProcColumns::for_width(20);
assert!(!c.mem && c.cpu);
assert_eq!(c.constraints().len(), 2);
// At the floor, just the name.
let c = ProcColumns::for_width(10);
assert!(!c.cpu && !c.mem);
assert_eq!(c.constraints().len(), 1);
}
/// Regression guard for the old behaviour: a 130-column terminal gives the process
/// pane ~48 columns, and every column still fits there.
#[test]
fn a_wide_terminal_keeps_the_full_table() {
assert_eq!(ProcColumns::for_width(48).constraints().len(), 5);
}
/// Sort clicks are resolved by index, so those indices must track the columns that
/// are actually rendered — otherwise clicking "CPU %" would sort by Mem.
#[test]
fn sort_indices_follow_the_rendered_columns() {
let wide = ProcColumns::for_width(48);
assert_eq!(wide.cpu_index(), Some(2)); // PID, Name, CPU %
assert_eq!(wide.mem_index(), Some(3));
let narrow = ProcColumns::for_width(35);
assert_eq!(narrow.cpu_index(), Some(1)); // Name, CPU %
assert_eq!(narrow.mem_index(), Some(2));
// A column that is not rendered has no index to click.
let tiny = ProcColumns::for_width(10);
assert_eq!(tiny.cpu_index(), None);
assert_eq!(tiny.mem_index(), None);
// Whatever the width, any index returned is inside the rendered set.
for width in 0..=200u16 {
let c = ProcColumns::for_width(width);
let n = c.constraints().len();
for i in [c.cpu_index(), c.mem_index()].into_iter().flatten() {
assert!(i < n, "width {width}: index {i} outside {n} columns");
}
}
}
/// Name takes the slack, so it grows with the pane instead of being pinned to a
/// percentage that the fixed columns can crush.
#[test]
fn name_absorbs_the_leftover_width() {
assert!(
name_width(80) > name_width(60),
"Name did not grow with the pane"
);
}
}
#[cfg(test)]
mod click_tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use socktop_connector::{Metrics, ProcessInfo};
fn metrics() -> Metrics {
Metrics {
cpu_total: 0.0,
cpu_per_core: vec![],
mem_total: 32_000_000_000,
mem_used: 0,
swap_total: 0,
swap_used: 0,
hostname: "t".into(),
cpu_temp_c: None,
disks: vec![],
networks: vec![],
top_processes: vec![ProcessInfo {
pid: 4242,
name: "some-process".into(),
cpu_usage: 1.5,
mem_bytes: 1_000_000,
}],
gpus: None,
process_count: Some(1),
}
}
/// Renders the pane and returns its header row as text.
fn header_row(width: u16) -> String {
let m = metrics();
let mut cache = Vec::new();
let peak = rebuild_row_cache(&m, &mut cache);
let idxs = [0usize];
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
terminal
.draw(|f| {
draw_top_processes(
f,
Rect::new(0, 0, width, 8),
ProcessDisplayParams {
metrics: Some(&m),
scroll_offset: 0,
sort_by: ProcSortBy::CpuDesc,
selected_process_pid: None,
selected_process_index: None,
search_query: "",
search_active: false,
filtered_indices: &idxs,
cached_rows: &cache,
peak_cpu: peak,
},
)
})
.unwrap();
let buf = terminal.backend().buffer();
(0..width)
.map(|x| buf[(x, 1)].symbol().to_string())
.collect()
}
fn click(width: u16, column: u16) -> Option<ProcSortBy> {
let mut scroll = 0usize;
let mut drag = None;
processes_handle_mouse(
&mut scroll,
&mut drag,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row: 1,
modifiers: KeyModifiers::NONE,
},
Rect::new(0, 0, width, 8),
1,
)
}
/// The hit-test rects are computed by a separate `Layout` call from the one `Table`
/// renders with. This walks the rendered header text and clicks each label where it
/// actually appears, which catches any drift between the two — including column
/// spacing, which the two APIs configure differently.
#[test]
fn clicking_a_rendered_sort_header_sorts_by_that_column() {
for width in [40u16, 50, 60, 80, 120] {
let row = header_row(width);
let cpu_at = row.find("CPU").map(|i| row[..i].chars().count() as u16);
let mem_at = row.find("Mem").map(|i| row[..i].chars().count() as u16);
if let Some(x) = cpu_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::CpuDesc),
"width {width}: clicking the rendered 'CPU %' header at column {x} \
did not sort by CPU (header row: {row:?})"
);
}
if let Some(x) = mem_at {
assert_eq!(
click(width, x),
Some(ProcSortBy::MemDesc),
"width {width}: clicking the rendered 'Mem' header at column {x} \
did not sort by Mem (header row: {row:?})"
);
}
}
}
/// Name is what identifies the row, so it must be rendered at every width the pane
/// can draw anything at.
#[test]
fn the_name_column_is_rendered_even_when_narrow() {
for width in [30u16, 40, 60, 120] {
let row = header_row(width);
assert!(
row.contains("Name"),
"width {width}: no Name column in header {row:?}"
);
}
}
}
-33
View File
@@ -73,36 +73,3 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
); );
assert!(text3.contains("Usage:")); assert!(text3.contains("Usage:"));
} }
#[test]
fn test_compact_flag_documented_and_accepted() {
let exe = env!("CARGO_BIN_EXE_socktop");
let out = Command::new(exe)
.args(["--compact", "--help"])
.output()
.expect("run socktop --compact --help");
assert!(
out.status.success(),
"socktop --compact --help did not succeed"
);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains("--compact"),
"help text missing --compact\n{text}"
);
// The flag must not be mistaken for the positional URL argument.
let out2 = Command::new(exe)
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
.output()
.expect("run socktop --compact --dry-run");
assert!(
out2.status.success(),
"socktop --compact with a URL was rejected: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
+95
View File
@@ -0,0 +1,95 @@
//! Caching for process metrics and journal entries
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use crate::types::{ProcessMetricsResponse, JournalResponse};
#[derive(Debug, Clone)]
struct CacheEntry<T> {
data: T,
cached_at: Instant,
ttl: Duration,
}
impl<T> CacheEntry<T> {
fn is_expired(&self) -> bool {
self.cached_at.elapsed() > self.ttl
}
}
#[derive(Debug)]
pub struct ProcessCache {
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
}
impl ProcessCache {
pub fn new() -> Self {
Self {
process_metrics: RwLock::new(HashMap::new()),
journal_entries: RwLock::new(HashMap::new()),
}
}
/// Get cached process metrics if available and not expired (250ms TTL)
pub async fn get_process_metrics(&self, pid: u32) -> Option<ProcessMetricsResponse> {
let cache = self.process_metrics.read().await;
if let Some(entry) = cache.get(&pid) {
if !entry.is_expired() {
return Some(entry.data.clone());
}
}
None
}
/// Cache process metrics with 250ms TTL
pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) {
let mut cache = self.process_metrics.write().await;
cache.insert(pid, CacheEntry {
data,
cached_at: Instant::now(),
ttl: Duration::from_millis(250),
});
}
/// Get cached journal entries if available and not expired (1s TTL)
pub async fn get_journal_entries(&self, pid: u32) -> Option<JournalResponse> {
let cache = self.journal_entries.read().await;
if let Some(entry) = cache.get(&pid) {
if !entry.is_expired() {
return Some(entry.data.clone());
}
}
None
}
/// Cache journal entries with 1s TTL
pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) {
let mut cache = self.journal_entries.write().await;
cache.insert(pid, CacheEntry {
data,
cached_at: Instant::now(),
ttl: Duration::from_secs(1),
});
}
/// Clean up expired entries periodically
pub async fn cleanup_expired(&self) {
{
let mut cache = self.process_metrics.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
{
let mut cache = self.journal_entries.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
}
}
impl Default for ProcessCache {
fn default() -> Self {
Self::new()
}
}
+234 -338
View File
@@ -23,40 +23,45 @@ use tracing::warn;
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go. // Helper functions to get CPU time from /proc/stat on Linux
// Returns (0, 0) if the file can't be read.
//
// We use `rfind(')')` to step past the `comm` field, which can contain
// arbitrary characters (including spaces and parens), then index the
// post-comm fields by position. This is the same trick `read_proc_jiffies`
// uses below — `split_whitespace().collect::<Vec<_>>()` from the start of
// the file would mis-parse process names with spaces, and also wastes an
// allocation per call. Two callers used to read this file twice (once for
// user, once for system); now it's one syscall per detailed-process record.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn get_cpu_times_ms(pid: u32) -> (u64, u64) { fn get_cpu_time_user(pid: u32) -> u64 {
let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else { if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
return (0, 0); let fields: Vec<&str> = stat.split_whitespace().collect();
}; if fields.len() > 13 {
let Some(rpar) = s.rfind(')') else { // Field 13 (0-indexed) is utime (user CPU time in clock ticks)
return (0, 0); if let Ok(utime) = fields[13].parse::<u64>() {
}; // Convert clock ticks to milliseconds (assuming 100 Hz)
let Some(after) = s.get(rpar + 2..) else { return utime * 10; // 1 tick = 10ms at 100 Hz
return (0, 0); }
}; }
let mut it = after.split_whitespace(); }
// Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid, 0
// flags, minflt, cminflt, majflt, cmajflt, utime, stime, ... }
// utime is offset 11; stime follows.
let utime = it.nth(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0); #[cfg(target_os = "linux")]
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0); fn get_cpu_time_system(pid: u32) -> u64 {
// 1 tick = 10ms at 100 Hz (USER_HZ). if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
(utime * 10, stime * 10) let fields: Vec<&str> = stat.split_whitespace().collect();
if fields.len() > 14 {
// Field 14 (0-indexed) is stime (system CPU time in clock ticks)
if let Ok(stime) = fields[14].parse::<u64>() {
// Convert clock ticks to milliseconds (assuming 100 Hz)
return stime * 10; // 1 tick = 10ms at 100 Hz
}
}
}
0
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) { fn get_cpu_time_user(_pid: u32) -> u64 {
(0, 0) 0 // Not implemented for non-Linux platforms
}
#[cfg(not(target_os = "linux"))]
fn get_cpu_time_system(_pid: u32) -> u64 {
0 // Not implemented for non-Linux platforms
} }
// Runtime toggles (read once) // Runtime toggles (read once)
fn gpu_enabled() -> bool { fn gpu_enabled() -> bool {
@@ -76,47 +81,6 @@ fn temp_enabled() -> bool {
}) })
} }
// TTL knobs read once at first use, then cached. These hit the hot polling
// paths (every 250ms-1.5s), so re-reading via libc getenv per call is wasted.
fn metrics_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(250)
})
}
fn disks_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_000)
})
}
#[cfg(target_os = "linux")]
fn processes_ttl_ms() -> u64 {
static V: OnceCell<u64> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1_500)
})
}
#[cfg(not(target_os = "linux"))]
fn name_cache_cleanup_threshold() -> usize {
static V: OnceCell<usize> = OnceCell::new();
*V.get_or_init(|| {
std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
})
}
// Tiny TTL caches to avoid rescanning sensors every 500ms // Tiny TTL caches to avoid rescanning sensors every 500ms
const TTL: Duration = Duration::from_millis(1500); const TTL: Duration = Duration::from_millis(1500);
struct TempCache { struct TempCache {
@@ -125,32 +89,6 @@ struct TempCache {
} }
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new(); static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
// Last time `state.components` was refreshed (by any caller). Both
// collect_fast_metrics and collect_disks need fresh sensor values; without
// this gate they were each doing their own `Components::refresh` on their
// own cadence, paying the hwmon syscall cost twice per polling cycle.
// 1s is short enough that disk temps stay accurate (they change slowly) and
// long enough to suppress back-to-back refreshes from concurrent endpoints.
const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000);
static COMPONENTS_LAST_REFRESH: OnceCell<Mutex<Option<Instant>>> = OnceCell::new();
/// Refresh `state.components` only if the cached refresh timestamp is older
/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components
/// lock.
fn refresh_components_if_stale(components: &mut sysinfo::Components) {
let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None));
let mut last = match lock.lock() {
Ok(g) => g,
Err(_) => return, // Poisoned — skip; values stay as-is until next call
};
let now = Instant::now();
let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL);
if stale {
components.refresh(false);
*last = Some(now);
}
}
struct GpuCache { struct GpuCache {
at: Option<Instant>, at: Option<Instant>,
v: Option<Vec<crate::gpu::GpuMetrics>>, v: Option<Vec<crate::gpu::GpuMetrics>>,
@@ -216,7 +154,12 @@ fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus). // Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
pub async fn collect_fast_metrics(state: &AppState) -> Metrics { pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
let ttl = StdDuration::from_millis(metrics_ttl_ms()); // 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; let cache = state.cache_metrics.lock().await;
if cache.is_fresh(ttl) if cache.is_fresh(ttl)
@@ -259,7 +202,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
} else if temp_enabled() { } else if temp_enabled() {
let val = { let val = {
let mut components = state.components.lock().await; let mut components = state.components.lock().await;
refresh_components_if_stale(&mut components); components.refresh(false);
components.iter().find_map(|c| { components.iter().find_map(|c| {
let l = c.label().to_ascii_lowercase(); let l = c.label().to_ascii_lowercase();
if l.contains("cpu") if l.contains("cpu")
@@ -293,19 +236,12 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
}); });
let mut cache = cache.lock().unwrap(); let mut cache = cache.lock().unwrap();
// Detect a topology change without allocating: compare lengths first, // Collect current network names
// then zip and walk. Only on a real diff do we materialize the new let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
// names list. Was: `nets.keys().map(to_string).collect::<Vec<_>>()`
// every tick — a fresh Vec<String> just to compare. // Update cached network names if they changed
let topology_changed = cache.names.len() != nets.keys().count() if cache.names != current_names {
|| cache cache.names = current_names;
.names
.iter()
.zip(nets.keys())
.any(|(cached, current)| cached.as_str() != current.as_str());
if topology_changed {
cache.names.clear();
cache.names.extend(nets.keys().map(|n| n.to_string()));
} }
// Reuse NetworkInfo objects // Reuse NetworkInfo objects
@@ -383,7 +319,11 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
// Cached disks // Cached disks
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> { pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
let ttl = StdDuration::from_millis(disks_ttl_ms()); 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; let cache = state.cache_disks.lock().await;
if cache.is_fresh(ttl) if cache.is_fresh(ttl)
@@ -399,9 +339,7 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
// NVMe temps show up as "Composite" under different chip names // NVMe temps show up as "Composite" under different chip names
let disk_temps = { let disk_temps = {
let mut components = state.components.lock().await; let mut components = state.components.lock().await;
// Shared TTL-gated refresh: avoids paying the hwmon scan twice when components.refresh(true); // true = refresh values, not just the list
// both endpoints converge in the same second.
refresh_components_if_stale(&mut components);
let mut composite_temps = Vec::new(); let mut composite_temps = Vec::new();
@@ -634,7 +572,12 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client. /// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let ttl = StdDuration::from_millis(processes_ttl_ms()); 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; let cache = state.cache_processes.lock().await;
if cache.is_fresh(ttl) if cache.is_fresh(ttl)
@@ -643,24 +586,13 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
return c.clone(); return c.clone();
} }
} }
// Reuse shared System to avoid reallocation. We only need name + memory // Reuse shared System to avoid reallocation; refresh processes fully.
// from sysinfo here — per-process CPU% is computed below from /proc/{pid}/stat
// jiffies (see `read_proc_jiffies` + `read_total_jiffies`), so asking sysinfo
// to gather CPU/exe/cmd/cwd/env per process is wasted /proc traffic on a Pi
// (was reading /proc/{pid}/{cmdline,exe,cwd,environ,io,status} for every PID
// on every 2 s poll via `everything()`).
//
// `without_tasks()` is REQUIRED: it suppresses per-thread entries in the
// process map (without it, sysinfo returns one entry per /proc/[tid] —
// 780+ entries on a typical desktop because of glib/gdbus/Chrome thread
// pools). The original code paired this with `everything()`; we keep the
// filter when downgrading to a minimal refresh spec.
let mut sys_guard = state.sys.lock().await; let mut sys_guard = state.sys.lock().await;
let sys = &mut *sys_guard; let sys = &mut *sys_guard;
sys.refresh_processes_specifics( sys.refresh_processes_specifics(
ProcessesToUpdate::All, ProcessesToUpdate::All,
false, false,
ProcessRefreshKind::nothing().with_memory().without_tasks(), ProcessRefreshKind::everything().without_tasks(),
); );
let total_count = sys.processes().len(); let total_count = sys.processes().len();
@@ -675,50 +607,36 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
} }
let total_now = read_total_jiffies().unwrap_or(0); let total_now = read_total_jiffies().unwrap_or(0);
// Compute deltas vs last sample. We hold the proc_cpu lock for the whole // Compute deltas vs last sample
// collection below so we can read+update the per-pid name cache in one let (last_total, mut last_map) = {
// critical section. #[cfg(target_os = "linux")]
let mut tracker = state.proc_cpu.lock().await; {
let last_total = tracker.last_total; let mut t = state.proc_cpu.lock().await;
// Move the old per-pid jiffies map out for delta computation. let lt = t.last_total;
let mut last_map = std::mem::take(&mut tracker.last_per_pid); let lm = std::mem::take(&mut t.last_per_pid);
tracker.last_total = total_now; 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())
}
};
// Resolve a name through the per-pid cache. Allocates only on miss. // On first run or if total delta is tiny, report zeros
let resolve_name =
|tracker: &mut crate::state::ProcCpuTracker, pid: u32, p: &sysinfo::Process| -> String {
if let Some(cached) = tracker.names.get(&pid) {
return cached.clone();
}
let new_name = p.name().to_string_lossy().into_owned();
tracker.names.insert(pid, new_name.clone());
new_name
};
// On first run or if total delta is tiny, report zeros.
if last_total == 0 || total_now <= last_total { if last_total == 0 || total_now <= last_total {
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count); let procs: Vec<ProcessInfo> = sys
for p in sys.processes().values() { .processes()
let pid = p.pid().as_u32(); .values()
let name = resolve_name(&mut tracker, pid, p); .map(|p| ProcessInfo {
procs.push(ProcessInfo { pid: p.pid().as_u32(),
pid, name: p.name().to_string_lossy().into_owned(),
name,
cpu_usage: 0.0, cpu_usage: 0.0,
mem_bytes: p.memory(), mem_bytes: p.memory(),
}); })
} .collect();
// Stash the just-collected jiffies for next call's delta, then prune
// dead pids from the name cache. Borrowing dance: retain reads
// `tracker.last_per_pid` through the closure, which conflicts with
// the mutable borrow of `tracker.names.retain`. Split via split-borrow:
tracker.last_per_pid = current;
let crate::state::ProcCpuTracker {
ref last_per_pid,
ref mut names,
..
} = *tracker;
names.retain(|pid, _| last_per_pid.contains_key(pid));
return ProcessesPayload { return ProcessesPayload {
process_count: total_count, process_count: total_count,
top_processes: procs, top_processes: procs,
@@ -727,31 +645,23 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
let dt = total_now.saturating_sub(last_total).max(1) as f32; let dt = total_now.saturating_sub(last_total).max(1) as f32;
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count); let procs: Vec<ProcessInfo> = sys
for p in sys.processes().values() { .processes()
let pid = p.pid().as_u32(); .values()
let now = current.get(&pid).copied().unwrap_or(0); .map(|p| {
let prev = last_map.remove(&pid).unwrap_or(0); let pid = p.pid().as_u32();
let du = now.saturating_sub(prev) as f32; let now = current.get(&pid).copied().unwrap_or(0);
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0); let prev = last_map.remove(&pid).unwrap_or(0);
let name = resolve_name(&mut tracker, pid, p); let du = now.saturating_sub(prev) as f32;
procs.push(ProcessInfo { let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
pid, ProcessInfo {
name, pid,
cpu_usage: cpu, name: p.name().to_string_lossy().into_owned(),
mem_bytes: p.memory(), cpu_usage: cpu,
}); mem_bytes: p.memory(),
} }
// Save current jiffies map for next call and prune dead pids from the })
// name cache. `current` is moved here (no clone — that's also #19). .collect();
tracker.last_per_pid = current;
let crate::state::ProcCpuTracker {
ref last_per_pid,
ref mut names,
..
} = *tracker;
names.retain(|pid, _| last_per_pid.contains_key(pid));
drop(tracker);
let payload = ProcessesPayload { let payload = ProcessesPayload {
process_count: total_count, process_count: total_count,
@@ -839,17 +749,13 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
// .unwrap_or(std::cmp::Ordering::Equal) // .unwrap_or(std::cmp::Ordering::Equal)
// }); // });
// Clean up old process names cache when it grows too large. // Clean up old process names cache when it grows too large
let cache_cleanup_threshold = name_cache_cleanup_threshold(); 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 { if total_count > proc_cache.names.len() + cache_cleanup_threshold {
// `now` is only consumed by the `tracing::debug!` below, so gate
// the binding with the same cfg as its consumer. Without this,
// a non-logging build (the default) emits an unused-variable
// warning. The Linux CI doesn't catch it because this block lives
// in the `#[cfg(not(target_os = "linux"))]` collect_processes_all —
// the warning only surfaces on the Windows build matrix.
#[cfg(feature = "logging")]
let now = std::time::Instant::now(); let now = std::time::Instant::now();
proc_cache proc_cache
.names .names
@@ -905,94 +811,17 @@ fn enumerate_child_processes_lightweight(
children children
} }
/// Single-read extraction of the /proc/{pid}/status fields the detail
/// endpoint cares about. Callers used to open this file twice per
/// detail-process record (once for VmRSS/VmSize, once for Uid/Gid/Threads/
/// State); now it's one read + one scan.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct ProcStatus {
rss_kb: u64,
vsize_kb: u64,
uid: u32,
gid: u32,
threads: u32,
/// Raw status letter from `State:` (e.g. 'R', 'S'). '?' if missing.
state_ch: char,
}
#[cfg(target_os = "linux")]
fn read_proc_status(pid: u32) -> Option<ProcStatus> {
let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut out = ProcStatus {
state_ch: '?',
..Default::default()
};
for line in content.lines() {
if let Some(v) = line.strip_prefix("VmRSS:") {
out.rss_kb = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("VmSize:") {
out.vsize_kb = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Uid:") {
out.uid = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Gid:") {
out.gid = v
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(v) = line.strip_prefix("Threads:") {
out.threads = v.trim().parse().unwrap_or(0);
} else if let Some(v) = line.strip_prefix("State:") {
out.state_ch = v.trim().chars().next().unwrap_or('?');
}
}
Some(out)
}
#[cfg(target_os = "linux")]
fn proc_state_label(c: char) -> &'static str {
match c {
'R' => "Running",
'S' => "Sleeping",
'D' => "Disk Sleep",
'Z' => "Zombie",
'T' => "Stopped",
't' => "Tracing Stop",
'X' | 'x' => "Dead",
'K' => "Wakekill",
'W' => "Waking",
'P' => "Parked",
'I' => "Idle",
_ => "Unknown",
}
}
/// Read parent PID from /proc/{pid}/stat /// Read parent PID from /proc/{pid}/stat
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> { fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// Format: pid (comm) state ppid ... — comm can contain spaces/parens, // Format: pid (comm) state ppid ...
// so we step past the closing paren first. // We need to handle process names with spaces/parentheses
let ppid_start = stat.rfind(')')?; let ppid_start = stat.rfind(')')?;
// After ") ": state, ppid, ... — ppid is the second field. let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect();
stat[ppid_start + 1..] // After the closing paren: state ppid ...
.split_whitespace() // Field 1 (0-indexed) is ppid
.nth(1)? fields.get(1)?.parse::<u32>().ok()
.parse::<u32>()
.ok()
} }
/// Collect process information from /proc files /// Collect process information from /proc files
@@ -1001,11 +830,8 @@ fn collect_process_info_from_proc(
pid: u32, pid: u32,
system: &sysinfo::System, system: &sysinfo::System,
) -> Option<DetailedProcessInfo> { ) -> Option<DetailedProcessInfo> {
// One read of /proc/{pid}/status gets us everything the detail endpoint // Try to get basic info from sysinfo if it's already loaded (cheap lookup)
// needs from it: memory (when not in sysinfo cache), Uid/Gid, Threads, // Otherwise read from /proc directly
// and State. The previous code opened this file twice per process record.
let st = read_proc_status(pid)?;
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) = let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) { if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
( (
@@ -1015,13 +841,30 @@ fn collect_process_info_from_proc(
proc.virtual_memory(), proc.virtual_memory(),
) )
} else { } else {
// Process not in sysinfo cache — derive name from /proc/{pid}/comm // Process not in sysinfo cache, read minimal info from /proc
// and memory from the status read above.
let name = fs::read_to_string(format!("/proc/{pid}/comm")) let name = fs::read_to_string(format!("/proc/{pid}/comm"))
.ok()? .ok()?
.trim() .trim()
.to_string(); .to_string();
(name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024)
// Read memory from /proc/{pid}/status
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut mem_bytes = 0u64;
let mut virtual_mem_bytes = 0u64;
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("VmRSS:") {
if let Some(kb) = value.split_whitespace().next() {
mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
}
} else if let Some(value) = line.strip_prefix("VmSize:")
&& let Some(kb) = value.split_whitespace().next()
{
virtual_mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
}
}
(name, 0.0, mem_bytes, virtual_mem_bytes)
}; };
// Read command line // Read command line
@@ -1030,21 +873,54 @@ fn collect_process_info_from_proc(
.map(|s| s.replace('\0', " ").trim().to_string()) .map(|s| s.replace('\0', " ").trim().to_string())
.unwrap_or_default(); .unwrap_or_default();
let uid = st.uid; // Read status information
let gid = st.gid; let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let thread_count = st.threads; let mut uid = 0u32;
let status = proc_state_label(st.state_ch).to_string(); let mut gid = 0u32;
let mut thread_count = 0u32;
let mut status = "Unknown".to_string();
// Read start time from stat — comm-safe via rfind(')'). for line in status_content.lines() {
if let Some(value) = line.strip_prefix("Uid:") {
if let Some(uid_str) = value.split_whitespace().next() {
uid = uid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Gid:") {
if let Some(gid_str) = value.split_whitespace().next() {
gid = gid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Threads:") {
thread_count = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("State:") {
status = value
.trim()
.chars()
.next()
.map(|c| match c {
'R' => "Running",
'S' => "Sleeping",
'D' => "Disk Sleep",
'Z' => "Zombie",
'T' => "Stopped",
't' => "Tracing Stop",
'X' | 'x' => "Dead",
'K' => "Wakekill",
'W' => "Waking",
'P' => "Parked",
'I' => "Idle",
_ => "Unknown",
})
.unwrap_or("Unknown")
.to_string();
}
}
// Read start time from stat
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let stat_end = stat.rfind(')')?; let stat_end = stat.rfind(')')?;
// After ") ": state, ppid, ..., starttime — starttime is the 20th let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect();
// post-comm field (index 19). // Field 19 (0-indexed) is starttime in clock ticks since boot
stat[stat_end + 1..] fields.get(19)?.parse::<u64>().ok()?
.split_whitespace()
.nth(19)?
.parse::<u64>()
.ok()?
} else { } else {
0 0
}; };
@@ -1078,9 +954,6 @@ fn collect_process_info_from_proc(
.ok() .ok()
.map(|p| p.to_string_lossy().to_string()); .map(|p| p.to_string_lossy().to_string());
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
Some(DetailedProcessInfo { Some(DetailedProcessInfo {
pid, pid,
name, name,
@@ -1096,8 +969,8 @@ fn collect_process_info_from_proc(
user_id: uid, user_id: uid,
group_id: gid, group_id: gid,
start_time, start_time,
cpu_time_user, cpu_time_user: get_cpu_time_user(pid),
cpu_time_system, cpu_time_system: get_cpu_time_system(pid),
read_bytes, read_bytes,
write_bytes, write_bytes,
working_directory, working_directory,
@@ -1186,24 +1059,22 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.trim() .trim()
.to_string(); .to_string();
// Read thread stat for CPU times and status. // Read thread stat for CPU times and status
let stat_path = format!("/proc/{pid}/task/{tid}/stat"); let stat_path = format!("/proc/{pid}/task/{tid}/stat");
let Ok(stat_content) = fs::read_to_string(&stat_path) else { let Ok(stat_content) = fs::read_to_string(&stat_path) else {
continue; continue;
}; };
// Thread/comm names can contain spaces or parens, so step past the // Parse stat file (similar format to process stat)
// last ')' before parsing post-comm fields. Post-comm offsets: // Fields: pid comm state ... utime stime ...
// 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime let fields: Vec<&str> = stat_content.split_whitespace().collect();
let Some(rpar) = stat_content.rfind(')') else { if fields.len() < 15 {
continue; continue;
}; }
let Some(after) = stat_content.get(rpar + 1..) else {
continue; // Field 2 is state (R, S, D, Z, T, etc.)
}; let status = fields
let mut it = after.split_whitespace(); .get(2)
let status = it
.next()
.and_then(|s| s.chars().next()) .and_then(|s| s.chars().next())
.map(|c| match c { .map(|c| match c {
'R' => "Running", 'R' => "Running",
@@ -1218,9 +1089,16 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
// 10 fields between state and utime (ppid..cmajflt). // Field 13 is utime (user CPU time in clock ticks)
let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0); // Field 14 is stime (system CPU time in clock ticks)
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0); let utime = fields
.get(13)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let stime = fields
.get(14)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
// Convert clock ticks to microseconds (assuming 100 Hz) // Convert clock ticks to microseconds (assuming 100 Hz)
// 1 tick = 10ms = 10,000 microseconds // 1 tick = 10ms = 10,000 microseconds
@@ -1289,13 +1167,34 @@ pub async fn collect_process_metrics(
let parent_pid = process.parent().map(|p| p.as_u32()); let parent_pid = process.parent().map(|p| p.as_u32());
let start_time = process.start_time(); let start_time = process.start_time();
// Read UID and GID directly from /proc/{pid}/status for accuracy. // Read UID and GID directly from /proc/{pid}/status for accuracy
// Uses the shared single-read helper (also extracts memory, threads,
// state — we discard those here since sysinfo already provided them).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let (user_id, group_id) = read_proc_status(pid) let (user_id, group_id) =
.map(|s| (s.uid, s.gid)) if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) {
.unwrap_or((0, 0)); let mut uid = 0u32;
let mut gid = 0u32;
for line in status_content.lines() {
if let Some(value) = line.strip_prefix("Uid:") {
// Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
// We want the real UID (first value)
if let Some(uid_str) = value.split_whitespace().next() {
uid = uid_str.parse().unwrap_or(0);
}
} else if let Some(value) = line.strip_prefix("Gid:") {
// Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
// We want the real GID (first value)
if let Some(gid_str) = value.split_whitespace().next() {
gid = gid_str.parse().unwrap_or(0);
}
}
}
(uid, gid)
} else {
// Fallback if /proc read fails (permission issue)
(0, 0)
};
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let (user_id, group_id) = (0, 0); let (user_id, group_id) = (0, 0);
@@ -1349,9 +1248,6 @@ pub async fn collect_process_metrics(
// Collect thread information (Linux only) // Collect thread information (Linux only)
let threads = collect_thread_info(pid); let threads = collect_thread_info(pid);
// One read of /proc/{pid}/stat covers both user + system CPU times.
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
// Now construct the detailed info without holding the lock // Now construct the detailed info without holding the lock
let detailed_info = DetailedProcessInfo { let detailed_info = DetailedProcessInfo {
pid, pid,
@@ -1368,8 +1264,8 @@ pub async fn collect_process_metrics(
user_id, user_id,
group_id, group_id,
start_time, start_time,
cpu_time_user, cpu_time_user: get_cpu_time_user(pid),
cpu_time_system, cpu_time_system: get_cpu_time_system(pid),
read_bytes, read_bytes,
write_bytes, write_bytes,
working_directory, working_directory,
-4
View File
@@ -17,10 +17,6 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
pub struct ProcCpuTracker { pub struct ProcCpuTracker {
pub last_total: u64, pub last_total: u64,
pub last_per_pid: HashMap<u32, u64>, pub last_per_pid: HashMap<u32, u64>,
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
/// the same `String`s on every processes poll (~once per 1.5s).
pub names: HashMap<u32, String>,
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
+19 -28
View File
@@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
Message::Text(ref text) if text == "get_processes" => { Message::Text(ref text) if text == "get_processes" => {
let payload = collect_processes_all(&state).await; let payload = collect_processes_all(&state).await;
// Get cached buffers. The Vec capacity is preserved across // Map to protobuf message
// calls (with_capacity(512) seeds it, then we swap-back after // Get cached buffers
// encode so the allocation outlives any single request).
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new())); let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
let mut cache = cache.lock().await; let mut cache = cache.lock().await;
// Reuse process vector to build the list
cache.processes_vec.clear(); cache.processes_vec.clear();
cache cache
.processes_vec .processes_vec
@@ -85,38 +85,29 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
mem_bytes: p.mem_bytes, mem_bytes: p.mem_bytes,
})); }));
// Move the populated Vec into the proto, encode, then move it let pb = pb::Processes {
// BACK into the cache so the next call reuses the same heap
// allocation. The previous code did `mem::take(...)` here but
// then dropped `pb` (and the Vec along with it), leaving the
// cache holding an empty zero-capacity Vec — defeating the
// whole point of `with_capacity(512)`.
let mut pb = pb::Processes {
process_count: payload.process_count as u64, process_count: payload.process_count as u64,
rows: std::mem::take(&mut cache.processes_vec), rows: std::mem::take(&mut cache.processes_vec),
}; };
let mut buf = Vec::with_capacity(8 * 1024); let mut buf = Vec::with_capacity(8 * 1024);
let encode_result = prost::Message::encode(&pb, &mut buf); if prost::Message::encode(&pb, &mut buf).is_err() {
// Restore the (now-encoded-from) Vec to the cache before pb is
// dropped. We `take` it out of pb to leave that field empty,
// and the next request will `.clear()` before refilling.
cache.processes_vec = std::mem::take(&mut pb.rows);
if encode_result.is_err() {
let _ = socket.send(Message::Close(None)).await; let _ = socket.send(Message::Close(None)).await;
} else if buf.len() <= COMPRESSION_THRESHOLD {
let _ = socket.send(Message::Binary(buf)).await;
} else { } else {
// Create a new encoder for each message to ensure proper gzip headers // compress if large
let mut encoder = if buf.len() <= COMPRESSION_THRESHOLD {
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast()); let _ = socket.send(Message::Binary(buf)).await;
match encoder.write_all(&buf).and_then(|_| encoder.finish()) { } else {
Ok(compressed) => { // Create a new encoder for each message to ensure proper gzip headers
let _ = socket.send(Message::Binary(compressed)).await; let mut encoder =
} GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
Err(_) => { match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
let _ = socket.send(Message::Binary(buf)).await; Ok(compressed) => {
let _ = socket.send(Message::Binary(compressed)).await;
}
Err(_) => {
let _ = socket.send(Message::Binary(buf)).await;
}
} }
} }
} }
+2 -2
View File
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"