Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 745a681de7 | |||
| a9366d069d | |||
| bf1b4f70c3 | |||
| f73e198a66 | |||
| f7b095eb4a | |||
| 180186e2cc | |||
| cc167f71d3 | |||
| dd1dbdf29b | |||
| 524af0d123 | |||
| 619c288e9e | |||
| e04e344e2c | |||
| 8c531f9981 | |||
| 7fe302a3e2 | |||
| 31c2b59fd4 | |||
| f9462a1633 | |||
| 512913e897 | |||
| 64b641368c | |||
| f4b6faffaa | |||
| 4b9d11dc9e | |||
| c80f8cc363 | |||
| ed3d43ff7a | |||
| 350611b3b1 |
@@ -1,10 +1,10 @@
|
||||
name: Build Debian Packages
|
||||
|
||||
on:
|
||||
# APT publishing is release-driven: we build + publish only on `v*` tag
|
||||
# pushes. PRs into master still build the .debs as a sanity check (no
|
||||
# publish). Manual dispatch is kept as an escape hatch.
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- feature/debian-packaging
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
@@ -231,9 +231,7 @@ jobs:
|
||||
name: Publish to APT Repository
|
||||
needs: combine-artifacts
|
||||
runs-on: ubuntu-latest
|
||||
# Publish only on `v*` release tags — keep gh-pages stable between
|
||||
# releases instead of overwriting same-version .debs on every commit.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -316,19 +314,8 @@ jobs:
|
||||
done
|
||||
done
|
||||
|
||||
- name: Set GPG available flag
|
||||
id: check_gpg
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -n "$GPG_PRIVATE_KEY" ]; then
|
||||
echo "available=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "available=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import GPG key
|
||||
if: steps.check_gpg.outputs.available == 'true'
|
||||
if: secrets.GPG_PRIVATE_KEY != ''
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
@@ -336,31 +323,29 @@ jobs:
|
||||
gpg --list-secret-keys
|
||||
|
||||
- name: Sign repository
|
||||
if: steps.check_gpg.outputs.available == 'true'
|
||||
if: secrets.GPG_PRIVATE_KEY != ''
|
||||
env:
|
||||
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
if [ -n "$GPG_PASSPHRASE" ]; then
|
||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
|
||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
-abs -o dists/stable/Release.gpg dists/stable/Release
|
||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --no-tty --pinentry-mode loopback --passphrase-fd 0 \
|
||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
--clearsign -o dists/stable/InRelease dists/stable/Release
|
||||
else
|
||||
gpg --batch --yes --no-tty --pinentry-mode loopback \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
gpg --batch --yes --default-key "$GPG_KEY_ID" \
|
||||
-abs -o dists/stable/Release.gpg dists/stable/Release
|
||||
gpg --batch --yes --no-tty --pinentry-mode loopback \
|
||||
--default-key "$GPG_KEY_ID" \
|
||||
gpg --batch --yes --default-key "$GPG_KEY_ID" \
|
||||
--clearsign -o dists/stable/InRelease dists/stable/Release
|
||||
fi
|
||||
gpg --armor --export "$GPG_KEY_ID" > KEY.gpg
|
||||
echo "✓ Repository signed"
|
||||
|
||||
- name: Create unsigned repository notice
|
||||
if: steps.check_gpg.outputs.available == 'false'
|
||||
if: secrets.GPG_PRIVATE_KEY == ''
|
||||
run: |
|
||||
echo "⚠️ Warning: GPG_PRIVATE_KEY not set. Repository will be UNSIGNED."
|
||||
echo "⚠️ Add GPG secrets to sign the repository automatically."
|
||||
|
||||
Generated
+277
-891
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -24,9 +24,8 @@ serde_json = "1.0"
|
||||
sysinfo = "0.37"
|
||||
|
||||
# CLI UI
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
unicode-width = "0.2"
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.27"
|
||||
|
||||
# web server (remote-agent)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
|
||||
@@ -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)
|
||||
- TUI: smooth graphs, sortable process table, scrollbars, readable colors
|
||||
|
||||
[socktop.io](https://www.socktop.io)
|
||||
|
||||
<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
|
||||
- Optional GPU metrics (can be disabled)
|
||||
- Optional auth token for the agent
|
||||
- Compact layout for small windows: automatically drops the panes that no longer fit so
|
||||
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
|
||||
|
||||
---
|
||||
|
||||
@@ -215,8 +211,6 @@ socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
||||
# shorthand:
|
||||
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
||||
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
||||
# force the small-window layout at any terminal size (normally automatic):
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
Intervals (client-driven):
|
||||
@@ -228,29 +222,6 @@ The agent stays idle unless queried. When queried, it collects just what’s nee
|
||||
|
||||
---
|
||||
|
||||
## Compact mode
|
||||
|
||||
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
|
||||
are the first things to collapse — exactly the panes you are most likely watching. Once
|
||||
the window is too short for the Disks pane to show even one disk, socktop switches to a
|
||||
compact layout:
|
||||
|
||||
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
|
||||
- **Memory and Swap move side by side** into the row Disks vacated.
|
||||
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
|
||||
with no GPU the pane disappears entirely.
|
||||
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
|
||||
below the size where they used to vanish.
|
||||
|
||||
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
|
||||
layout at any window size:
|
||||
|
||||
```bash
|
||||
socktop --compact ws://HOST:3000/ws
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection Profiles (Named)
|
||||
|
||||
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -20,7 +20,6 @@ serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
dirs-next = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
|
||||
+213
-258
@@ -15,7 +15,7 @@ use ratatui::{
|
||||
//style::Color, // + add Color
|
||||
Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::Rect,
|
||||
layout::{Constraint, Direction, Rect},
|
||||
};
|
||||
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_handle_scrollbar_mouse,
|
||||
};
|
||||
use crate::ui::layout::{AppLayout, compute as compute_layout};
|
||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||
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,
|
||||
};
|
||||
use crate::ui::{
|
||||
disks::draw_disks,
|
||||
gpu::{draw_gpu, draw_gpu_compact},
|
||||
header::{HeaderState, build_header, draw_header},
|
||||
mem::draw_mem,
|
||||
net::draw_net_spark,
|
||||
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
|
||||
swap::draw_swap,
|
||||
};
|
||||
|
||||
@@ -51,15 +46,6 @@ use socktop_connector::{
|
||||
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
||||
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)]
|
||||
pub enum ConnectionState {
|
||||
Connected,
|
||||
@@ -71,9 +57,8 @@ pub struct App {
|
||||
// Latest metrics + histories
|
||||
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_sum: u64,
|
||||
|
||||
// Per-core history (0..100)
|
||||
per_core_hist: PerCoreHistory,
|
||||
@@ -104,17 +89,6 @@ pub struct App {
|
||||
pub process_search_active: bool,
|
||||
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_disks_poll: Instant,
|
||||
procs_interval: Duration,
|
||||
@@ -125,7 +99,6 @@ pub struct App {
|
||||
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
|
||||
pub journal_entries: Option<socktop_connector::JournalResponse>,
|
||||
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_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)
|
||||
@@ -146,20 +119,6 @@ pub struct App {
|
||||
pub is_tls: 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
|
||||
pub modal_manager: crate::ui::modal::ModalManager,
|
||||
|
||||
@@ -177,7 +136,6 @@ impl App {
|
||||
Self {
|
||||
last_metrics: None,
|
||||
cpu_hist: VecDeque::with_capacity(600),
|
||||
cpu_hist_sum: 0,
|
||||
per_core_hist: PerCoreHistory::new(60),
|
||||
last_net_totals: None,
|
||||
rx_hist: VecDeque::with_capacity(600),
|
||||
@@ -196,10 +154,6 @@ impl App {
|
||||
prev_selected_process_pid: None,
|
||||
process_search_active: false,
|
||||
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()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||
@@ -212,7 +166,6 @@ impl App {
|
||||
process_details: None,
|
||||
journal_entries: None,
|
||||
process_cpu_history: VecDeque::with_capacity(600),
|
||||
process_cpu_history_sum: 0.0,
|
||||
process_mem_history: VecDeque::with_capacity(600),
|
||||
process_io_read_history: VecDeque::with_capacity(600),
|
||||
process_io_write_history: VecDeque::with_capacity(600),
|
||||
@@ -233,14 +186,6 @@ impl App {
|
||||
verify_hostname: false,
|
||||
is_tls: 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(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
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 {
|
||||
metrics_ms.inspect(|&m| {
|
||||
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
|
||||
@@ -537,10 +465,7 @@ impl App {
|
||||
_url: &str,
|
||||
_tls_ca: Option<&str>,
|
||||
_verify_hostname: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
// Handle input for modal
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
@@ -647,10 +572,7 @@ impl App {
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
mut ws: SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
// Main event loop
|
||||
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
|
||||
@@ -670,10 +592,7 @@ impl App {
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
ws: &mut SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
// Input (non-blocking)
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
@@ -746,7 +665,6 @@ impl App {
|
||||
// Exit search mode
|
||||
self.process_search_active = false;
|
||||
self.process_search_query.clear();
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
@@ -754,24 +672,27 @@ impl App {
|
||||
self.process_search_active = false;
|
||||
|
||||
// Auto-select first filtered result
|
||||
let first = self.procs_filter().first().copied();
|
||||
if let (Some(first_idx), Some(m)) =
|
||||
(first, self.last_metrics.as_ref())
|
||||
{
|
||||
self.selected_process_index = Some(first_idx);
|
||||
self.selected_process_pid =
|
||||
Some(m.top_processes[first_idx].pid);
|
||||
if let Some(m) = self.last_metrics.as_ref() {
|
||||
let idxs = get_filtered_sorted_indices(
|
||||
m,
|
||||
&self.process_search_query,
|
||||
self.procs_sort_by,
|
||||
);
|
||||
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;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.process_search_query.pop();
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.process_search_query.push(c);
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Down => {
|
||||
@@ -807,7 +728,6 @@ impl App {
|
||||
self.process_search_query.clear();
|
||||
self.selected_process_pid = None;
|
||||
self.selected_process_index = None;
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -824,12 +744,21 @@ impl App {
|
||||
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
|
||||
let sz = terminal.size()?;
|
||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||
let layout = self.layout(area);
|
||||
let content = per_core_content_area(layout.per_core);
|
||||
|
||||
// Refresh the filtered+sorted index cache once before we
|
||||
// borrow individual fields of `self`.
|
||||
let _ = self.procs_filter();
|
||||
let rows = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(10),
|
||||
])
|
||||
.split(area);
|
||||
let top = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[1]);
|
||||
let content = per_core_content_area(top[1]);
|
||||
|
||||
// First try process selection (only handles arrows if a process is selected)
|
||||
let process_handled = if self.last_procs_area.is_some() {
|
||||
@@ -838,7 +767,8 @@ impl App {
|
||||
selected_process_index: &mut self.selected_process_index,
|
||||
key: k,
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
filtered_indices: &self.procs_filtered,
|
||||
sort_by: self.procs_sort_by,
|
||||
search_query: &self.process_search_query,
|
||||
})
|
||||
} else {
|
||||
false
|
||||
@@ -856,9 +786,14 @@ impl App {
|
||||
// Auto-scroll to keep selected process visible
|
||||
if let (Some(selected_idx), Some(p_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
|
||||
if let Some(display_pos) =
|
||||
@@ -923,10 +858,23 @@ impl App {
|
||||
// Layout to get areas
|
||||
let sz = terminal.size()?;
|
||||
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
|
||||
let content = per_core_content_area(layout.per_core);
|
||||
let content = per_core_content_area(top[1]);
|
||||
per_core_handle_mouse(
|
||||
&mut self.per_core_scroll,
|
||||
m,
|
||||
@@ -944,7 +892,7 @@ impl App {
|
||||
&mut self.per_core_scroll,
|
||||
&mut self.per_core_drag,
|
||||
m,
|
||||
layout.per_core,
|
||||
top[1],
|
||||
total_rows,
|
||||
);
|
||||
|
||||
@@ -955,17 +903,11 @@ impl App {
|
||||
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
|
||||
if let (Some(_mm), Some(p_area)) =
|
||||
if let (Some(mm), Some(p_area)) =
|
||||
(self.last_metrics.as_ref(), self.last_procs_area)
|
||||
{
|
||||
use crate::ui::processes::ProcessMouseParams;
|
||||
let total_rows = self.procs_filtered.len();
|
||||
if let Some(new_sort) =
|
||||
processes_handle_mouse_with_selection(ProcessMouseParams {
|
||||
scroll_offset: &mut self.procs_scroll_offset,
|
||||
@@ -974,14 +916,13 @@ impl App {
|
||||
drag: &mut self.procs_drag,
|
||||
mouse: m,
|
||||
area: p_area,
|
||||
total_rows,
|
||||
total_rows: mm.top_processes.len(),
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
search_box_visible,
|
||||
filtered_indices: &self.procs_filtered,
|
||||
sort_by: self.procs_sort_by,
|
||||
search_query: &self.process_search_query,
|
||||
})
|
||||
{
|
||||
self.procs_sort_by = new_sort;
|
||||
self.invalidate_procs_filter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,36 +959,22 @@ impl App {
|
||||
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
let mut updated = false;
|
||||
if let Ok(AgentResponse::Processes(procs)) =
|
||||
ws.request(AgentRequest::Processes).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
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();
|
||||
}
|
||||
|
||||
// Only poll disks every 5s
|
||||
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
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
dedup_disks(&mut disks);
|
||||
mm.disks = disks;
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
@@ -1073,14 +1000,7 @@ impl App {
|
||||
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
|
||||
// Update history for sparklines
|
||||
let cpu_usage = details.process.cpu_usage;
|
||||
let evicted_cpu = push_capped(
|
||||
&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);
|
||||
push_capped(&mut self.process_cpu_history, cpu_usage, 600);
|
||||
|
||||
let mem_bytes = details.process.mem_bytes;
|
||||
push_capped(&mut self.process_mem_history, mem_bytes, 600);
|
||||
@@ -1185,37 +1105,11 @@ impl App {
|
||||
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
|
||||
pub fn clear_process_details(&mut self) {
|
||||
self.process_details = None;
|
||||
self.journal_entries = None;
|
||||
self.process_cpu_history.clear();
|
||||
self.process_cpu_history_sum = 0.0;
|
||||
self.process_mem_history.clear();
|
||||
self.process_io_read_history.clear();
|
||||
self.process_io_write_history.clear();
|
||||
@@ -1226,24 +1120,23 @@ impl App {
|
||||
}
|
||||
|
||||
fn update_with_metrics(&mut self, mut m: Metrics) {
|
||||
if let Some(prev) = self.last_metrics.as_mut() {
|
||||
// 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 let Some(prev) = &self.last_metrics {
|
||||
// Preserve slower fields when the fast payload omits them
|
||||
if m.disks.is_empty() {
|
||||
m.disks = std::mem::take(&mut prev.disks);
|
||||
m.disks = prev.disks.clone();
|
||||
}
|
||||
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() {
|
||||
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 evicted = push_capped(&mut self.cpu_hist, v, 600);
|
||||
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
|
||||
push_capped(&mut self.cpu_hist, v, 600);
|
||||
|
||||
// Per-core history (push current samples)
|
||||
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
||||
@@ -1273,106 +1166,112 @@ impl App {
|
||||
|
||||
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
|
||||
let area = f.area();
|
||||
let l = self.layout(area);
|
||||
|
||||
// Header — refresh cached strings only when their inputs change so the
|
||||
// ratatui diff renderer can suppress repaints on idle frames. The wording now
|
||||
// depends on the row width too, so that is part of the key.
|
||||
{
|
||||
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
||||
let state = HeaderState {
|
||||
hostname,
|
||||
is_tls: self.is_tls,
|
||||
has_token: self.has_token,
|
||||
metrics_ms: self.metrics_interval.as_millis(),
|
||||
procs_ms: self.procs_interval.as_millis(),
|
||||
};
|
||||
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);
|
||||
// Root rows: header, top (cpu avg + per-core), memory, swap, bottom
|
||||
let rows = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Ratio(1, 3), // top row
|
||||
Constraint::Length(3), // memory (left) + GPU (right, part 1)
|
||||
Constraint::Length(3), // swap (left) + GPU (right, part 2)
|
||||
Constraint::Min(10), // bottom: disks + net (left), top procs (right)
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_cpu_avg_graph(
|
||||
// Header
|
||||
draw_header(
|
||||
f,
|
||||
l.cpu,
|
||||
&mut self.cpu_hist,
|
||||
self.cpu_hist_sum,
|
||||
rows[0],
|
||||
self.last_metrics.as_ref(),
|
||||
self.is_tls,
|
||||
self.has_token,
|
||||
self.metrics_interval,
|
||||
self.procs_interval,
|
||||
);
|
||||
|
||||
// Top row: left CPU avg, right Per-core (full top-right)
|
||||
let top_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[1]);
|
||||
|
||||
draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
|
||||
draw_per_core_bars(
|
||||
f,
|
||||
l.per_core,
|
||||
top_lr[1],
|
||||
self.last_metrics.as_ref(),
|
||||
&mut self.per_core_hist,
|
||||
&self.per_core_hist,
|
||||
self.per_core_scroll,
|
||||
);
|
||||
|
||||
// Memory + Swap: stacked vertically in the normal layout, side by side in the
|
||||
// row Disks vacates in compact mode.
|
||||
draw_mem(f, l.mem, self.last_metrics.as_ref());
|
||||
draw_swap(f, l.swap, self.last_metrics.as_ref());
|
||||
// Memory + Swap rows split into left/right columns
|
||||
let mem_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[2]);
|
||||
let swap_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[3]);
|
||||
|
||||
// GPU: a panel beside Memory/Swap normally, a single full-width line in compact
|
||||
// mode, and absent entirely when the host reports no GPU while compact.
|
||||
if let Some(gpu_area) = l.gpu {
|
||||
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());
|
||||
}
|
||||
}
|
||||
// Left: Memory + Swap
|
||||
draw_mem(f, mem_lr[0], self.last_metrics.as_ref());
|
||||
draw_swap(f, swap_lr[0], self.last_metrics.as_ref());
|
||||
|
||||
if let Some(disks_area) = l.disks {
|
||||
draw_disks(f, disks_area, self.last_metrics.as_ref());
|
||||
}
|
||||
// Right: GPU spans the same vertical space as Memory + Swap
|
||||
let gpu_area = ratatui::layout::Rect {
|
||||
x: mem_lr[1].x,
|
||||
y: mem_lr[1].y,
|
||||
width: mem_lr[1].width,
|
||||
height: mem_lr[1].height + swap_lr[1].height,
|
||||
};
|
||||
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
|
||||
|
||||
// Net titles only change when the throughput or peak changes.
|
||||
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
|
||||
let rx_key = (rx_now, self.rx_peak);
|
||||
if self.net_dl_key != rx_key {
|
||||
self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak);
|
||||
self.net_dl_key = rx_key;
|
||||
}
|
||||
// Bottom area: left = Disks + Network, right = Top Processes
|
||||
let bottom_lr = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(rows[4]);
|
||||
|
||||
// Left bottom: Disks + Net stacked (make net panes slightly taller)
|
||||
let left_stack = ratatui::layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(4), // Disks shrink slightly
|
||||
Constraint::Length(5), // Download taller
|
||||
Constraint::Length(5), // Upload taller
|
||||
])
|
||||
.split(bottom_lr[0]);
|
||||
|
||||
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
|
||||
draw_net_spark(
|
||||
f,
|
||||
l.download,
|
||||
&self.net_dl_title,
|
||||
&mut self.rx_hist,
|
||||
left_stack[1],
|
||||
&format!(
|
||||
"Download (KB/s) — now: {} | peak: {}",
|
||||
self.rx_hist.back().copied().unwrap_or(0),
|
||||
self.rx_peak
|
||||
),
|
||||
&self.rx_hist,
|
||||
ratatui::style::Color::Green,
|
||||
);
|
||||
|
||||
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(
|
||||
f,
|
||||
l.upload,
|
||||
&self.net_ul_title,
|
||||
&mut self.tx_hist,
|
||||
left_stack[2],
|
||||
&format!(
|
||||
"Upload (KB/s) — now: {} | peak: {}",
|
||||
self.tx_hist.back().copied().unwrap_or(0),
|
||||
self.tx_peak
|
||||
),
|
||||
&self.tx_hist,
|
||||
ratatui::style::Color::Blue,
|
||||
);
|
||||
|
||||
// Right bottom: Top Processes fills the column
|
||||
let procs_area = l.procs;
|
||||
let procs_area = bottom_lr[1];
|
||||
// Cache for input handlers
|
||||
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(
|
||||
f,
|
||||
procs_area,
|
||||
@@ -1384,9 +1283,6 @@ impl App {
|
||||
selected_process_index: self.selected_process_index,
|
||||
search_query: &self.process_search_query,
|
||||
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(),
|
||||
history: ProcessHistoryData {
|
||||
cpu: &self.process_cpu_history,
|
||||
cpu_sum: self.process_cpu_history_sum,
|
||||
mem: &self.process_mem_history,
|
||||
io_read: &self.process_io_read_history,
|
||||
io_write: &self.process_io_write_history,
|
||||
@@ -1415,6 +1310,66 @@ impl App {
|
||||
|
||||
impl Default for App {
|
||||
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
@@ -2,25 +2,16 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Push a value into a capped deque. Returns the evicted front element if any.
|
||||
/// Callers maintaining a running sum can use this to update the sum without
|
||||
/// re-iterating the whole deque.
|
||||
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
|
||||
};
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||
if dq.len() == cap {
|
||||
dq.pop_front();
|
||||
}
|
||||
dq.push_back(v);
|
||||
evicted
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Keeps a history deque per core with a fixed capacity
|
||||
pub struct PerCoreHistory {
|
||||
pub deques: Vec<VecDeque<u64>>,
|
||||
pub deques: Vec<VecDeque<u16>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
@@ -44,7 +35,7 @@ impl PerCoreHistory {
|
||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||
self.ensure_cores(samples.len());
|
||||
for (i, v) in samples.iter().enumerate() {
|
||||
let val = v.clamp(0.0, 100.0).round() as u64;
|
||||
let val = v.clamp(0.0, 100.0).round() as u16;
|
||||
push_capped(&mut self.deques[i], val, self.cap);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-77
@@ -22,7 +22,6 @@ pub(crate) struct ParsedArgs {
|
||||
metrics_interval_ms: Option<u64>,
|
||||
processes_interval_ms: Option<u64>,
|
||||
verify_hostname: bool,
|
||||
compact: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||
@@ -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 processes_interval_ms: Option<u64> = None;
|
||||
let mut verify_hostname = false;
|
||||
let mut compact = false;
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => {
|
||||
return Err(format!(
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||
));
|
||||
}
|
||||
"--tls-ca" | "-t" => {
|
||||
@@ -63,11 +61,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
"--demo" => {
|
||||
demo = true;
|
||||
}
|
||||
"--compact" => {
|
||||
// Force the small-window layout at any terminal size. Without it the
|
||||
// layout switches on its own once the window gets too short.
|
||||
compact = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
// intentionally undocumented
|
||||
dry_run = true;
|
||||
@@ -107,7 +100,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
url = Some(arg);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
|
||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -123,7 +116,6 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
||||
metrics_interval_ms,
|
||||
processes_interval_ms,
|
||||
verify_hostname,
|
||||
compact,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,7 +136,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
}
|
||||
|
||||
let profiles_file = load_profiles();
|
||||
@@ -249,7 +241,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if (1..=names.len()).contains(&idx) {
|
||||
let name = &names[idx - 1];
|
||||
if name == "demo" {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
}
|
||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||
(
|
||||
@@ -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.");
|
||||
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
|
||||
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
||||
} else {
|
||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||
return Ok(());
|
||||
@@ -323,8 +315,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let has_token = url.contains("token=");
|
||||
let mut app = App::new()
|
||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||
.with_status(is_tls, has_token)
|
||||
.with_compact(parsed.compact);
|
||||
.with_status(is_tls, has_token);
|
||||
if parsed.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -388,23 +379,11 @@ fn gather_intervals(
|
||||
}
|
||||
|
||||
// Demo mode implementation
|
||||
async fn run_demo_mode(
|
||||
_tls_ca: Option<&str>,
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let port = 3231;
|
||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||
let child = match spawn_demo_agent(port) {
|
||||
Ok(child) => child,
|
||||
// The agent ships as its own binary, so a missing one is a setup problem,
|
||||
// not a crash: tell the user how to fix it instead of dumping an io error.
|
||||
Err(e @ DemoAgentError::NotFound(_)) => {
|
||||
eprintln!("{e}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut app = App::new().with_compact(compact);
|
||||
let child = spawn_demo_agent(port)?;
|
||||
let mut app = App::new();
|
||||
// Demo mode connects to localhost, so disable hostname verification
|
||||
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||
}
|
||||
@@ -420,50 +399,9 @@ impl Drop for DemoGuard {
|
||||
eprintln!("Stopped demo agent on port {}", self.port);
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
enum DemoAgentError {
|
||||
/// The socktop_agent executable could not be located.
|
||||
NotFound(std::path::PathBuf),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DemoAgentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(candidate) => write!(
|
||||
f,
|
||||
"Could not start demo mode: '{}' was not found{}.\n\
|
||||
\n\
|
||||
Demo mode runs a local agent, which is shipped as a separate binary\n\
|
||||
and is not installed alongside the socktop TUI. Install it with:\n\
|
||||
\n cargo install socktop_agent\n\n\
|
||||
then run socktop again. See {} for other install options.",
|
||||
candidate.display(),
|
||||
// A bare file name means find_agent_executable() fell back to a PATH lookup.
|
||||
if candidate.parent().is_none_or(|p| p.as_os_str().is_empty()) {
|
||||
" on your PATH"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
env!("CARGO_PKG_HOMEPAGE"),
|
||||
),
|
||||
Self::Io(e) => write!(f, "Could not start demo mode: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DemoAgentError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::NotFound(_) => None,
|
||||
Self::Io(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, DemoAgentError> {
|
||||
fn spawn_demo_agent(port: u16) -> Result<DemoGuard, Box<dyn std::error::Error>> {
|
||||
let candidate = find_agent_executable();
|
||||
let mut cmd = std::process::Command::new(&candidate);
|
||||
let mut cmd = std::process::Command::new(candidate);
|
||||
cmd.arg("--port").arg(port.to_string());
|
||||
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_TEMP", "0");
|
||||
|
||||
let child = cmd.spawn().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DemoAgentError::NotFound(candidate),
|
||||
_ => DemoAgentError::Io(e),
|
||||
})?;
|
||||
let child = cmd.spawn()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
Ok(DemoGuard {
|
||||
port,
|
||||
|
||||
+88
-331
@@ -7,17 +7,11 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
||||
},
|
||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||
};
|
||||
|
||||
use crate::history::PerCoreHistory;
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::fit::{cols, pick_pair};
|
||||
|
||||
/// Columns kept clear between the CPU title and the temperature readout.
|
||||
const TITLE_GAP: u16 = 2;
|
||||
|
||||
/// State for dragging the scrollbar thumb
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -139,9 +133,11 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
}
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let top_for_offset = |off: usize| -> usize {
|
||||
((track - thumb_len) * off + max_off / 2)
|
||||
.checked_div(max_off)
|
||||
.unwrap_or(0)
|
||||
if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * off + max_off / 2) / max_off
|
||||
}
|
||||
};
|
||||
let thumb_top = top_for_offset(offset);
|
||||
|
||||
@@ -194,9 +190,11 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
// Inverse mapping top -> offset
|
||||
if track > thumb_len {
|
||||
let denom = track - thumb_len;
|
||||
offset = (new_top * max_off + denom / 2)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0);
|
||||
offset = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
(new_top * max_off + denom / 2) / denom
|
||||
};
|
||||
} else {
|
||||
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.
|
||||
///
|
||||
/// `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(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
hist: &mut std::collections::VecDeque<u64>,
|
||||
hist_sum: u64,
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
m: Option<&Metrics>,
|
||||
) {
|
||||
let avg_cpu = if hist.is_empty() {
|
||||
0.0
|
||||
// Calculate average CPU over the monitoring period
|
||||
let avg_cpu = if !hist.is_empty() {
|
||||
let sum: u64 = hist.iter().sum();
|
||||
sum as f64 / hist.len() as f64
|
||||
} else {
|
||||
hist_sum as f64 / hist.len() as f64
|
||||
0.0
|
||||
};
|
||||
|
||||
let (title, top_right_info) = cpu_title_for_width(
|
||||
m.map(|mm| mm.cpu_total),
|
||||
avg_cpu,
|
||||
m.and_then(|mm| mm.cpu_temp_c),
|
||||
area.width,
|
||||
);
|
||||
let title = if let Some(mm) = m {
|
||||
format!("CPU (now: {:>5.1}% | avg: {:>5.1}%)", mm.cpu_total, avg_cpu)
|
||||
} else {
|
||||
"CPU avg".into()
|
||||
};
|
||||
|
||||
// Build the top-right info (CPU temp and polling intervals)
|
||||
let top_right_info = if let Some(mm) = m {
|
||||
mm.cpu_temp_c
|
||||
.map(|t| {
|
||||
let icon = if t < 50.0 {
|
||||
"😎"
|
||||
} else if t < 85.0 {
|
||||
"⚠️"
|
||||
} else {
|
||||
"🔥"
|
||||
};
|
||||
format!("CPU Temp: {t:.1}°C {icon}")
|
||||
})
|
||||
.unwrap_or_else(|| "CPU Temp: N/A".into())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||
// us skip the per-frame Vec allocation .collect() used to do.
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
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()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(slice)
|
||||
.data(&data)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
|
||||
// Temperature overlays the top border, right-aligned inside the corner. The title
|
||||
// above is sized so the two cannot collide.
|
||||
// Render the top-right info as text overlay in the top-right corner
|
||||
if !top_right_info.is_empty() {
|
||||
let w = cols(&top_right_info);
|
||||
let info_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(w + 1),
|
||||
x: area.x + area.width.saturating_sub(top_right_info.len() as u16 + 2),
|
||||
y: area.y,
|
||||
width: w,
|
||||
width: top_right_info.len() as u16 + 1,
|
||||
height: 1,
|
||||
};
|
||||
let info_line = Line::from(Span::raw(top_right_info));
|
||||
@@ -288,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.
|
||||
pub fn draw_per_core_bars(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
per_core_hist: &mut PerCoreHistory,
|
||||
per_core_hist: &PerCoreHistory,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
f.render_widget(
|
||||
@@ -399,7 +347,7 @@ pub fn draw_per_core_bars(
|
||||
let rect = vchunks[i];
|
||||
let hchunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||
.split(rect);
|
||||
|
||||
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)
|
||||
.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 {
|
||||
"↑"
|
||||
} else if curr + 0.2 < older {
|
||||
"↓"
|
||||
} else {
|
||||
" "
|
||||
"╌"
|
||||
};
|
||||
|
||||
let fg = match curr {
|
||||
@@ -429,24 +372,24 @@ pub fn draw_per_core_bars(
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
||||
// to Sparkline without allocating a fresh Vec each frame.
|
||||
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
let slice = &d.make_contiguous()[start..];
|
||||
let spark = Sparkline::default()
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
}
|
||||
let hist: Vec<u64> = per_core_hist
|
||||
.deques
|
||||
.get(idx)
|
||||
.map(|d| {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
d.iter().skip(start).map(|&v| v as u64).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Hard space between the trend mark and the number — even if the
|
||||
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
||||
// them, this space prevents the trend mark from visually joining
|
||||
// `100.0` to look like a negative value.
|
||||
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
||||
let spark = Sparkline::default()
|
||||
.data(&hist)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
|
||||
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
|
||||
let line = Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
@@ -454,224 +397,38 @@ pub fn draw_per_core_bars(
|
||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||
}
|
||||
|
||||
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
||||
// content fits in the viewport, matching the previous behaviour.
|
||||
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
||||
let scroll_area = Rect {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
if scroll_area.height >= 3 {
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_tests {
|
||||
use super::*;
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let thumb_top = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
||||
};
|
||||
|
||||
/// The defect this replaces: the temperature was painted over the title's tail on a
|
||||
/// narrow pane. Whatever the width, the two must fit side by side on the border.
|
||||
#[test]
|
||||
fn title_and_temperature_never_overlap() {
|
||||
for width in 0..=200u16 {
|
||||
let (title, temp) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||
let budget = width.saturating_sub(4);
|
||||
if temp.is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
cols(&title) + cols(&temp) + TITLE_GAP <= budget,
|
||||
"width {width}: {title:?} + {temp:?} do not fit in {budget} columns"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current CPU reading is the one thing the pane must always show.
|
||||
#[test]
|
||||
fn the_current_reading_always_survives() {
|
||||
for width in 20..=200u16 {
|
||||
let (title, _) = cpu_title_for_width(Some(3.4), 12.7, Some(43.0), width);
|
||||
assert!(
|
||||
title.contains("3.4"),
|
||||
"width {width}: lost the reading ({title:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder from the design: temp label, then now/avg labels, then the average,
|
||||
/// then the temperature's decimal, then the temperature.
|
||||
#[test]
|
||||
fn detail_is_dropped_in_priority_order() {
|
||||
let at = |w| cpu_title_for_width(Some(0.7), 1.3, Some(43.0), w);
|
||||
|
||||
let (title, temp) = at(80);
|
||||
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||
assert_eq!(temp, "CPU Temp: 43.0°C 😎");
|
||||
|
||||
// The "CPU Temp:" label goes first; the readings keep their labels.
|
||||
let (title, temp) = at(50);
|
||||
assert_eq!(title, "CPU (now: 0.7% | avg: 1.3%)");
|
||||
assert_eq!(temp, "43.0°C 😎");
|
||||
|
||||
// Then the now:/avg: labels.
|
||||
let (title, temp) = at(40);
|
||||
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||
assert_eq!(temp, "43.0°C 😎");
|
||||
|
||||
// Then the temperature's decimal.
|
||||
let (title, temp) = at(31);
|
||||
assert_eq!(title, "CPU (0.7% | 1.3%)");
|
||||
assert_eq!(temp, "43°C 😎");
|
||||
|
||||
// Then the average reading.
|
||||
let (title, temp) = at(26);
|
||||
assert_eq!(title, "CPU (0.7%)");
|
||||
assert_eq!(temp, "43°C 😎");
|
||||
|
||||
// Last of all, the temperature itself.
|
||||
let (title, temp) = at(15);
|
||||
assert_eq!(title, "CPU (0.7%)");
|
||||
assert_eq!(temp, "");
|
||||
}
|
||||
|
||||
/// A hot CPU has to stay visible as a warning, so the glyph rides along with the
|
||||
/// reading at every tier that shows a temperature at all.
|
||||
#[test]
|
||||
fn the_thermal_glyph_tracks_the_temperature() {
|
||||
for (t, icon) in [(43.0, "😎"), (70.0, "⚠️"), (92.0, "🔥")] {
|
||||
for width in 26..=80u16 {
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, Some(t), width);
|
||||
assert!(
|
||||
temp.contains(icon),
|
||||
"width {width} at {t}°C: expected {icon} in {temp:?}"
|
||||
);
|
||||
// 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))));
|
||||
|
||||
/// An agent that reports no temperature must not leave a stray label behind.
|
||||
#[test]
|
||||
fn a_missing_temperature_degrades_to_nothing() {
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 80);
|
||||
assert_eq!(temp, "CPU Temp: N/A");
|
||||
let (_, temp) = cpu_title_for_width(Some(0.7), 1.3, None, 14);
|
||||
assert_eq!(temp, "");
|
||||
}
|
||||
|
||||
/// Before the first payload arrives there are no readings to show.
|
||||
#[test]
|
||||
fn no_metrics_yet_shows_the_placeholder() {
|
||||
let (title, temp) = cpu_title_for_width(None, 0.0, None, 80);
|
||||
assert_eq!(title, "CPU avg");
|
||||
assert!(temp.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests {
|
||||
use super::*;
|
||||
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"
|
||||
);
|
||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -24,11 +24,16 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplication is performed once on the App side when fresh disk data
|
||||
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
||||
// here would rebuild a HashSet ~10x per refresh for no reason).
|
||||
// Filter duplicates by keeping first occurrence of each unique name
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
let unique_disks: Vec<_> = mm
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| seen_names.insert(d.name.clone()))
|
||||
.collect();
|
||||
|
||||
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)
|
||||
.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);
|
||||
|
||||
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 ratio = if d.total > 0 {
|
||||
used as f64 / d.total as f64
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,232 +1,55 @@
|
||||
//! Top header with hostname, connection status and polling intervals.
|
||||
//!
|
||||
//! The row carries two pieces of text — session identity on the left, polling intervals
|
||||
//! on the right — and both matter. Rather than let the right one overwrite the left when
|
||||
//! they no longer both fit, the header drops detail in priority order: the hostname and
|
||||
//! the intervals are what survive longest, because they are what tells you *which* host
|
||||
//! you are looking at and how fresh the numbers are.
|
||||
//! Top header with hostname and CPU temperature indicator.
|
||||
|
||||
use crate::ui::fit::{cols, pick_pair, truncate_cols};
|
||||
use crate::types::Metrics;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Columns kept clear between the left and right halves.
|
||||
const GAP: u16 = 2;
|
||||
/// Never shorten the hostname below this before dropping the intervals instead.
|
||||
const HOSTNAME_FLOOR: u16 = 8;
|
||||
|
||||
/// Session state the header renders.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct HeaderState<'a> {
|
||||
pub hostname: Option<&'a str>,
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
pub metrics_ms: u128,
|
||||
pub procs_ms: u128,
|
||||
}
|
||||
|
||||
/// 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"
|
||||
pub fn draw_header(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
is_tls: bool,
|
||||
has_token: bool,
|
||||
metrics_interval: Duration,
|
||||
procs_interval: Duration,
|
||||
) {
|
||||
let base = if let Some(mm) = m {
|
||||
format!("socktop — host: {}", mm.hostname)
|
||||
} else {
|
||||
"🔒✗ TLS"
|
||||
"socktop — connecting...".into()
|
||||
};
|
||||
let badges = if state.has_token {
|
||||
format!("{tls} | 🔑 token")
|
||||
} else {
|
||||
tls.to_string()
|
||||
};
|
||||
|
||||
let named = format!("socktop — host: {host}");
|
||||
let with_badges = format!("{named} | {badges}");
|
||||
let with_keys = format!("{with_badges} | (a: about, h: help, q: quit)");
|
||||
|
||||
let intervals = format!(
|
||||
"⏱ {}ms metrics | {}ms procs",
|
||||
state.metrics_ms, state.procs_ms
|
||||
);
|
||||
let intervals_short = format!("⏱ {}ms | {}ms", state.metrics_ms, state.procs_ms);
|
||||
|
||||
// Richest first. The bare hostname is reached before the intervals lose their
|
||||
// labels, and the hostname is only shortened once nothing else is left to give.
|
||||
let ladder = [
|
||||
(with_keys.as_str(), intervals.as_str()),
|
||||
(with_badges.as_str(), intervals.as_str()),
|
||||
(named.as_str(), intervals.as_str()),
|
||||
(host, intervals.as_str()),
|
||||
(host, intervals_short.as_str()),
|
||||
];
|
||||
let (left, right) = pick_pair(width, GAP, &ladder);
|
||||
if cols(left) + cols(right) + GAP <= width {
|
||||
return (left.to_string(), right.to_string());
|
||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||
// Token indicator
|
||||
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||
let mut parts = vec![base, tls_txt.into()];
|
||||
if !tok_txt.is_empty() {
|
||||
parts.push(tok_txt.into());
|
||||
}
|
||||
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
|
||||
// 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) {
|
||||
// Render the block with left-aligned title
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
|
||||
if intervals.is_empty() {
|
||||
return;
|
||||
}
|
||||
let intervals_width = cols(intervals);
|
||||
if area.width >= intervals_width {
|
||||
// Render polling intervals on the right side
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let intervals_width = intervals.len() as u16;
|
||||
|
||||
if area.width > intervals_width + 2 {
|
||||
let right_area = Rect {
|
||||
x: area.x + area.width - intervals_width,
|
||||
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
||||
y: area.y,
|
||||
width: intervals_width,
|
||||
height: 1,
|
||||
};
|
||||
f.render_widget(Paragraph::new(Line::from(Span::raw(intervals))), right_area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(hostname: Option<&str>) -> HeaderState<'_> {
|
||||
HeaderState {
|
||||
hostname,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
metrics_ms: 500,
|
||||
procs_ms: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The defect this replaces: the two halves were painted independently, so below
|
||||
/// ~105 columns the right half landed on top of the title. Whatever the width, they
|
||||
/// must now fit side by side.
|
||||
#[test]
|
||||
fn halves_never_overlap_at_any_width() {
|
||||
for width in 0..=200u16 {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||
let used = cols(&left) + cols(&right);
|
||||
if right.is_empty() {
|
||||
assert!(cols(&left) <= width, "width {width}: {left:?} overflows");
|
||||
} else {
|
||||
assert!(
|
||||
used + GAP <= width,
|
||||
"width {width}: {left:?} + {right:?} = {used} cols, no room for both"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostname and intervals are the two things worth keeping; everything else is
|
||||
/// context that can go.
|
||||
#[test]
|
||||
fn hostname_and_intervals_survive_longest() {
|
||||
for width in 34..=200u16 {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), width);
|
||||
assert!(
|
||||
left.contains("cachyos-gaming"),
|
||||
"width {width}: lost the hostname ({left:?})"
|
||||
);
|
||||
assert!(
|
||||
right.contains("500ms") && right.contains("2000ms"),
|
||||
"width {width}: lost the intervals ({right:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder from the design: key hints, then badges, then the prefix, then the
|
||||
/// interval labels, then the hostname itself.
|
||||
#[test]
|
||||
fn detail_is_dropped_in_priority_order() {
|
||||
let s = state(Some("cachyos-gaming"));
|
||||
|
||||
let (left, right) = build_header(s, 120);
|
||||
assert_eq!(
|
||||
left,
|
||||
"socktop — host: cachyos-gaming | 🔒✗ TLS | (a: about, h: help, q: quit)"
|
||||
);
|
||||
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||
|
||||
// Key hints go first.
|
||||
let (left, _) = build_header(s, 80);
|
||||
assert_eq!(left, "socktop — host: cachyos-gaming | 🔒✗ TLS");
|
||||
|
||||
// Then the badges.
|
||||
let (left, _) = build_header(s, 70);
|
||||
assert_eq!(left, "socktop — host: cachyos-gaming");
|
||||
|
||||
// Then the prefix, leaving the bare hostname.
|
||||
let (left, right) = build_header(s, 50);
|
||||
assert_eq!(left, "cachyos-gaming");
|
||||
assert_eq!(right, "⏱ 500ms metrics | 2000ms procs");
|
||||
|
||||
// Then the interval labels.
|
||||
let (left, right) = build_header(s, 34);
|
||||
assert_eq!(left, "cachyos-gaming");
|
||||
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||
|
||||
// Only then is the hostname itself shortened.
|
||||
// 30 columns - 16 for the short intervals - 2 gap leaves 12 for the hostname.
|
||||
let (left, right) = build_header(s, 30);
|
||||
assert_eq!(left, "cachyos-gam…");
|
||||
assert_eq!(right, "⏱ 500ms | 2000ms");
|
||||
}
|
||||
|
||||
/// A long hostname must not push the intervals off the row.
|
||||
#[test]
|
||||
fn a_long_hostname_is_shortened_rather_than_winning_the_row() {
|
||||
let long = "a-very-long-hostname-that-will-not-fit-anywhere";
|
||||
for width in 30..=100u16 {
|
||||
let (left, right) = build_header(state(Some(long)), width);
|
||||
assert!(!right.is_empty(), "width {width}: intervals were dropped");
|
||||
assert!(cols(&left) + cols(&right) + GAP <= width, "width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Widths too small for both: the hostname is the last thing standing.
|
||||
#[test]
|
||||
fn hostname_is_the_final_survivor() {
|
||||
let (left, right) = build_header(state(Some("cachyos-gaming")), 20);
|
||||
assert!(right.is_empty(), "intervals should have been dropped");
|
||||
assert!(!left.is_empty());
|
||||
assert!(cols(&left) <= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_and_token_badges_appear_when_there_is_room() {
|
||||
let s = HeaderState {
|
||||
hostname: Some("host"),
|
||||
is_tls: true,
|
||||
has_token: true,
|
||||
metrics_ms: 500,
|
||||
procs_ms: 2000,
|
||||
};
|
||||
let (left, _) = build_header(s, 200);
|
||||
assert!(left.contains("🔒 TLS"), "{left}");
|
||||
assert!(left.contains("🔑 token"), "{left}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_hostname_reads_as_connecting() {
|
||||
let (left, _) = build_header(state(None), 120);
|
||||
assert!(left.contains("connecting"), "{left}");
|
||||
let intervals_line = Line::from(Span::raw(intervals));
|
||||
f.render_widget(Paragraph::new(intervals_line), right_area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +2,8 @@
|
||||
|
||||
pub mod cpu;
|
||||
pub mod disks;
|
||||
pub mod fit;
|
||||
pub mod gpu;
|
||||
pub mod header;
|
||||
pub mod layout;
|
||||
pub mod mem;
|
||||
pub mod modal;
|
||||
pub mod modal_connection;
|
||||
|
||||
@@ -41,12 +41,11 @@ impl ModalManager {
|
||||
])
|
||||
.split(area);
|
||||
let block = Block::default()
|
||||
.title(
|
||||
Line::from(ICON_WARNING_TITLE).style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
.title(ICON_WARNING_TITLE)
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||
|
||||
@@ -60,7 +60,6 @@ impl ModalManager {
|
||||
main_chunks[0],
|
||||
&details.process,
|
||||
data.history.cpu,
|
||||
data.history.cpu_sum,
|
||||
);
|
||||
|
||||
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
||||
@@ -420,7 +419,7 @@ impl ModalManager {
|
||||
)
|
||||
.header(header)
|
||||
.block(block)
|
||||
.row_highlight_style(Style::default());
|
||||
.highlight_style(Style::default());
|
||||
|
||||
f.render_widget(table, area);
|
||||
|
||||
@@ -565,13 +564,8 @@ impl ModalManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation
|
||||
// instead of `plot_height` inner Vec<char>s like the old version did.
|
||||
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;
|
||||
};
|
||||
// Create a 2D grid to represent the plot
|
||||
let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
|
||||
|
||||
// Plot main process
|
||||
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,
|
||||
);
|
||||
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)
|
||||
@@ -593,13 +587,13 @@ impl ModalManager {
|
||||
);
|
||||
|
||||
if thread_x < plot_width && thread_y < plot_height {
|
||||
let ch = cell(&plot_grid, thread_x, thread_y);
|
||||
let next = match ch {
|
||||
' ' => '○',
|
||||
'○' => '◎',
|
||||
_ => '◉',
|
||||
};
|
||||
put(&mut plot_grid, thread_x, thread_y, next);
|
||||
if plot_grid[thread_y][thread_x] == ' ' {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,34 +608,28 @@ impl ModalManager {
|
||||
);
|
||||
|
||||
if child_x < plot_width && child_y < plot_height {
|
||||
let ch = cell(&plot_grid, child_x, child_y);
|
||||
let next = if ch == ' ' { '•' } else { '◉' };
|
||||
put(&mut plot_grid, child_x, child_y, next);
|
||||
if plot_grid[child_y][child_x] == ' ' {
|
||||
plot_grid[child_y][child_x] = '•'; // Child process marker
|
||||
} else {
|
||||
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
|
||||
// labels + axis title + (top) Y-axis title + legend + spacing.
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
|
||||
// Render the plot
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Y-axis labels and plot content
|
||||
let mut row_buf = String::with_capacity(plot_width);
|
||||
for y in 0..plot_height {
|
||||
let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64));
|
||||
// 4-char fixed-width label so the axis doesn't shift as digits change.
|
||||
// Add Y-axis labels and plot content
|
||||
for (i, row) in plot_grid.iter().enumerate() {
|
||||
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
|
||||
// Always format with 4 characters width, right-aligned, to prevent axis shifting
|
||||
let y_label = if y_value >= 100.0 {
|
||||
format!("{y_value:>4.0}")
|
||||
} else {
|
||||
format!("{y_value:>4.1}")
|
||||
};
|
||||
|
||||
// Build the row's char slice into a reusable String buffer.
|
||||
row_buf.clear();
|
||||
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);
|
||||
let plot_content: String = row.iter().collect();
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(y_label, Style::default()),
|
||||
@@ -845,7 +833,6 @@ impl ModalManager {
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
cpu_history: &std::collections::VecDeque<f32>,
|
||||
cpu_history_sum: f32,
|
||||
) {
|
||||
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
||||
let top_chunks = Layout::default()
|
||||
@@ -856,7 +843,7 @@ impl ModalManager {
|
||||
])
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -866,7 +853,6 @@ impl ModalManager {
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
cpu_history: &std::collections::VecDeque<f32>,
|
||||
cpu_history_sum: f32,
|
||||
) {
|
||||
// Normalize CPU to 0-100% by dividing by thread count
|
||||
// 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() {
|
||||
0.0
|
||||
} 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}%)");
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ use std::time::Instant;
|
||||
/// History data for process metrics rendering
|
||||
pub struct ProcessHistoryData<'a> {
|
||||
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 io_read: &'a std::collections::VecDeque<u64>,
|
||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||
|
||||
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
hist: &mut VecDeque<u64>,
|
||||
hist: &VecDeque<u64>,
|
||||
color: Color,
|
||||
) {
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
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()
|
||||
.block(
|
||||
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string()),
|
||||
)
|
||||
.data(slice)
|
||||
.data(&data)
|
||||
.style(Style::default().fg(color));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
|
||||
+187
-613
@@ -5,8 +5,8 @@ use ratatui::style::Modifier;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Table},
|
||||
};
|
||||
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,
|
||||
SB_THUMB, SB_TRACK,
|
||||
};
|
||||
use crate::ui::util::human;
|
||||
|
||||
/// Simple fuzzy matching: returns true if all characters in needle appear in
|
||||
/// 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).
|
||||
/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
|
||||
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
|
||||
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
|
||||
let haystack_lower = haystack.to_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) {
|
||||
return false;
|
||||
}
|
||||
@@ -34,37 +35,36 @@ fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first
|
||||
/// and reused across calls so callers can amortize the allocation. This is
|
||||
/// the underlying helper for the App-side cached slice.
|
||||
pub fn fill_filtered_sorted_indices(
|
||||
/// Get filtered and sorted process indices based on search query and sort order
|
||||
pub fn get_filtered_sorted_indices(
|
||||
metrics: &Metrics,
|
||||
search_query: &str,
|
||||
sort_by: ProcSortBy,
|
||||
out: &mut Vec<usize>,
|
||||
) {
|
||||
out.clear();
|
||||
out.reserve(metrics.top_processes.len());
|
||||
if search_query.is_empty() {
|
||||
out.extend(0..metrics.top_processes.len());
|
||||
) -> Vec<usize> {
|
||||
// Filter processes by search query (fuzzy match)
|
||||
let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
|
||||
(0..metrics.top_processes.len()).collect()
|
||||
} else {
|
||||
out.extend(
|
||||
(0..metrics.top_processes.len())
|
||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
|
||||
);
|
||||
}
|
||||
(0..metrics.top_processes.len())
|
||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Sort filtered rows
|
||||
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 bb = metrics.top_processes[b].cpu_usage;
|
||||
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 bb = metrics.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
|
||||
filtered_idxs
|
||||
}
|
||||
|
||||
/// Parameters for drawing the top processes table
|
||||
@@ -76,16 +76,6 @@ pub struct ProcessDisplayParams<'a> {
|
||||
pub selected_process_index: Option<usize>,
|
||||
pub search_query: &'a str,
|
||||
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)]
|
||||
@@ -95,122 +85,14 @@ pub enum ProcSortBy {
|
||||
MemDesc,
|
||||
}
|
||||
|
||||
/// Pre-formatted strings for one row of the process table. Built once per
|
||||
/// `Processes` poll (cadence ~2s) and reused by every draw frame in between
|
||||
/// so the diff renderer can suppress repaints when nothing changed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedRow {
|
||||
pub pid_str: String,
|
||||
pub cpu_str: String,
|
||||
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))
|
||||
}
|
||||
}
|
||||
// Keep the original header widths here so drawing and hit-testing match.
|
||||
const COLS: [Constraint; 5] = [
|
||||
Constraint::Length(8), // PID
|
||||
Constraint::Percentage(40), // Name
|
||||
Constraint::Length(8), // CPU %
|
||||
Constraint::Length(12), // Mem
|
||||
Constraint::Length(8), // Mem %
|
||||
];
|
||||
|
||||
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) {
|
||||
// Draw outer block and title
|
||||
@@ -277,7 +159,8 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
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
|
||||
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 show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||
|
||||
// Use the App-side cache when available so we avoid allocating ~5 strings
|
||||
// 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();
|
||||
// Build visible rows
|
||||
let total_mem_bytes = mm.mem_total.max(1);
|
||||
let peak_cpu = if cache_ok {
|
||||
params.peak_cpu
|
||||
} else {
|
||||
mm.top_processes
|
||||
.iter()
|
||||
.map(|p| p.cpu_usage)
|
||||
.fold(0.0_f32, f32::max)
|
||||
};
|
||||
|
||||
let columns = ProcColumns::for_width(content.width);
|
||||
let peak_cpu = mm
|
||||
.top_processes
|
||||
.iter()
|
||||
.map(|p| p.cpu_usage)
|
||||
.fold(0.0_f32, f32::max);
|
||||
|
||||
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
|
||||
let p = &mm.top_processes[ix];
|
||||
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
|
||||
|
||||
let (
|
||||
cpu_val,
|
||||
mem_pct,
|
||||
pid_span,
|
||||
name_span,
|
||||
cpu_span_text,
|
||||
mem_span_text,
|
||||
mem_pct_span_text,
|
||||
) = if cache_ok {
|
||||
let row = ¶ms.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_val = p.cpu_usage;
|
||||
let cpu_fg = match cpu_val {
|
||||
x if x < 25.0 => Color::Green,
|
||||
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()
|
||||
};
|
||||
|
||||
// Check if this process is selected - prioritize PID matching
|
||||
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
|
||||
selected_pid == p.pid
|
||||
} 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 {
|
||||
false
|
||||
};
|
||||
|
||||
// Apply selection highlighting
|
||||
if is_selected {
|
||||
emphasis = emphasis
|
||||
.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);
|
||||
}
|
||||
|
||||
let mut cells = Vec::with_capacity(5);
|
||||
if columns.pid {
|
||||
cells.push(
|
||||
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
|
||||
);
|
||||
}
|
||||
cells.push(ratatui::widgets::Cell::from(name_span));
|
||||
if columns.cpu {
|
||||
cells.push(
|
||||
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
|
||||
.style(Style::default().fg(cpu_fg)),
|
||||
);
|
||||
}
|
||||
if columns.mem {
|
||||
cells.push(ratatui::widgets::Cell::from(Span::raw(mem_span_text)));
|
||||
}
|
||||
if columns.mem_pct {
|
||||
cells.push(
|
||||
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
|
||||
.style(Style::default().fg(mem_fg)),
|
||||
);
|
||||
}
|
||||
ratatui::widgets::Row::new(cells).style(emphasis)
|
||||
let cpu_str = fmt_cpu_pct(cpu_val);
|
||||
|
||||
ratatui::widgets::Row::new(vec![
|
||||
ratatui::widgets::Cell::from(p.pid.to_string())
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
ratatui::widgets::Cell::from(p.name.clone()),
|
||||
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
|
||||
ratatui::widgets::Cell::from(human(p.mem_bytes)),
|
||||
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
|
||||
.style(Style::default().fg(mem_fg)),
|
||||
])
|
||||
.style(emphasis)
|
||||
});
|
||||
|
||||
// Header with sort indicator
|
||||
@@ -409,30 +240,16 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
ProcSortBy::MemDesc => "Mem •",
|
||||
_ => "Mem",
|
||||
};
|
||||
let mut header_cells = Vec::with_capacity(5);
|
||||
if columns.pid {
|
||||
header_cells.push("PID");
|
||||
}
|
||||
header_cells.push("Name");
|
||||
if columns.cpu {
|
||||
header_cells.push(cpu_hdr);
|
||||
}
|
||||
if columns.mem {
|
||||
header_cells.push(mem_hdr);
|
||||
}
|
||||
if columns.mem_pct {
|
||||
header_cells.push("Mem %");
|
||||
}
|
||||
let header = ratatui::widgets::Row::new(header_cells).style(
|
||||
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
// Render table inside content area (no borders here; outer block already drawn)
|
||||
let table = Table::new(rows_iter, columns.constraints())
|
||||
let table = Table::new(rows_iter, COLS.to_vec())
|
||||
.header(header)
|
||||
.column_spacing(COL_SPACING);
|
||||
.column_spacing(1);
|
||||
f.render_widget(table, content);
|
||||
|
||||
// Draw tooltip if a process is selected
|
||||
@@ -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 {
|
||||
x: inner.x + inner.width.saturating_sub(1),
|
||||
y: inner.y,
|
||||
width: 1,
|
||||
height: inner.height,
|
||||
};
|
||||
let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off_for_bar > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.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_for_bar).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
if scroll_area.height >= 3 {
|
||||
let track = (scroll_area.height - 2) as usize;
|
||||
let total = total_rows.max(1);
|
||||
let view = viewport_rows.clamp(1, total);
|
||||
let max_off = total.saturating_sub(view);
|
||||
|
||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||
let thumb_top = if max_off == 0 {
|
||||
0
|
||||
} else {
|
||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
||||
};
|
||||
|
||||
// Build lines: top arrow, track (with thumb), bottom arrow
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
|
||||
lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW))));
|
||||
for i in 0..track {
|
||||
if i >= thumb_top && i < thumb_top + thumb_len {
|
||||
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_cpu_pct(v: f32) -> String {
|
||||
format!("{:>5.1}", v.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
||||
/// Parameters for process key event handling
|
||||
pub struct ProcessKeyParams<'a> {
|
||||
@@ -506,7 +339,8 @@ pub struct ProcessKeyParams<'a> {
|
||||
pub selected_process_index: &'a mut Option<usize>,
|
||||
pub key: crossterm::event::KeyEvent,
|
||||
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
|
||||
@@ -522,71 +356,87 @@ pub fn processes_handle_key(
|
||||
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
||||
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 {
|
||||
KeyCode::Up => {
|
||||
move_selection(
|
||||
-1,
|
||||
params.selected_process_index,
|
||||
params.selected_process_pid,
|
||||
params.metrics,
|
||||
params.filtered_indices,
|
||||
);
|
||||
true
|
||||
// Navigate through filtered and sorted results
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
if idxs.is_empty() {
|
||||
// No filtered results, clear selection
|
||||
*params.selected_process_index = None;
|
||||
*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 => {
|
||||
move_selection(
|
||||
1,
|
||||
params.selected_process_index,
|
||||
params.selected_process_pid,
|
||||
params.metrics,
|
||||
params.filtered_indices,
|
||||
);
|
||||
true
|
||||
// Navigate through filtered and sorted results
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
if idxs.is_empty() {
|
||||
// No filtered results, clear selection
|
||||
*params.selected_process_index = None;
|
||||
*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')
|
||||
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
|
||||
{
|
||||
*params.selected_process_pid = None;
|
||||
*params.selected_process_index = None;
|
||||
true
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => {
|
||||
// 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_index = None;
|
||||
true // Handled
|
||||
} else {
|
||||
false // No selection to clear
|
||||
}
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => false,
|
||||
KeyCode::Enter => {
|
||||
// Signal that Enter was pressed with a selection
|
||||
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;
|
||||
|
||||
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
// Split the header the same way the draw path did, so a click lands on the
|
||||
// column actually on screen even when PID has been dropped.
|
||||
let columns = ProcColumns::for_width(header_area.width);
|
||||
// Split header into the same columns
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(columns.constraints())
|
||||
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
|
||||
.constraints(COLS.to_vec())
|
||||
.split(header_area);
|
||||
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
|
||||
&& mouse.column >= cpu.x
|
||||
&& mouse.column < cpu.x + cpu.width
|
||||
{
|
||||
if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
|
||||
return Some(ProcSortBy::CpuDesc);
|
||||
}
|
||||
if let Some(mem) = columns.mem_index().map(|i| cols[i])
|
||||
&& mouse.column >= mem.x
|
||||
&& mouse.column < mem.x + mem.width
|
||||
{
|
||||
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
|
||||
return Some(ProcSortBy::MemDesc);
|
||||
}
|
||||
}
|
||||
@@ -685,11 +526,8 @@ pub struct ProcessMouseParams<'a> {
|
||||
pub area: Rect,
|
||||
pub total_rows: usize,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
/// True when the on-screen search box is currently being drawn (active
|
||||
/// edit mode OR a non-empty filter is showing). The caller computes this
|
||||
/// from the same condition as the draw path.
|
||||
pub search_box_visible: bool,
|
||||
pub filtered_indices: &'a [usize],
|
||||
pub sort_by: ProcSortBy,
|
||||
pub search_query: &'a str,
|
||||
}
|
||||
|
||||
/// 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!
|
||||
// If a search box is being drawn (active edit mode OR a filter showing),
|
||||
// content starts 3 rows below.
|
||||
let content_start_y = if params.search_box_visible {
|
||||
inner.y + 3
|
||||
} else {
|
||||
inner.y
|
||||
};
|
||||
// If search is active or query exists, content starts after search box (3 lines)
|
||||
let search_active = !params.search_query.is_empty();
|
||||
let content_start_y = if search_active { inner.y + 3 } else { inner.y };
|
||||
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
@@ -721,7 +555,7 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner
|
||||
.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)
|
||||
@@ -753,24 +587,15 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
&& params.mouse.column < header_area.x + header_area.width;
|
||||
|
||||
if inside_header && matches!(params.mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
// Split the header the same way the draw path did, so a click lands on the
|
||||
// column actually on screen even when PID has been dropped.
|
||||
let columns = ProcColumns::for_width(header_area.width);
|
||||
// Split header into the same columns
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(columns.constraints())
|
||||
.spacing(COL_SPACING) // must match Table::column_spacing in the draw path
|
||||
.constraints(COLS.to_vec())
|
||||
.split(header_area);
|
||||
if let Some(cpu) = columns.cpu_index().map(|i| cols[i])
|
||||
&& params.mouse.column >= cpu.x
|
||||
&& params.mouse.column < cpu.x + cpu.width
|
||||
{
|
||||
if params.mouse.column >= cols[2].x && params.mouse.column < cols[2].x + cols[2].width {
|
||||
return Some(ProcSortBy::CpuDesc);
|
||||
}
|
||||
if let Some(mem) = columns.mem_index().map(|i| cols[i])
|
||||
&& params.mouse.column >= mem.x
|
||||
&& params.mouse.column < mem.x + mem.width
|
||||
{
|
||||
if params.mouse.column >= cols[3].x && params.mouse.column < cols[3].x + cols[3].width {
|
||||
return Some(ProcSortBy::MemDesc);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
// Find the actual process using the same filtering/sorting logic as the drawing code
|
||||
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;
|
||||
if visible_process_position < idxs.len() {
|
||||
let actual_process_index = idxs[visible_process_position];
|
||||
@@ -807,258 +636,3 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod column_tests {
|
||||
use super::*;
|
||||
use ratatui::layout::{Direction, Layout, Rect};
|
||||
|
||||
fn name_width(w: u16) -> u16 {
|
||||
let c = ProcColumns::for_width(w);
|
||||
let rects = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(c.constraints())
|
||||
.spacing(COL_SPACING)
|
||||
.split(Rect::new(0, 0, w, 1));
|
||||
rects[usize::from(c.pid)].width
|
||||
}
|
||||
|
||||
/// The complaint this fixes: on a narrow pane the Name column was the first thing to
|
||||
/// disappear, leaving a table of numbers with nothing to identify the process. Name
|
||||
/// must now be the last column standing, at every width that can render anything.
|
||||
#[test]
|
||||
fn name_is_never_the_column_that_gets_dropped() {
|
||||
for width in NAME_MIN_W..=200u16 {
|
||||
assert!(
|
||||
name_width(width) >= 1,
|
||||
"width {width}: Name was squeezed to nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns are shed in reverse priority order, so a narrower pane can never show a
|
||||
/// column that a wider one hid.
|
||||
#[test]
|
||||
fn columns_are_shed_in_priority_order() {
|
||||
for width in 0..=200u16 {
|
||||
let c = ProcColumns::for_width(width);
|
||||
assert!(!c.mem_pct || c.pid, "width {width}: Mem % outlived PID");
|
||||
assert!(!c.pid || c.mem, "width {width}: PID outlived Mem");
|
||||
assert!(!c.mem || c.cpu, "width {width}: Mem outlived CPU %");
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns come back as the pane widens and never flap.
|
||||
#[test]
|
||||
fn columns_are_monotonic_in_width() {
|
||||
let mut prev = ProcColumns::for_width(0);
|
||||
for width in 1..=200u16 {
|
||||
let c = ProcColumns::for_width(width);
|
||||
for (was, now, name) in [
|
||||
(prev.cpu, c.cpu, "CPU %"),
|
||||
(prev.mem, c.mem, "Mem"),
|
||||
(prev.pid, c.pid, "PID"),
|
||||
(prev.mem_pct, c.mem_pct, "Mem %"),
|
||||
] {
|
||||
assert!(!was || now, "width {width}: {name} vanished as it widened");
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiers, from a comfortable pane down to a very narrow one.
|
||||
#[test]
|
||||
fn narrow_panes_shed_columns_in_order() {
|
||||
let full = ProcColumns::for_width(48);
|
||||
assert_eq!(full.constraints().len(), 5);
|
||||
assert!(full.pid && full.cpu && full.mem && full.mem_pct);
|
||||
|
||||
// Mem % goes first.
|
||||
let c = ProcColumns::for_width(45);
|
||||
assert!(c.pid && c.mem && !c.mem_pct);
|
||||
|
||||
// Then PID.
|
||||
let c = ProcColumns::for_width(35);
|
||||
assert!(!c.pid && c.cpu && c.mem);
|
||||
|
||||
// Then Mem, leaving the name and its CPU load.
|
||||
let c = ProcColumns::for_width(20);
|
||||
assert!(!c.mem && c.cpu);
|
||||
assert_eq!(c.constraints().len(), 2);
|
||||
|
||||
// At the floor, just the name.
|
||||
let c = ProcColumns::for_width(10);
|
||||
assert!(!c.cpu && !c.mem);
|
||||
assert_eq!(c.constraints().len(), 1);
|
||||
}
|
||||
|
||||
/// Regression guard for the old behaviour: a 130-column terminal gives the process
|
||||
/// pane ~48 columns, and every column still fits there.
|
||||
#[test]
|
||||
fn a_wide_terminal_keeps_the_full_table() {
|
||||
assert_eq!(ProcColumns::for_width(48).constraints().len(), 5);
|
||||
}
|
||||
|
||||
/// Sort clicks are resolved by index, so those indices must track the columns that
|
||||
/// are actually rendered — otherwise clicking "CPU %" would sort by Mem.
|
||||
#[test]
|
||||
fn sort_indices_follow_the_rendered_columns() {
|
||||
let wide = ProcColumns::for_width(48);
|
||||
assert_eq!(wide.cpu_index(), Some(2)); // PID, Name, CPU %
|
||||
assert_eq!(wide.mem_index(), Some(3));
|
||||
|
||||
let narrow = ProcColumns::for_width(35);
|
||||
assert_eq!(narrow.cpu_index(), Some(1)); // Name, CPU %
|
||||
assert_eq!(narrow.mem_index(), Some(2));
|
||||
|
||||
// A column that is not rendered has no index to click.
|
||||
let tiny = ProcColumns::for_width(10);
|
||||
assert_eq!(tiny.cpu_index(), None);
|
||||
assert_eq!(tiny.mem_index(), None);
|
||||
|
||||
// Whatever the width, any index returned is inside the rendered set.
|
||||
for width in 0..=200u16 {
|
||||
let c = ProcColumns::for_width(width);
|
||||
let n = c.constraints().len();
|
||||
for i in [c.cpu_index(), c.mem_index()].into_iter().flatten() {
|
||||
assert!(i < n, "width {width}: index {i} outside {n} columns");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name takes the slack, so it grows with the pane instead of being pinned to a
|
||||
/// percentage that the fixed columns can crush.
|
||||
#[test]
|
||||
fn name_absorbs_the_leftover_width() {
|
||||
assert!(
|
||||
name_width(80) > name_width(60),
|
||||
"Name did not grow with the pane"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod click_tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use socktop_connector::{Metrics, ProcessInfo};
|
||||
|
||||
fn metrics() -> Metrics {
|
||||
Metrics {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,36 +73,3 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
|
||||
);
|
||||
assert!(text3.contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_flag_documented_and_accepted() {
|
||||
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||
let out = Command::new(exe)
|
||||
.args(["--compact", "--help"])
|
||||
.output()
|
||||
.expect("run socktop --compact --help");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"socktop --compact --help did not succeed"
|
||||
);
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.contains("--compact"),
|
||||
"help text missing --compact\n{text}"
|
||||
);
|
||||
|
||||
// The flag must not be mistaken for the positional URL argument.
|
||||
let out2 = Command::new(exe)
|
||||
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
||||
.output()
|
||||
.expect("run socktop --compact --dry-run");
|
||||
assert!(
|
||||
out2.status.success(),
|
||||
"socktop --compact with a URL was rejected: {}",
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,27 +29,27 @@ cat <<EOF
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ socktop-agent has been installed successfully! │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ The systemd service has been installed but is NOT enabled by │
|
||||
│ default. To enable and start the service: │
|
||||
│ │
|
||||
│ sudo systemctl enable --now socktop-agent │
|
||||
│ │
|
||||
│ To start without enabling on boot: │
|
||||
│ │
|
||||
│ sudo systemctl start socktop-agent │
|
||||
│ │
|
||||
│ To check service status: │
|
||||
│ │
|
||||
│ sudo systemctl status socktop-agent │
|
||||
│ │
|
||||
│ Default settings: │
|
||||
│ - Port: 3000 (use -p or --port to change) │
|
||||
│ - SSL/TLS: disabled (use --enableSSL to enable) │
|
||||
│ │
|
||||
│ For more information, see: │
|
||||
│ /usr/share/doc/socktop_agent/README.md │
|
||||
│ │
|
||||
│ │
|
||||
│ The systemd service has been installed but is NOT enabled by │
|
||||
│ default. To enable and start the service: │
|
||||
│ │
|
||||
│ sudo systemctl enable --now socktop-agent │
|
||||
│ │
|
||||
│ To start without enabling on boot: │
|
||||
│ │
|
||||
│ sudo systemctl start socktop-agent │
|
||||
│ │
|
||||
│ To check service status: │
|
||||
│ │
|
||||
│ sudo systemctl status socktop-agent │
|
||||
│ │
|
||||
│ Default settings: │
|
||||
│ - Port: 3000 (use -p or --port to change) │
|
||||
│ - SSL/TLS: disabled (use --enableSSL to enable) │
|
||||
│ │
|
||||
│ For more information, see: │
|
||||
│ /usr/share/doc/socktop_agent/README.md │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EOF
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go.
|
||||
// Returns (0, 0) if the file can't be read.
|
||||
//
|
||||
// We use `rfind(')')` to step past the `comm` field, which can contain
|
||||
// arbitrary characters (including spaces and parens), then index the
|
||||
// post-comm fields by position. This is the same trick `read_proc_jiffies`
|
||||
// uses below — `split_whitespace().collect::<Vec<_>>()` from the start of
|
||||
// the file would mis-parse process names with spaces, and also wastes an
|
||||
// allocation per call. Two callers used to read this file twice (once for
|
||||
// user, once for system); now it's one syscall per detailed-process record.
|
||||
// Helper functions to get CPU time from /proc/stat on Linux
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_cpu_times_ms(pid: u32) -> (u64, u64) {
|
||||
let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
return (0, 0);
|
||||
};
|
||||
let Some(rpar) = s.rfind(')') else {
|
||||
return (0, 0);
|
||||
};
|
||||
let Some(after) = s.get(rpar + 2..) else {
|
||||
return (0, 0);
|
||||
};
|
||||
let mut it = after.split_whitespace();
|
||||
// Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid,
|
||||
// flags, minflt, cminflt, majflt, cmajflt, utime, stime, ...
|
||||
// utime is offset 11; stime follows.
|
||||
let utime = it.nth(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
// 1 tick = 10ms at 100 Hz (USER_HZ).
|
||||
(utime * 10, stime * 10)
|
||||
fn get_cpu_time_user(pid: u32) -> u64 {
|
||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
||||
if fields.len() > 13 {
|
||||
// Field 13 (0-indexed) is utime (user CPU time in clock ticks)
|
||||
if let Ok(utime) = fields[13].parse::<u64>() {
|
||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
||||
return utime * 10; // 1 tick = 10ms at 100 Hz
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_cpu_time_system(pid: u32) -> u64 {
|
||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
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"))]
|
||||
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
|
||||
(0, 0)
|
||||
fn get_cpu_time_user(_pid: u32) -> u64 {
|
||||
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)
|
||||
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
|
||||
const TTL: Duration = Duration::from_millis(1500);
|
||||
struct TempCache {
|
||||
@@ -125,32 +89,6 @@ struct TempCache {
|
||||
}
|
||||
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 {
|
||||
at: Option<Instant>,
|
||||
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).
|
||||
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;
|
||||
if cache.is_fresh(ttl)
|
||||
@@ -259,7 +202,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
} else if temp_enabled() {
|
||||
let val = {
|
||||
let mut components = state.components.lock().await;
|
||||
refresh_components_if_stale(&mut components);
|
||||
components.refresh(false);
|
||||
components.iter().find_map(|c| {
|
||||
let l = c.label().to_ascii_lowercase();
|
||||
if l.contains("cpu")
|
||||
@@ -293,19 +236,12 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
});
|
||||
let mut cache = cache.lock().unwrap();
|
||||
|
||||
// Detect a topology change without allocating: compare lengths first,
|
||||
// then zip and walk. Only on a real diff do we materialize the new
|
||||
// names list. Was: `nets.keys().map(to_string).collect::<Vec<_>>()`
|
||||
// every tick — a fresh Vec<String> just to compare.
|
||||
let topology_changed = cache.names.len() != nets.keys().count()
|
||||
|| cache
|
||||
.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()));
|
||||
// Collect current network names
|
||||
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
|
||||
|
||||
// Update cached network names if they changed
|
||||
if cache.names != current_names {
|
||||
cache.names = current_names;
|
||||
}
|
||||
|
||||
// Reuse NetworkInfo objects
|
||||
@@ -383,7 +319,11 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
|
||||
// Cached disks
|
||||
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;
|
||||
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
|
||||
let disk_temps = {
|
||||
let mut components = state.components.lock().await;
|
||||
// Shared TTL-gated refresh: avoids paying the hwmon scan twice when
|
||||
// both endpoints converge in the same second.
|
||||
refresh_components_if_stale(&mut components);
|
||||
components.refresh(true); // true = refresh values, not just the list
|
||||
|
||||
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.
|
||||
#[cfg(target_os = "linux")]
|
||||
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;
|
||||
if cache.is_fresh(ttl)
|
||||
@@ -643,24 +586,13 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
// Reuse shared System to avoid reallocation. We only need name + memory
|
||||
// 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.
|
||||
// Reuse shared System to avoid reallocation; refresh processes fully.
|
||||
let mut sys_guard = state.sys.lock().await;
|
||||
let sys = &mut *sys_guard;
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::All,
|
||||
false,
|
||||
ProcessRefreshKind::nothing().with_memory().without_tasks(),
|
||||
ProcessRefreshKind::everything().without_tasks(),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Compute deltas vs last sample. We hold the proc_cpu lock for the whole
|
||||
// collection below so we can read+update the per-pid name cache in one
|
||||
// critical section.
|
||||
let mut tracker = state.proc_cpu.lock().await;
|
||||
let last_total = tracker.last_total;
|
||||
// Move the old per-pid jiffies map out for delta computation.
|
||||
let mut last_map = std::mem::take(&mut tracker.last_per_pid);
|
||||
tracker.last_total = total_now;
|
||||
// Compute deltas vs last sample
|
||||
let (last_total, mut last_map) = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut t = state.proc_cpu.lock().await;
|
||||
let lt = t.last_total;
|
||||
let lm = std::mem::take(&mut t.last_per_pid);
|
||||
t.last_total = total_now;
|
||||
t.last_per_pid = current.clone();
|
||||
(lt, lm)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _: u64 = total_now; // silence unused warning
|
||||
(0u64, HashMap::new())
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve a name through the per-pid cache. Allocates only on miss.
|
||||
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.
|
||||
// On first run or if total delta is tiny, report zeros
|
||||
if last_total == 0 || total_now <= last_total {
|
||||
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
|
||||
for p in sys.processes().values() {
|
||||
let pid = p.pid().as_u32();
|
||||
let name = resolve_name(&mut tracker, pid, p);
|
||||
procs.push(ProcessInfo {
|
||||
pid,
|
||||
name,
|
||||
let procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| ProcessInfo {
|
||||
pid: p.pid().as_u32(),
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
cpu_usage: 0.0,
|
||||
mem_bytes: p.memory(),
|
||||
});
|
||||
}
|
||||
// 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));
|
||||
})
|
||||
.collect();
|
||||
return ProcessesPayload {
|
||||
process_count: total_count,
|
||||
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 mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
|
||||
for p in sys.processes().values() {
|
||||
let pid = p.pid().as_u32();
|
||||
let now = current.get(&pid).copied().unwrap_or(0);
|
||||
let prev = last_map.remove(&pid).unwrap_or(0);
|
||||
let du = now.saturating_sub(prev) as f32;
|
||||
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
|
||||
let name = resolve_name(&mut tracker, pid, p);
|
||||
procs.push(ProcessInfo {
|
||||
pid,
|
||||
name,
|
||||
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).
|
||||
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 procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| {
|
||||
let pid = p.pid().as_u32();
|
||||
let now = current.get(&pid).copied().unwrap_or(0);
|
||||
let prev = last_map.remove(&pid).unwrap_or(0);
|
||||
let du = now.saturating_sub(prev) as f32;
|
||||
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
|
||||
ProcessInfo {
|
||||
pid,
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
cpu_usage: cpu,
|
||||
mem_bytes: p.memory(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = ProcessesPayload {
|
||||
process_count: total_count,
|
||||
@@ -839,17 +749,13 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
// .unwrap_or(std::cmp::Ordering::Equal)
|
||||
// });
|
||||
|
||||
// Clean up old process names cache when it grows too large.
|
||||
let cache_cleanup_threshold = name_cache_cleanup_threshold();
|
||||
// Clean up old process names cache when it grows too large
|
||||
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
|
||||
|
||||
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
|
||||
// `now` is only consumed by the `tracing::debug!` below, so gate
|
||||
// the binding with the same cfg as its consumer. Without this,
|
||||
// a non-logging build (the default) emits an unused-variable
|
||||
// warning. The Linux CI doesn't catch it because this block lives
|
||||
// in the `#[cfg(not(target_os = "linux"))]` collect_processes_all —
|
||||
// the warning only surfaces on the Windows build matrix.
|
||||
#[cfg(feature = "logging")]
|
||||
let now = std::time::Instant::now();
|
||||
proc_cache
|
||||
.names
|
||||
@@ -905,94 +811,17 @@ fn enumerate_child_processes_lightweight(
|
||||
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
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
// Format: pid (comm) state ppid ... — comm can contain spaces/parens,
|
||||
// so we step past the closing paren first.
|
||||
// Format: pid (comm) state ppid ...
|
||||
// We need to handle process names with spaces/parentheses
|
||||
let ppid_start = stat.rfind(')')?;
|
||||
// After ") ": state, ppid, ... — ppid is the second field.
|
||||
stat[ppid_start + 1..]
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect();
|
||||
// After the closing paren: state ppid ...
|
||||
// Field 1 (0-indexed) is ppid
|
||||
fields.get(1)?.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Collect process information from /proc files
|
||||
@@ -1001,11 +830,8 @@ fn collect_process_info_from_proc(
|
||||
pid: u32,
|
||||
system: &sysinfo::System,
|
||||
) -> Option<DetailedProcessInfo> {
|
||||
// One read of /proc/{pid}/status gets us everything the detail endpoint
|
||||
// needs from it: memory (when not in sysinfo cache), Uid/Gid, Threads,
|
||||
// and State. The previous code opened this file twice per process record.
|
||||
let st = read_proc_status(pid)?;
|
||||
|
||||
// Try to get basic info from sysinfo if it's already loaded (cheap lookup)
|
||||
// Otherwise read from /proc directly
|
||||
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
|
||||
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
|
||||
(
|
||||
@@ -1015,13 +841,30 @@ fn collect_process_info_from_proc(
|
||||
proc.virtual_memory(),
|
||||
)
|
||||
} else {
|
||||
// Process not in sysinfo cache — derive name from /proc/{pid}/comm
|
||||
// and memory from the status read above.
|
||||
// Process not in sysinfo cache, read minimal info from /proc
|
||||
let name = fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||
.ok()?
|
||||
.trim()
|
||||
.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
|
||||
@@ -1030,21 +873,54 @@ fn collect_process_info_from_proc(
|
||||
.map(|s| s.replace('\0', " ").trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let uid = st.uid;
|
||||
let gid = st.gid;
|
||||
let thread_count = st.threads;
|
||||
let status = proc_state_label(st.state_ch).to_string();
|
||||
// Read status information
|
||||
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let mut uid = 0u32;
|
||||
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 stat_end = stat.rfind(')')?;
|
||||
// After ") ": state, ppid, ..., starttime — starttime is the 20th
|
||||
// post-comm field (index 19).
|
||||
stat[stat_end + 1..]
|
||||
.split_whitespace()
|
||||
.nth(19)?
|
||||
.parse::<u64>()
|
||||
.ok()?
|
||||
let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect();
|
||||
// Field 19 (0-indexed) is starttime in clock ticks since boot
|
||||
fields.get(19)?.parse::<u64>().ok()?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -1078,9 +954,6 @@ fn collect_process_info_from_proc(
|
||||
.ok()
|
||||
.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 {
|
||||
pid,
|
||||
name,
|
||||
@@ -1096,8 +969,8 @@ fn collect_process_info_from_proc(
|
||||
user_id: uid,
|
||||
group_id: gid,
|
||||
start_time,
|
||||
cpu_time_user,
|
||||
cpu_time_system,
|
||||
cpu_time_user: get_cpu_time_user(pid),
|
||||
cpu_time_system: get_cpu_time_system(pid),
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
working_directory,
|
||||
@@ -1186,24 +1059,22 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
||||
.trim()
|
||||
.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 Ok(stat_content) = fs::read_to_string(&stat_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Thread/comm names can contain spaces or parens, so step past the
|
||||
// last ')' before parsing post-comm fields. Post-comm offsets:
|
||||
// 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime
|
||||
let Some(rpar) = stat_content.rfind(')') else {
|
||||
// Parse stat file (similar format to process stat)
|
||||
// Fields: pid comm state ... utime stime ...
|
||||
let fields: Vec<&str> = stat_content.split_whitespace().collect();
|
||||
if fields.len() < 15 {
|
||||
continue;
|
||||
};
|
||||
let Some(after) = stat_content.get(rpar + 1..) else {
|
||||
continue;
|
||||
};
|
||||
let mut it = after.split_whitespace();
|
||||
let status = it
|
||||
.next()
|
||||
}
|
||||
|
||||
// Field 2 is state (R, S, D, Z, T, etc.)
|
||||
let status = fields
|
||||
.get(2)
|
||||
.and_then(|s| s.chars().next())
|
||||
.map(|c| match c {
|
||||
'R' => "Running",
|
||||
@@ -1218,9 +1089,16 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
// 10 fields between state and utime (ppid..cmajflt).
|
||||
let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
// Field 13 is utime (user CPU time in clock ticks)
|
||||
// Field 14 is stime (system CPU time in clock ticks)
|
||||
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)
|
||||
// 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 start_time = process.start_time();
|
||||
|
||||
// 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).
|
||||
// Read UID and GID directly from /proc/{pid}/status for accuracy
|
||||
#[cfg(target_os = "linux")]
|
||||
let (user_id, group_id) = read_proc_status(pid)
|
||||
.map(|s| (s.uid, s.gid))
|
||||
.unwrap_or((0, 0));
|
||||
let (user_id, group_id) =
|
||||
if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
||||
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"))]
|
||||
let (user_id, group_id) = (0, 0);
|
||||
@@ -1349,9 +1248,6 @@ pub async fn collect_process_metrics(
|
||||
// Collect thread information (Linux only)
|
||||
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
|
||||
let detailed_info = DetailedProcessInfo {
|
||||
pid,
|
||||
@@ -1368,8 +1264,8 @@ pub async fn collect_process_metrics(
|
||||
user_id,
|
||||
group_id,
|
||||
start_time,
|
||||
cpu_time_user,
|
||||
cpu_time_system,
|
||||
cpu_time_user: get_cpu_time_user(pid),
|
||||
cpu_time_system: get_cpu_time_system(pid),
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
working_directory,
|
||||
|
||||
@@ -17,10 +17,6 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
|
||||
pub struct ProcCpuTracker {
|
||||
pub last_total: 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"))]
|
||||
|
||||
+19
-28
@@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
Message::Text(ref text) if text == "get_processes" => {
|
||||
let payload = collect_processes_all(&state).await;
|
||||
|
||||
// Get cached buffers. The Vec capacity is preserved across
|
||||
// calls (with_capacity(512) seeds it, then we swap-back after
|
||||
// encode so the allocation outlives any single request).
|
||||
// Map to protobuf message
|
||||
// Get cached buffers
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
// Reuse process vector to build the list
|
||||
cache.processes_vec.clear();
|
||||
cache
|
||||
.processes_vec
|
||||
@@ -85,38 +85,29 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
mem_bytes: p.mem_bytes,
|
||||
}));
|
||||
|
||||
// Move the populated Vec into the proto, encode, then move it
|
||||
// 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 {
|
||||
let pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows: std::mem::take(&mut cache.processes_vec),
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
let encode_result = prost::Message::encode(&pb, &mut buf);
|
||||
// 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() {
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
// compress if large
|
||||
if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -37,9 +37,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
|
||||
Reference in New Issue
Block a user