chore: update ratatui from 0.28 to 0.30 (#33)
* chore: update ratatui from 0.28 to 0.30 * style: cargo fmt * fix: replace manual zero-guarded divisions with checked_div * fix: collapse nested if into match guard * style: cargo fmt * bump crossterm and optimize various types, remove stale code. * fix windows build * only show parent level processes on main tui
This commit is contained in:
Generated
+869
-201
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -24,8 +24,8 @@ serde_json = "1.0"
|
|||||||
sysinfo = "0.37"
|
sysinfo = "0.37"
|
||||||
|
|
||||||
# CLI UI
|
# CLI UI
|
||||||
ratatui = "0.28"
|
ratatui = "0.30"
|
||||||
crossterm = "0.27"
|
crossterm = "0.29"
|
||||||
|
|
||||||
# web server (remote-agent)
|
# web server (remote-agent)
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/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)."
|
||||||
|
|
||||||
+211
-125
@@ -29,11 +29,15 @@ use crate::ui::cpu::{
|
|||||||
};
|
};
|
||||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||||
use crate::ui::processes::{
|
use crate::ui::processes::{
|
||||||
ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection,
|
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
|
||||||
processes_handle_mouse_with_selection,
|
processes_handle_mouse_with_selection,
|
||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
|
disks::draw_disks,
|
||||||
|
gpu::draw_gpu,
|
||||||
|
header::{build_header_intervals, build_header_title, draw_header},
|
||||||
|
mem::draw_mem,
|
||||||
|
net::draw_net_spark,
|
||||||
swap::draw_swap,
|
swap::draw_swap,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,6 +50,15 @@ use socktop_connector::{
|
|||||||
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
||||||
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
||||||
|
|
||||||
|
/// Drop duplicate-name entries from a disks payload (the agent occasionally
|
||||||
|
/// reports a partition twice). Done once when fresh disk data arrives so the
|
||||||
|
/// per-frame draw path doesn't have to rebuild a HashSet.
|
||||||
|
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
|
||||||
|
let mut seen: std::collections::HashSet<String> =
|
||||||
|
std::collections::HashSet::with_capacity(disks.len());
|
||||||
|
disks.retain(|d| seen.insert(d.name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ConnectionState {
|
pub enum ConnectionState {
|
||||||
Connected,
|
Connected,
|
||||||
@@ -57,8 +70,9 @@ pub struct App {
|
|||||||
// Latest metrics + histories
|
// Latest metrics + histories
|
||||||
last_metrics: Option<Metrics>,
|
last_metrics: Option<Metrics>,
|
||||||
|
|
||||||
// CPU avg history (0..100)
|
// CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame
|
||||||
cpu_hist: VecDeque<u64>,
|
cpu_hist: VecDeque<u64>,
|
||||||
|
cpu_hist_sum: u64,
|
||||||
|
|
||||||
// Per-core history (0..100)
|
// Per-core history (0..100)
|
||||||
per_core_hist: PerCoreHistory,
|
per_core_hist: PerCoreHistory,
|
||||||
@@ -89,6 +103,17 @@ pub struct App {
|
|||||||
pub process_search_active: bool,
|
pub process_search_active: bool,
|
||||||
pub process_search_query: String,
|
pub process_search_query: String,
|
||||||
|
|
||||||
|
// Cached filtered + sorted process indices. Refreshed lazily when any of
|
||||||
|
// (metrics, sort order, search query) changes — input handlers, the draw
|
||||||
|
// path, and auto-scroll all read from this slice so we avoid rebuilding
|
||||||
|
// an indices Vec on every event.
|
||||||
|
procs_filtered: Vec<usize>,
|
||||||
|
procs_filter_dirty: bool,
|
||||||
|
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
|
||||||
|
// parallel to `last_metrics.top_processes`.
|
||||||
|
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
|
||||||
|
procs_row_peak_cpu: f32,
|
||||||
|
|
||||||
last_procs_poll: Instant,
|
last_procs_poll: Instant,
|
||||||
last_disks_poll: Instant,
|
last_disks_poll: Instant,
|
||||||
procs_interval: Duration,
|
procs_interval: Duration,
|
||||||
@@ -99,6 +124,7 @@ pub struct App {
|
|||||||
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
|
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
|
||||||
pub journal_entries: Option<socktop_connector::JournalResponse>,
|
pub journal_entries: Option<socktop_connector::JournalResponse>,
|
||||||
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
|
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
|
||||||
|
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
|
||||||
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
|
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
|
||||||
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
|
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
|
||||||
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
|
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
|
||||||
@@ -119,6 +145,17 @@ pub struct App {
|
|||||||
pub is_tls: bool,
|
pub is_tls: bool,
|
||||||
pub has_token: bool,
|
pub has_token: bool,
|
||||||
|
|
||||||
|
// Cached title strings — only rebuilt when source values change so the
|
||||||
|
// diff renderer can suppress redraws on idle frames.
|
||||||
|
header_title: String,
|
||||||
|
header_title_key: (String, bool, bool),
|
||||||
|
header_intervals_text: String,
|
||||||
|
header_intervals_key: (u128, u128),
|
||||||
|
net_dl_title: String,
|
||||||
|
net_dl_key: (u64, u64),
|
||||||
|
net_ul_title: String,
|
||||||
|
net_ul_key: (u64, u64),
|
||||||
|
|
||||||
// Modal system
|
// Modal system
|
||||||
pub modal_manager: crate::ui::modal::ModalManager,
|
pub modal_manager: crate::ui::modal::ModalManager,
|
||||||
|
|
||||||
@@ -136,6 +173,7 @@ impl App {
|
|||||||
Self {
|
Self {
|
||||||
last_metrics: None,
|
last_metrics: None,
|
||||||
cpu_hist: VecDeque::with_capacity(600),
|
cpu_hist: VecDeque::with_capacity(600),
|
||||||
|
cpu_hist_sum: 0,
|
||||||
per_core_hist: PerCoreHistory::new(60),
|
per_core_hist: PerCoreHistory::new(60),
|
||||||
last_net_totals: None,
|
last_net_totals: None,
|
||||||
rx_hist: VecDeque::with_capacity(600),
|
rx_hist: VecDeque::with_capacity(600),
|
||||||
@@ -154,6 +192,10 @@ impl App {
|
|||||||
prev_selected_process_pid: None,
|
prev_selected_process_pid: None,
|
||||||
process_search_active: false,
|
process_search_active: false,
|
||||||
process_search_query: String::new(),
|
process_search_query: String::new(),
|
||||||
|
procs_filtered: Vec::new(),
|
||||||
|
procs_filter_dirty: true,
|
||||||
|
procs_row_cache: Vec::new(),
|
||||||
|
procs_row_peak_cpu: 0.0,
|
||||||
last_procs_poll: Instant::now()
|
last_procs_poll: Instant::now()
|
||||||
.checked_sub(Duration::from_secs(2))
|
.checked_sub(Duration::from_secs(2))
|
||||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||||
@@ -166,6 +208,7 @@ impl App {
|
|||||||
process_details: None,
|
process_details: None,
|
||||||
journal_entries: None,
|
journal_entries: None,
|
||||||
process_cpu_history: VecDeque::with_capacity(600),
|
process_cpu_history: VecDeque::with_capacity(600),
|
||||||
|
process_cpu_history_sum: 0.0,
|
||||||
process_mem_history: VecDeque::with_capacity(600),
|
process_mem_history: VecDeque::with_capacity(600),
|
||||||
process_io_read_history: VecDeque::with_capacity(600),
|
process_io_read_history: VecDeque::with_capacity(600),
|
||||||
process_io_write_history: VecDeque::with_capacity(600),
|
process_io_write_history: VecDeque::with_capacity(600),
|
||||||
@@ -186,6 +229,14 @@ impl App {
|
|||||||
verify_hostname: false,
|
verify_hostname: false,
|
||||||
is_tls: false,
|
is_tls: false,
|
||||||
has_token: false,
|
has_token: false,
|
||||||
|
header_title: String::new(),
|
||||||
|
header_title_key: (String::new(), false, false),
|
||||||
|
header_intervals_text: String::new(),
|
||||||
|
header_intervals_key: (u128::MAX, u128::MAX),
|
||||||
|
net_dl_title: String::new(),
|
||||||
|
net_dl_key: (u64::MAX, u64::MAX),
|
||||||
|
net_ul_title: String::new(),
|
||||||
|
net_ul_key: (u64::MAX, u64::MAX),
|
||||||
modal_manager: ModalManager::new(),
|
modal_manager: ModalManager::new(),
|
||||||
connection_state: ConnectionState::Disconnected,
|
connection_state: ConnectionState::Disconnected,
|
||||||
last_connection_attempt: Instant::now(),
|
last_connection_attempt: Instant::now(),
|
||||||
@@ -465,7 +516,10 @@ impl App {
|
|||||||
_url: &str,
|
_url: &str,
|
||||||
_tls_ca: Option<&str>,
|
_tls_ca: Option<&str>,
|
||||||
_verify_hostname: bool,
|
_verify_hostname: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
where
|
||||||
|
<B as ratatui::backend::Backend>::Error: 'static,
|
||||||
|
{
|
||||||
loop {
|
loop {
|
||||||
// Handle input for modal
|
// Handle input for modal
|
||||||
while event::poll(Duration::from_millis(10))? {
|
while event::poll(Duration::from_millis(10))? {
|
||||||
@@ -572,7 +626,10 @@ impl App {
|
|||||||
&mut self,
|
&mut self,
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
mut ws: SocktopConnector,
|
mut ws: SocktopConnector,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
where
|
||||||
|
<B as ratatui::backend::Backend>::Error: 'static,
|
||||||
|
{
|
||||||
loop {
|
loop {
|
||||||
// Main event loop
|
// Main event loop
|
||||||
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
|
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
|
||||||
@@ -592,7 +649,10 @@ impl App {
|
|||||||
&mut self,
|
&mut self,
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
ws: &mut SocktopConnector,
|
ws: &mut SocktopConnector,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>>
|
||||||
|
where
|
||||||
|
<B as ratatui::backend::Backend>::Error: 'static,
|
||||||
|
{
|
||||||
loop {
|
loop {
|
||||||
// Input (non-blocking)
|
// Input (non-blocking)
|
||||||
while event::poll(Duration::from_millis(10))? {
|
while event::poll(Duration::from_millis(10))? {
|
||||||
@@ -665,6 +725,7 @@ impl App {
|
|||||||
// Exit search mode
|
// Exit search mode
|
||||||
self.process_search_active = false;
|
self.process_search_active = false;
|
||||||
self.process_search_query.clear();
|
self.process_search_query.clear();
|
||||||
|
self.invalidate_procs_filter();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
@@ -672,27 +733,24 @@ impl App {
|
|||||||
self.process_search_active = false;
|
self.process_search_active = false;
|
||||||
|
|
||||||
// Auto-select first filtered result
|
// Auto-select first filtered result
|
||||||
if let Some(m) = self.last_metrics.as_ref() {
|
let first = self.procs_filter().first().copied();
|
||||||
let idxs = get_filtered_sorted_indices(
|
if let (Some(first_idx), Some(m)) =
|
||||||
m,
|
(first, self.last_metrics.as_ref())
|
||||||
&self.process_search_query,
|
{
|
||||||
self.procs_sort_by,
|
self.selected_process_index = Some(first_idx);
|
||||||
);
|
self.selected_process_pid =
|
||||||
if !idxs.is_empty() {
|
Some(m.top_processes[first_idx].pid);
|
||||||
let first_idx = idxs[0];
|
|
||||||
self.selected_process_index = Some(first_idx);
|
|
||||||
self.selected_process_pid =
|
|
||||||
Some(m.top_processes[first_idx].pid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
self.process_search_query.pop();
|
self.process_search_query.pop();
|
||||||
|
self.invalidate_procs_filter();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
self.process_search_query.push(c);
|
self.process_search_query.push(c);
|
||||||
|
self.invalidate_procs_filter();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Down => {
|
KeyCode::Up | KeyCode::Down => {
|
||||||
@@ -728,6 +786,7 @@ impl App {
|
|||||||
self.process_search_query.clear();
|
self.process_search_query.clear();
|
||||||
self.selected_process_pid = None;
|
self.selected_process_pid = None;
|
||||||
self.selected_process_index = None;
|
self.selected_process_index = None;
|
||||||
|
self.invalidate_procs_filter();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,6 +819,10 @@ impl App {
|
|||||||
.split(rows[1]);
|
.split(rows[1]);
|
||||||
let content = per_core_content_area(top[1]);
|
let content = per_core_content_area(top[1]);
|
||||||
|
|
||||||
|
// Refresh the filtered+sorted index cache once before we
|
||||||
|
// borrow individual fields of `self`.
|
||||||
|
let _ = self.procs_filter();
|
||||||
|
|
||||||
// First try process selection (only handles arrows if a process is selected)
|
// First try process selection (only handles arrows if a process is selected)
|
||||||
let process_handled = if self.last_procs_area.is_some() {
|
let process_handled = if self.last_procs_area.is_some() {
|
||||||
processes_handle_key_with_selection(ProcessKeyParams {
|
processes_handle_key_with_selection(ProcessKeyParams {
|
||||||
@@ -767,8 +830,7 @@ impl App {
|
|||||||
selected_process_index: &mut self.selected_process_index,
|
selected_process_index: &mut self.selected_process_index,
|
||||||
key: k,
|
key: k,
|
||||||
metrics: self.last_metrics.as_ref(),
|
metrics: self.last_metrics.as_ref(),
|
||||||
sort_by: self.procs_sort_by,
|
filtered_indices: &self.procs_filtered,
|
||||||
search_query: &self.process_search_query,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
@@ -786,14 +848,9 @@ impl App {
|
|||||||
// Auto-scroll to keep selected process visible
|
// Auto-scroll to keep selected process visible
|
||||||
if let (Some(selected_idx), Some(p_area)) =
|
if let (Some(selected_idx), Some(p_area)) =
|
||||||
(self.selected_process_index, self.last_procs_area)
|
(self.selected_process_index, self.last_procs_area)
|
||||||
&& let Some(m) = self.last_metrics.as_ref()
|
&& self.last_metrics.is_some()
|
||||||
{
|
{
|
||||||
// Get filtered and sorted indices (same as display)
|
let idxs = &self.procs_filtered;
|
||||||
let idxs = get_filtered_sorted_indices(
|
|
||||||
m,
|
|
||||||
&self.process_search_query,
|
|
||||||
self.procs_sort_by,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find the display position of the selected process in filtered list
|
// Find the display position of the selected process in filtered list
|
||||||
if let Some(display_pos) =
|
if let Some(display_pos) =
|
||||||
@@ -903,11 +960,17 @@ impl App {
|
|||||||
content.height as usize,
|
content.height as usize,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh filter cache before partial borrows of self.
|
||||||
|
let _ = self.procs_filter();
|
||||||
|
let search_box_visible =
|
||||||
|
self.process_search_active || !self.process_search_query.is_empty();
|
||||||
|
|
||||||
// Processes table: sort by column on header click and handle row selection
|
// Processes table: sort by column on header click and handle row selection
|
||||||
if let (Some(mm), Some(p_area)) =
|
if let (Some(_mm), Some(p_area)) =
|
||||||
(self.last_metrics.as_ref(), self.last_procs_area)
|
(self.last_metrics.as_ref(), self.last_procs_area)
|
||||||
{
|
{
|
||||||
use crate::ui::processes::ProcessMouseParams;
|
use crate::ui::processes::ProcessMouseParams;
|
||||||
|
let total_rows = self.procs_filtered.len();
|
||||||
if let Some(new_sort) =
|
if let Some(new_sort) =
|
||||||
processes_handle_mouse_with_selection(ProcessMouseParams {
|
processes_handle_mouse_with_selection(ProcessMouseParams {
|
||||||
scroll_offset: &mut self.procs_scroll_offset,
|
scroll_offset: &mut self.procs_scroll_offset,
|
||||||
@@ -916,13 +979,14 @@ impl App {
|
|||||||
drag: &mut self.procs_drag,
|
drag: &mut self.procs_drag,
|
||||||
mouse: m,
|
mouse: m,
|
||||||
area: p_area,
|
area: p_area,
|
||||||
total_rows: mm.top_processes.len(),
|
total_rows,
|
||||||
metrics: self.last_metrics.as_ref(),
|
metrics: self.last_metrics.as_ref(),
|
||||||
sort_by: self.procs_sort_by,
|
search_box_visible,
|
||||||
search_query: &self.process_search_query,
|
filtered_indices: &self.procs_filtered,
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
self.procs_sort_by = new_sort;
|
self.procs_sort_by = new_sort;
|
||||||
|
self.invalidate_procs_filter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,22 +1023,36 @@ impl App {
|
|||||||
|
|
||||||
// Only poll processes every 2s
|
// Only poll processes every 2s
|
||||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||||
|
let mut updated = false;
|
||||||
if let Ok(AgentResponse::Processes(procs)) =
|
if let Ok(AgentResponse::Processes(procs)) =
|
||||||
ws.request(AgentRequest::Processes).await
|
ws.request(AgentRequest::Processes).await
|
||||||
&& let Some(mm) = self.last_metrics.as_mut()
|
&& let Some(mm) = self.last_metrics.as_mut()
|
||||||
{
|
{
|
||||||
mm.top_processes = procs.top_processes;
|
mm.top_processes = procs.top_processes;
|
||||||
mm.process_count = Some(procs.process_count);
|
mm.process_count = Some(procs.process_count);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
|
self.invalidate_procs_filter();
|
||||||
|
// Rebuild the pre-formatted row cache for the next
|
||||||
|
// ~N frames. Done once per poll, not per frame.
|
||||||
|
if let Some(mm) = self.last_metrics.as_ref() {
|
||||||
|
self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache(
|
||||||
|
mm,
|
||||||
|
&mut self.procs_row_cache,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.last_procs_poll = Instant::now();
|
self.last_procs_poll = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only poll disks every 5s
|
// Only poll disks every 5s
|
||||||
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
||||||
if let Ok(AgentResponse::Disks(disks)) =
|
if let Ok(AgentResponse::Disks(mut disks)) =
|
||||||
ws.request(AgentRequest::Disks).await
|
ws.request(AgentRequest::Disks).await
|
||||||
&& let Some(mm) = self.last_metrics.as_mut()
|
&& let Some(mm) = self.last_metrics.as_mut()
|
||||||
{
|
{
|
||||||
|
dedup_disks(&mut disks);
|
||||||
mm.disks = disks;
|
mm.disks = disks;
|
||||||
}
|
}
|
||||||
self.last_disks_poll = Instant::now();
|
self.last_disks_poll = Instant::now();
|
||||||
@@ -1000,7 +1078,14 @@ impl App {
|
|||||||
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
|
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
|
||||||
// Update history for sparklines
|
// Update history for sparklines
|
||||||
let cpu_usage = details.process.cpu_usage;
|
let cpu_usage = details.process.cpu_usage;
|
||||||
push_capped(&mut self.process_cpu_history, cpu_usage, 600);
|
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);
|
||||||
|
|
||||||
let mem_bytes = details.process.mem_bytes;
|
let mem_bytes = details.process.mem_bytes;
|
||||||
push_capped(&mut self.process_mem_history, mem_bytes, 600);
|
push_capped(&mut self.process_mem_history, mem_bytes, 600);
|
||||||
@@ -1105,11 +1190,37 @@ impl App {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark the filtered-process cache stale. Call this whenever
|
||||||
|
/// `procs_sort_by`, `process_search_query`, or the top_processes content
|
||||||
|
/// changes — the cache is rebuilt lazily on the next read.
|
||||||
|
pub fn invalidate_procs_filter(&mut self) {
|
||||||
|
self.procs_filter_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lazily refresh and return the cached filtered+sorted process indices.
|
||||||
|
/// Empty slice when there are no metrics yet.
|
||||||
|
pub fn procs_filter(&mut self) -> &[usize] {
|
||||||
|
if self.procs_filter_dirty {
|
||||||
|
self.procs_filtered.clear();
|
||||||
|
if let Some(m) = self.last_metrics.as_ref() {
|
||||||
|
crate::ui::processes::fill_filtered_sorted_indices(
|
||||||
|
m,
|
||||||
|
&self.process_search_query,
|
||||||
|
self.procs_sort_by,
|
||||||
|
&mut self.procs_filtered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.procs_filter_dirty = false;
|
||||||
|
}
|
||||||
|
&self.procs_filtered
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear process details when modal is closed or selection changes
|
/// Clear process details when modal is closed or selection changes
|
||||||
pub fn clear_process_details(&mut self) {
|
pub fn clear_process_details(&mut self) {
|
||||||
self.process_details = None;
|
self.process_details = None;
|
||||||
self.journal_entries = None;
|
self.journal_entries = None;
|
||||||
self.process_cpu_history.clear();
|
self.process_cpu_history.clear();
|
||||||
|
self.process_cpu_history_sum = 0.0;
|
||||||
self.process_mem_history.clear();
|
self.process_mem_history.clear();
|
||||||
self.process_io_read_history.clear();
|
self.process_io_read_history.clear();
|
||||||
self.process_io_write_history.clear();
|
self.process_io_write_history.clear();
|
||||||
@@ -1120,23 +1231,24 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_with_metrics(&mut self, mut m: Metrics) {
|
fn update_with_metrics(&mut self, mut m: Metrics) {
|
||||||
if let Some(prev) = &self.last_metrics {
|
if let Some(prev) = self.last_metrics.as_mut() {
|
||||||
// Preserve slower fields when the fast payload omits them
|
// Preserve slower fields when the fast payload omits them.
|
||||||
|
// prev is about to be dropped so we can move its Vecs instead of cloning.
|
||||||
if m.disks.is_empty() {
|
if m.disks.is_empty() {
|
||||||
m.disks = prev.disks.clone();
|
m.disks = std::mem::take(&mut prev.disks);
|
||||||
}
|
}
|
||||||
if m.top_processes.is_empty() {
|
if m.top_processes.is_empty() {
|
||||||
m.top_processes = prev.top_processes.clone();
|
m.top_processes = std::mem::take(&mut prev.top_processes);
|
||||||
}
|
}
|
||||||
// Preserve total processes count across fast updates
|
|
||||||
if m.process_count.is_none() {
|
if m.process_count.is_none() {
|
||||||
m.process_count = prev.process_count;
|
m.process_count = prev.process_count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPU avg history
|
// CPU avg history with running sum
|
||||||
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
|
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
|
||||||
push_capped(&mut self.cpu_hist, v, 600);
|
let evicted = push_capped(&mut self.cpu_hist, v, 600);
|
||||||
|
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
|
||||||
|
|
||||||
// Per-core history (push current samples)
|
// Per-core history (push current samples)
|
||||||
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
||||||
@@ -1179,16 +1291,31 @@ impl App {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Header
|
// Header — refresh cached strings only when their inputs change so the
|
||||||
draw_header(
|
// ratatui diff renderer can suppress repaints on idle frames.
|
||||||
f,
|
{
|
||||||
rows[0],
|
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
||||||
self.last_metrics.as_ref(),
|
let key = (
|
||||||
self.is_tls,
|
hostname.unwrap_or("").to_string(),
|
||||||
self.has_token,
|
self.is_tls,
|
||||||
self.metrics_interval,
|
self.has_token,
|
||||||
self.procs_interval,
|
);
|
||||||
);
|
if self.header_title_key != key {
|
||||||
|
self.header_title = build_header_title(hostname, self.is_tls, self.has_token);
|
||||||
|
self.header_title_key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervals_key = (
|
||||||
|
self.metrics_interval.as_millis(),
|
||||||
|
self.procs_interval.as_millis(),
|
||||||
|
);
|
||||||
|
if self.header_intervals_key != intervals_key {
|
||||||
|
self.header_intervals_text =
|
||||||
|
build_header_intervals(intervals_key.0, intervals_key.1);
|
||||||
|
self.header_intervals_key = intervals_key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw_header(f, rows[0], &self.header_title, &self.header_intervals_text);
|
||||||
|
|
||||||
// Top row: left CPU avg, right Per-core (full top-right)
|
// Top row: left CPU avg, right Per-core (full top-right)
|
||||||
let top_lr = ratatui::layout::Layout::default()
|
let top_lr = ratatui::layout::Layout::default()
|
||||||
@@ -1196,12 +1323,18 @@ impl App {
|
|||||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||||
.split(rows[1]);
|
.split(rows[1]);
|
||||||
|
|
||||||
draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
|
draw_cpu_avg_graph(
|
||||||
|
f,
|
||||||
|
top_lr[0],
|
||||||
|
&mut self.cpu_hist,
|
||||||
|
self.cpu_hist_sum,
|
||||||
|
self.last_metrics.as_ref(),
|
||||||
|
);
|
||||||
draw_per_core_bars(
|
draw_per_core_bars(
|
||||||
f,
|
f,
|
||||||
top_lr[1],
|
top_lr[1],
|
||||||
self.last_metrics.as_ref(),
|
self.last_metrics.as_ref(),
|
||||||
&self.per_core_hist,
|
&mut self.per_core_hist,
|
||||||
self.per_core_scroll,
|
self.per_core_scroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1245,26 +1378,33 @@ impl App {
|
|||||||
.split(bottom_lr[0]);
|
.split(bottom_lr[0]);
|
||||||
|
|
||||||
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
|
draw_disks(f, left_stack[0], 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;
|
||||||
|
}
|
||||||
draw_net_spark(
|
draw_net_spark(
|
||||||
f,
|
f,
|
||||||
left_stack[1],
|
left_stack[1],
|
||||||
&format!(
|
&self.net_dl_title,
|
||||||
"Download (KB/s) — now: {} | peak: {}",
|
&mut self.rx_hist,
|
||||||
self.rx_hist.back().copied().unwrap_or(0),
|
|
||||||
self.rx_peak
|
|
||||||
),
|
|
||||||
&self.rx_hist,
|
|
||||||
ratatui::style::Color::Green,
|
ratatui::style::Color::Green,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
|
||||||
|
let tx_key = (tx_now, self.tx_peak);
|
||||||
|
if self.net_ul_key != tx_key {
|
||||||
|
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
|
||||||
|
self.net_ul_key = tx_key;
|
||||||
|
}
|
||||||
draw_net_spark(
|
draw_net_spark(
|
||||||
f,
|
f,
|
||||||
left_stack[2],
|
left_stack[2],
|
||||||
&format!(
|
&self.net_ul_title,
|
||||||
"Upload (KB/s) — now: {} | peak: {}",
|
&mut self.tx_hist,
|
||||||
self.tx_hist.back().copied().unwrap_or(0),
|
|
||||||
self.tx_peak
|
|
||||||
),
|
|
||||||
&self.tx_hist,
|
|
||||||
ratatui::style::Color::Blue,
|
ratatui::style::Color::Blue,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1272,6 +1412,8 @@ impl App {
|
|||||||
let procs_area = bottom_lr[1];
|
let procs_area = bottom_lr[1];
|
||||||
// Cache for input handlers
|
// Cache for input handlers
|
||||||
self.last_procs_area = Some(procs_area);
|
self.last_procs_area = Some(procs_area);
|
||||||
|
// Refresh the filter cache before partial borrows of self.
|
||||||
|
let _ = self.procs_filter();
|
||||||
crate::ui::processes::draw_top_processes(
|
crate::ui::processes::draw_top_processes(
|
||||||
f,
|
f,
|
||||||
procs_area,
|
procs_area,
|
||||||
@@ -1283,6 +1425,9 @@ impl App {
|
|||||||
selected_process_index: self.selected_process_index,
|
selected_process_index: self.selected_process_index,
|
||||||
search_query: &self.process_search_query,
|
search_query: &self.process_search_query,
|
||||||
search_active: self.process_search_active,
|
search_active: self.process_search_active,
|
||||||
|
filtered_indices: &self.procs_filtered,
|
||||||
|
cached_rows: &self.procs_row_cache,
|
||||||
|
peak_cpu: self.procs_row_peak_cpu,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1296,6 +1441,7 @@ impl App {
|
|||||||
journal: self.journal_entries.as_ref(),
|
journal: self.journal_entries.as_ref(),
|
||||||
history: ProcessHistoryData {
|
history: ProcessHistoryData {
|
||||||
cpu: &self.process_cpu_history,
|
cpu: &self.process_cpu_history,
|
||||||
|
cpu_sum: self.process_cpu_history_sum,
|
||||||
mem: &self.process_mem_history,
|
mem: &self.process_mem_history,
|
||||||
io_read: &self.process_io_read_history,
|
io_read: &self.process_io_read_history,
|
||||||
io_write: &self.process_io_write_history,
|
io_write: &self.process_io_write_history,
|
||||||
@@ -1310,66 +1456,6 @@ impl App {
|
|||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self::new()
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -2,16 +2,25 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
/// Push a value into a capped deque. Returns the evicted front element if any.
|
||||||
if dq.len() == cap {
|
/// Callers maintaining a running sum can use this to update the sum without
|
||||||
dq.pop_front();
|
/// 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
|
||||||
|
};
|
||||||
dq.push_back(v);
|
dq.push_back(v);
|
||||||
|
evicted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keeps a history deque per core with a fixed capacity
|
// Keeps a history deque per core with a fixed capacity.
|
||||||
|
// Storage is u64 so sparkline rendering can hand the slice directly to
|
||||||
|
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
|
||||||
|
// allocation or widening conversion.
|
||||||
pub struct PerCoreHistory {
|
pub struct PerCoreHistory {
|
||||||
pub deques: Vec<VecDeque<u16>>,
|
pub deques: Vec<VecDeque<u64>>,
|
||||||
cap: usize,
|
cap: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +44,7 @@ impl PerCoreHistory {
|
|||||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||||
self.ensure_cores(samples.len());
|
self.ensure_cores(samples.len());
|
||||||
for (i, v) in samples.iter().enumerate() {
|
for (i, v) in samples.iter().enumerate() {
|
||||||
let val = v.clamp(0.0, 100.0).round() as u16;
|
let val = v.clamp(0.0, 100.0).round() as u64;
|
||||||
push_capped(&mut self.deques[i], val, self.cap);
|
push_capped(&mut self.deques[i], val, self.cap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-67
@@ -7,7 +7,9 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
widgets::{
|
||||||
|
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::history::PerCoreHistory;
|
use crate::history::PerCoreHistory;
|
||||||
@@ -133,11 +135,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
|||||||
}
|
}
|
||||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
||||||
let top_for_offset = |off: usize| -> usize {
|
let top_for_offset = |off: usize| -> usize {
|
||||||
if max_off == 0 {
|
((track - thumb_len) * off + max_off / 2)
|
||||||
0
|
.checked_div(max_off)
|
||||||
} else {
|
.unwrap_or(0)
|
||||||
((track - thumb_len) * off + max_off / 2) / max_off
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let thumb_top = top_for_offset(offset);
|
let thumb_top = top_for_offset(offset);
|
||||||
|
|
||||||
@@ -190,11 +190,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
|||||||
// Inverse mapping top -> offset
|
// Inverse mapping top -> offset
|
||||||
if track > thumb_len {
|
if track > thumb_len {
|
||||||
let denom = track - thumb_len;
|
let denom = track - thumb_len;
|
||||||
offset = if max_off == 0 {
|
offset = (new_top * max_off + denom / 2)
|
||||||
0
|
.checked_div(denom)
|
||||||
} else {
|
.unwrap_or(0);
|
||||||
(new_top * max_off + denom / 2) / denom
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
offset = 0;
|
offset = 0;
|
||||||
}
|
}
|
||||||
@@ -234,18 +232,20 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draws the CPU average sparkline graph.
|
/// Draws the CPU average sparkline graph.
|
||||||
|
///
|
||||||
|
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
|
||||||
|
/// fold the (up to 600-element) deque on every frame.
|
||||||
pub fn draw_cpu_avg_graph(
|
pub fn draw_cpu_avg_graph(
|
||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
hist: &std::collections::VecDeque<u64>,
|
hist: &mut std::collections::VecDeque<u64>,
|
||||||
|
hist_sum: u64,
|
||||||
m: Option<&Metrics>,
|
m: Option<&Metrics>,
|
||||||
) {
|
) {
|
||||||
// Calculate average CPU over the monitoring period
|
let avg_cpu = if hist.is_empty() {
|
||||||
let avg_cpu = if !hist.is_empty() {
|
|
||||||
let sum: u64 = hist.iter().sum();
|
|
||||||
sum as f64 / hist.len() as f64
|
|
||||||
} else {
|
|
||||||
0.0
|
0.0
|
||||||
|
} else {
|
||||||
|
hist_sum as f64 / hist.len() as f64
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = if let Some(mm) = m {
|
let title = if let Some(mm) = m {
|
||||||
@@ -272,14 +272,16 @@ pub fn draw_cpu_avg_graph(
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||||
|
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||||
|
// us skip the per-frame Vec allocation .collect() used to do.
|
||||||
let max_points = area.width.saturating_sub(2) as usize;
|
let max_points = area.width.saturating_sub(2) as usize;
|
||||||
let start = hist.len().saturating_sub(max_points);
|
let start = hist.len().saturating_sub(max_points);
|
||||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
let slice = &hist.make_contiguous()[start..];
|
||||||
|
|
||||||
// Render the sparkline with title on left
|
|
||||||
let spark = Sparkline::default()
|
let spark = Sparkline::default()
|
||||||
.block(Block::default().borders(Borders::ALL).title(title))
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
.data(&data)
|
.data(slice)
|
||||||
.max(100)
|
.max(100)
|
||||||
.style(Style::default().fg(Color::Cyan));
|
.style(Style::default().fg(Color::Cyan));
|
||||||
f.render_widget(spark, area);
|
f.render_widget(spark, area);
|
||||||
@@ -302,7 +304,7 @@ pub fn draw_per_core_bars(
|
|||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
m: Option<&Metrics>,
|
m: Option<&Metrics>,
|
||||||
per_core_hist: &PerCoreHistory,
|
per_core_hist: &mut PerCoreHistory,
|
||||||
scroll_offset: usize,
|
scroll_offset: usize,
|
||||||
) {
|
) {
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
@@ -347,7 +349,7 @@ pub fn draw_per_core_bars(
|
|||||||
let rect = vchunks[i];
|
let rect = vchunks[i];
|
||||||
let hchunks = Layout::default()
|
let hchunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
||||||
.split(rect);
|
.split(rect);
|
||||||
|
|
||||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||||
@@ -358,12 +360,17 @@ pub fn draw_per_core_bars(
|
|||||||
.map(|v| v as f32)
|
.map(|v| v as f32)
|
||||||
.unwrap_or(curr);
|
.unwrap_or(curr);
|
||||||
|
|
||||||
|
// Trend indicator. Various Unicode glyphs we tried for the "flat"
|
||||||
|
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
|
||||||
|
// coverage; combined with the next column being `100.0` they read as
|
||||||
|
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
|
||||||
|
// for the flat case — no character, no fallback, no confusion.
|
||||||
let trend = if curr > older + 0.2 {
|
let trend = if curr > older + 0.2 {
|
||||||
"↑"
|
"↑"
|
||||||
} else if curr + 0.2 < older {
|
} else if curr + 0.2 < older {
|
||||||
"↓"
|
"↓"
|
||||||
} else {
|
} else {
|
||||||
"╌"
|
" "
|
||||||
};
|
};
|
||||||
|
|
||||||
let fg = match curr {
|
let fg = match curr {
|
||||||
@@ -372,24 +379,24 @@ pub fn draw_per_core_bars(
|
|||||||
_ => Color::Red,
|
_ => Color::Red,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hist: Vec<u64> = per_core_hist
|
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
||||||
.deques
|
// to Sparkline without allocating a fresh Vec each frame.
|
||||||
.get(idx)
|
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
||||||
.map(|d| {
|
let max_points = hchunks[0].width as usize;
|
||||||
let max_points = hchunks[0].width as usize;
|
let start = d.len().saturating_sub(max_points);
|
||||||
let start = d.len().saturating_sub(max_points);
|
let slice = &d.make_contiguous()[start..];
|
||||||
d.iter().skip(start).map(|&v| v as u64).collect()
|
let spark = Sparkline::default()
|
||||||
})
|
.data(slice)
|
||||||
.unwrap_or_default();
|
.max(100)
|
||||||
|
.style(Style::default().fg(fg));
|
||||||
|
f.render_widget(spark, hchunks[0]);
|
||||||
|
}
|
||||||
|
|
||||||
let spark = Sparkline::default()
|
// Hard space between the trend mark and the number — even if the
|
||||||
.data(&hist)
|
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
||||||
.max(100)
|
// them, this space prevents the trend mark from visually joining
|
||||||
.style(Style::default().fg(fg));
|
// `100.0` to look like a negative value.
|
||||||
|
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
||||||
f.render_widget(spark, hchunks[0]);
|
|
||||||
|
|
||||||
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
|
|
||||||
let line = Line::from(Span::styled(
|
let line = Line::from(Span::styled(
|
||||||
label,
|
label,
|
||||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||||
@@ -397,38 +404,122 @@ pub fn draw_per_core_bars(
|
|||||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
||||||
|
// content fits in the viewport, matching the previous behaviour.
|
||||||
let scroll_area = Rect {
|
let scroll_area = Rect {
|
||||||
x: inner.x + inner.width.saturating_sub(1),
|
x: inner.x + inner.width.saturating_sub(1),
|
||||||
y: inner.y,
|
y: inner.y,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: inner.height,
|
height: inner.height,
|
||||||
};
|
};
|
||||||
if scroll_area.height >= 3 {
|
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||||
let track = (scroll_area.height - 2) as usize;
|
if scroll_area.height >= 3 && max_off > 0 {
|
||||||
let total = total_rows.max(1);
|
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||||
let view = viewport_rows.clamp(1, total);
|
.begin_symbol(Some("▲"))
|
||||||
let max_off = total.saturating_sub(view);
|
.end_symbol(Some("▼"))
|
||||||
|
.thumb_symbol("█")
|
||||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
.track_symbol(Some("│"))
|
||||||
let thumb_top = if max_off == 0 {
|
.thumb_style(Style::default().fg(SB_THUMB))
|
||||||
0
|
.track_style(Style::default().fg(SB_TRACK))
|
||||||
} else {
|
.begin_style(Style::default().fg(SB_ARROW))
|
||||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
.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);
|
||||||
// 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 {
|
#[cfg(test)]
|
||||||
if i >= thumb_top && i < thumb_top + thumb_len {
|
mod render_tests {
|
||||||
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
use super::*;
|
||||||
} else {
|
use ratatui::Terminal;
|
||||||
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
use ratatui::backend::TestBackend;
|
||||||
}
|
use socktop_connector::Metrics;
|
||||||
}
|
|
||||||
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
fn fake_metrics(cores: Vec<f32>) -> Metrics {
|
||||||
|
Metrics {
|
||||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-10
@@ -24,16 +24,11 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter duplicates by keeping first occurrence of each unique name
|
// Deduplication is performed once on the App side when fresh disk data
|
||||||
let mut seen_names = std::collections::HashSet::new();
|
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
||||||
let unique_disks: Vec<_> = mm
|
// here would rebuild a HashSet ~10x per refresh for no reason).
|
||||||
.disks
|
|
||||||
.iter()
|
|
||||||
.filter(|d| seen_names.insert(d.name.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let per_disk_h = 3u16;
|
let per_disk_h = 3u16;
|
||||||
let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize;
|
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||||
|
|
||||||
let constraints: Vec<Constraint> = (0..max_cards)
|
let constraints: Vec<Constraint> = (0..max_cards)
|
||||||
.map(|_| Constraint::Length(per_disk_h))
|
.map(|_| Constraint::Length(per_disk_h))
|
||||||
@@ -44,7 +39,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
for (i, slot) in rows.iter().enumerate() {
|
for (i, slot) in rows.iter().enumerate() {
|
||||||
let d = unique_disks[i];
|
let d = &mm.disks[i];
|
||||||
let used = d.total.saturating_sub(d.available);
|
let used = d.total.saturating_sub(d.available);
|
||||||
let ratio = if d.total > 0 {
|
let ratio = if d.total > 0 {
|
||||||
used as f64 / d.total as f64
|
used as f64 / d.total as f64
|
||||||
|
|||||||
+16
-27
@@ -1,47 +1,36 @@
|
|||||||
//! Top header with hostname and CPU temperature indicator.
|
//! Top header with hostname and CPU temperature indicator.
|
||||||
|
|
||||||
use crate::types::Metrics;
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub fn draw_header(
|
/// Build the header's left-side title from session state. Callers cache the
|
||||||
f: &mut ratatui::Frame<'_>,
|
/// returned String and only rebuild it when one of the inputs changes.
|
||||||
area: Rect,
|
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
|
||||||
m: Option<&Metrics>,
|
let base = match hostname {
|
||||||
is_tls: bool,
|
Some(h) => format!("socktop — host: {h}"),
|
||||||
has_token: bool,
|
None => "socktop — connecting...".into(),
|
||||||
metrics_interval: Duration,
|
|
||||||
procs_interval: Duration,
|
|
||||||
) {
|
|
||||||
let base = if let Some(mm) = m {
|
|
||||||
format!("socktop — host: {}", mm.hostname)
|
|
||||||
} else {
|
|
||||||
"socktop — connecting...".into()
|
|
||||||
};
|
};
|
||||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
|
||||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
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()];
|
let mut parts = vec![base, tls_txt.into()];
|
||||||
if !tok_txt.is_empty() {
|
if has_token {
|
||||||
parts.push(tok_txt.into());
|
parts.push("🔑 token".into());
|
||||||
}
|
}
|
||||||
parts.push("(a: about, h: help, q: quit)".into());
|
parts.push("(a: about, h: help, q: quit)".into());
|
||||||
let title = parts.join(" | ");
|
parts.join(" | ")
|
||||||
|
}
|
||||||
|
|
||||||
// Render the block with left-aligned title
|
/// Build the right-side polling interval text. Callers cache this string.
|
||||||
|
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
|
||||||
|
format!("⏱ {metrics_ms}ms metrics | {procs_ms}ms procs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
||||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||||
|
|
||||||
// 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;
|
let intervals_width = intervals.len() as u16;
|
||||||
|
|
||||||
if area.width > intervals_width + 2 {
|
if area.width > intervals_width + 2 {
|
||||||
let right_area = Rect {
|
let right_area = Rect {
|
||||||
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ impl ModalManager {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(ICON_WARNING_TITLE)
|
.title(
|
||||||
.title_style(
|
Line::from(ICON_WARNING_TITLE).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(MODAL_TITLE_FG)
|
.fg(MODAL_TITLE_FG)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ impl ModalManager {
|
|||||||
main_chunks[0],
|
main_chunks[0],
|
||||||
&details.process,
|
&details.process,
|
||||||
data.history.cpu,
|
data.history.cpu,
|
||||||
|
data.history.cpu_sum,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
||||||
@@ -419,7 +420,7 @@ impl ModalManager {
|
|||||||
)
|
)
|
||||||
.header(header)
|
.header(header)
|
||||||
.block(block)
|
.block(block)
|
||||||
.highlight_style(Style::default());
|
.row_highlight_style(Style::default());
|
||||||
|
|
||||||
f.render_widget(table, area);
|
f.render_widget(table, area);
|
||||||
|
|
||||||
@@ -564,8 +565,13 @@ impl ModalManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a 2D grid to represent the plot
|
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation
|
||||||
let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
// Plot main process
|
// Plot main process
|
||||||
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
|
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
|
||||||
@@ -573,7 +579,7 @@ impl ModalManager {
|
|||||||
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
|
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
|
||||||
);
|
);
|
||||||
if main_x < plot_width && main_y < plot_height {
|
if main_x < plot_width && main_y < plot_height {
|
||||||
plot_grid[main_y][main_x] = '●'; // Main process marker
|
put(&mut plot_grid, main_x, main_y, '●');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plot threads (use different marker)
|
// Plot threads (use different marker)
|
||||||
@@ -587,13 +593,13 @@ impl ModalManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if thread_x < plot_width && thread_y < plot_height {
|
if thread_x < plot_width && thread_y < plot_height {
|
||||||
if plot_grid[thread_y][thread_x] == ' ' {
|
let ch = cell(&plot_grid, thread_x, thread_y);
|
||||||
plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle)
|
let next = match ch {
|
||||||
} else if plot_grid[thread_y][thread_x] == '○' {
|
' ' => '○',
|
||||||
plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point
|
'○' => '◎',
|
||||||
} else {
|
_ => '◉',
|
||||||
plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point
|
};
|
||||||
}
|
put(&mut plot_grid, thread_x, thread_y, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,28 +614,34 @@ impl ModalManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if child_x < plot_width && child_y < plot_height {
|
if child_x < plot_width && child_y < plot_height {
|
||||||
if plot_grid[child_y][child_x] == ' ' {
|
let ch = cell(&plot_grid, child_x, child_y);
|
||||||
plot_grid[child_y][child_x] = '•'; // Child process marker
|
let next = if ch == ' ' { '•' } else { '◉' };
|
||||||
} else {
|
put(&mut plot_grid, child_x, child_y, next);
|
||||||
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the plot
|
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
|
||||||
let mut lines = Vec::new();
|
// labels + axis title + (top) Y-axis title + legend + spacing.
|
||||||
|
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
|
||||||
|
|
||||||
// Add Y-axis labels and plot content
|
// Y-axis labels and plot content
|
||||||
for (i, row) in plot_grid.iter().enumerate() {
|
let mut row_buf = String::with_capacity(plot_width);
|
||||||
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
|
for y in 0..plot_height {
|
||||||
// Always format with 4 characters width, right-aligned, to prevent axis shifting
|
let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64));
|
||||||
|
// 4-char fixed-width label so the axis doesn't shift as digits change.
|
||||||
let y_label = if y_value >= 100.0 {
|
let y_label = if y_value >= 100.0 {
|
||||||
format!("{y_value:>4.0}")
|
format!("{y_value:>4.0}")
|
||||||
} else {
|
} else {
|
||||||
format!("{y_value:>4.1}")
|
format!("{y_value:>4.1}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let plot_content: String = row.iter().collect();
|
// 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);
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(y_label, Style::default()),
|
Span::styled(y_label, Style::default()),
|
||||||
@@ -833,6 +845,7 @@ impl ModalManager {
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
process: &socktop_connector::DetailedProcessInfo,
|
process: &socktop_connector::DetailedProcessInfo,
|
||||||
cpu_history: &std::collections::VecDeque<f32>,
|
cpu_history: &std::collections::VecDeque<f32>,
|
||||||
|
cpu_history_sum: f32,
|
||||||
) {
|
) {
|
||||||
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
||||||
let top_chunks = Layout::default()
|
let top_chunks = Layout::default()
|
||||||
@@ -843,7 +856,7 @@ impl ModalManager {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history);
|
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum);
|
||||||
self.render_thread_scatter_plot(f, top_chunks[1], process);
|
self.render_thread_scatter_plot(f, top_chunks[1], process);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,6 +866,7 @@ impl ModalManager {
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
process: &socktop_connector::DetailedProcessInfo,
|
process: &socktop_connector::DetailedProcessInfo,
|
||||||
cpu_history: &std::collections::VecDeque<f32>,
|
cpu_history: &std::collections::VecDeque<f32>,
|
||||||
|
cpu_history_sum: f32,
|
||||||
) {
|
) {
|
||||||
// Normalize CPU to 0-100% by dividing by thread count
|
// Normalize CPU to 0-100% by dividing by thread count
|
||||||
// This shows per-core utilization rather than total utilization across all cores
|
// This shows per-core utilization rather than total utilization across all cores
|
||||||
@@ -864,8 +878,7 @@ impl ModalManager {
|
|||||||
let avg_cpu = if cpu_history.is_empty() {
|
let avg_cpu = if cpu_history.is_empty() {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let total: f32 = cpu_history.iter().sum();
|
normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count)
|
||||||
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
|
|
||||||
};
|
};
|
||||||
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
|
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use std::time::Instant;
|
|||||||
/// History data for process metrics rendering
|
/// History data for process metrics rendering
|
||||||
pub struct ProcessHistoryData<'a> {
|
pub struct ProcessHistoryData<'a> {
|
||||||
pub cpu: &'a std::collections::VecDeque<f32>,
|
pub cpu: &'a std::collections::VecDeque<f32>,
|
||||||
|
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
|
||||||
|
pub cpu_sum: f32,
|
||||||
pub mem: &'a std::collections::VecDeque<u64>,
|
pub mem: &'a std::collections::VecDeque<u64>,
|
||||||
pub io_read: &'a std::collections::VecDeque<u64>,
|
pub io_read: &'a std::collections::VecDeque<u64>,
|
||||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
|
|||||||
f: &mut ratatui::Frame<'_>,
|
f: &mut ratatui::Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
title: &str,
|
title: &str,
|
||||||
hist: &VecDeque<u64>,
|
hist: &mut VecDeque<u64>,
|
||||||
color: Color,
|
color: Color,
|
||||||
) {
|
) {
|
||||||
let max_points = area.width.saturating_sub(2) as usize;
|
let max_points = area.width.saturating_sub(2) as usize;
|
||||||
let start = hist.len().saturating_sub(max_points);
|
let start = hist.len().saturating_sub(max_points);
|
||||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
let slice = &hist.make_contiguous()[start..];
|
||||||
|
|
||||||
let spark = Sparkline::default()
|
let spark = Sparkline::default()
|
||||||
.block(
|
.block(
|
||||||
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
|
|||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(title.to_string()),
|
.title(title.to_string()),
|
||||||
)
|
)
|
||||||
.data(&data)
|
.data(slice)
|
||||||
.style(Style::default().fg(color));
|
.style(Style::default().fg(color));
|
||||||
f.render_widget(spark, area);
|
f.render_widget(spark, area);
|
||||||
}
|
}
|
||||||
|
|||||||
+219
-164
@@ -5,8 +5,8 @@ use ratatui::style::Modifier;
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
text::{Line, Span},
|
text::Span,
|
||||||
widgets::{Block, Borders, Paragraph, Table},
|
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
|
||||||
};
|
};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
@@ -16,18 +16,17 @@ use crate::ui::theme::{
|
|||||||
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
|
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
|
||||||
SB_THUMB, SB_TRACK,
|
SB_THUMB, SB_TRACK,
|
||||||
};
|
};
|
||||||
use crate::ui::util::human;
|
|
||||||
|
|
||||||
/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
|
/// 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).
|
||||||
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
||||||
if needle.is_empty() {
|
if needle.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let haystack_lower = haystack.to_lowercase();
|
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
|
||||||
let needle_lower = needle.to_lowercase();
|
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
|
||||||
let mut haystack_chars = haystack_lower.chars();
|
|
||||||
|
|
||||||
for needle_char in needle_lower.chars() {
|
|
||||||
if !haystack_chars.any(|c| c == needle_char) {
|
if !haystack_chars.any(|c| c == needle_char) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -35,36 +34,37 @@ fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get filtered and sorted process indices based on search query and sort order
|
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first
|
||||||
pub fn get_filtered_sorted_indices(
|
/// 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(
|
||||||
metrics: &Metrics,
|
metrics: &Metrics,
|
||||||
search_query: &str,
|
search_query: &str,
|
||||||
sort_by: ProcSortBy,
|
sort_by: ProcSortBy,
|
||||||
) -> Vec<usize> {
|
out: &mut Vec<usize>,
|
||||||
// Filter processes by search query (fuzzy match)
|
) {
|
||||||
let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
|
out.clear();
|
||||||
(0..metrics.top_processes.len()).collect()
|
out.reserve(metrics.top_processes.len());
|
||||||
|
if search_query.is_empty() {
|
||||||
|
out.extend(0..metrics.top_processes.len());
|
||||||
} else {
|
} else {
|
||||||
(0..metrics.top_processes.len())
|
out.extend(
|
||||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
|
(0..metrics.top_processes.len())
|
||||||
.collect()
|
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
|
||||||
};
|
);
|
||||||
|
}
|
||||||
// Sort filtered rows
|
|
||||||
match sort_by {
|
match sort_by {
|
||||||
ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| {
|
ProcSortBy::CpuDesc => out.sort_by(|&a, &b| {
|
||||||
let aa = metrics.top_processes[a].cpu_usage;
|
let aa = metrics.top_processes[a].cpu_usage;
|
||||||
let bb = metrics.top_processes[b].cpu_usage;
|
let bb = metrics.top_processes[b].cpu_usage;
|
||||||
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
|
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
|
||||||
}),
|
}),
|
||||||
ProcSortBy::MemDesc => filtered_idxs.sort_by(|&a, &b| {
|
ProcSortBy::MemDesc => out.sort_by(|&a, &b| {
|
||||||
let aa = metrics.top_processes[a].mem_bytes;
|
let aa = metrics.top_processes[a].mem_bytes;
|
||||||
let bb = metrics.top_processes[b].mem_bytes;
|
let bb = metrics.top_processes[b].mem_bytes;
|
||||||
bb.cmp(&aa)
|
bb.cmp(&aa)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered_idxs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for drawing the top processes table
|
/// Parameters for drawing the top processes table
|
||||||
@@ -76,6 +76,16 @@ pub struct ProcessDisplayParams<'a> {
|
|||||||
pub selected_process_index: Option<usize>,
|
pub selected_process_index: Option<usize>,
|
||||||
pub search_query: &'a str,
|
pub search_query: &'a str,
|
||||||
pub search_active: bool,
|
pub search_active: bool,
|
||||||
|
/// Precomputed filtered + sorted indices into `metrics.top_processes`.
|
||||||
|
/// Maintained on the App side so the draw path never recomputes the list.
|
||||||
|
pub filtered_indices: &'a [usize],
|
||||||
|
/// Pre-formatted strings for each row of `metrics.top_processes`.
|
||||||
|
/// Indexed the same as `metrics.top_processes`. Empty when no procs poll
|
||||||
|
/// has run yet (the draw path falls back to fast inline formatting).
|
||||||
|
pub cached_rows: &'a [CachedRow],
|
||||||
|
/// Peak cpu_usage from the most recent cache build; used to bold the
|
||||||
|
/// busiest process. -1.0 if no cache.
|
||||||
|
pub peak_cpu: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -85,6 +95,45 @@ pub enum ProcSortBy {
|
|||||||
MemDesc,
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the original header widths here so drawing and hit-testing match.
|
// Keep the original header widths here so drawing and hit-testing match.
|
||||||
const COLS: [Constraint; 5] = [
|
const COLS: [Constraint; 5] = [
|
||||||
Constraint::Length(8), // PID
|
Constraint::Length(8), // PID
|
||||||
@@ -159,8 +208,7 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
height: inner.height,
|
height: inner.height,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get filtered and sorted indices
|
let idxs = params.filtered_indices;
|
||||||
let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by);
|
|
||||||
|
|
||||||
// Scrolling
|
// Scrolling
|
||||||
let total_rows = idxs.len();
|
let total_rows = idxs.len();
|
||||||
@@ -170,19 +218,60 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
let offset = params.scroll_offset.min(max_off);
|
let offset = params.scroll_offset.min(max_off);
|
||||||
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||||
|
|
||||||
// Build visible 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();
|
||||||
let total_mem_bytes = mm.mem_total.max(1);
|
let total_mem_bytes = mm.mem_total.max(1);
|
||||||
let peak_cpu = mm
|
let peak_cpu = if cache_ok {
|
||||||
.top_processes
|
params.peak_cpu
|
||||||
.iter()
|
} else {
|
||||||
.map(|p| p.cpu_usage)
|
mm.top_processes
|
||||||
.fold(0.0_f32, f32::max);
|
.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 rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
|
||||||
let p = &mm.top_processes[ix];
|
let p = &mm.top_processes[ix];
|
||||||
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
|
|
||||||
|
|
||||||
let cpu_val = p.cpu_usage;
|
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_fg = match cpu_val {
|
let cpu_fg = match cpu_val {
|
||||||
x if x < 25.0 => Color::Green,
|
x if x < 25.0 => Color::Green,
|
||||||
x if x < 60.0 => Color::Yellow,
|
x if x < 60.0 => Color::Yellow,
|
||||||
@@ -200,16 +289,14 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this process is selected - prioritize PID matching
|
|
||||||
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
|
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
|
||||||
selected_pid == p.pid
|
selected_pid == p.pid
|
||||||
} else if let Some(selected_idx) = params.selected_process_index {
|
} else if let Some(selected_idx) = params.selected_process_index {
|
||||||
selected_idx == ix // ix is the absolute index in the sorted list
|
selected_idx == ix
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply selection highlighting
|
|
||||||
if is_selected {
|
if is_selected {
|
||||||
emphasis = emphasis
|
emphasis = emphasis
|
||||||
.bg(PROCESS_SELECTION_BG)
|
.bg(PROCESS_SELECTION_BG)
|
||||||
@@ -217,15 +304,13 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cpu_str = fmt_cpu_pct(cpu_val);
|
|
||||||
|
|
||||||
ratatui::widgets::Row::new(vec![
|
ratatui::widgets::Row::new(vec![
|
||||||
ratatui::widgets::Cell::from(p.pid.to_string())
|
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
|
||||||
.style(Style::default().fg(Color::DarkGray)),
|
ratatui::widgets::Cell::from(name_span),
|
||||||
ratatui::widgets::Cell::from(p.name.clone()),
|
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
|
||||||
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
|
.style(Style::default().fg(cpu_fg)),
|
||||||
ratatui::widgets::Cell::from(human(p.mem_bytes)),
|
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
|
||||||
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
|
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
|
||||||
.style(Style::default().fg(mem_fg)),
|
.style(Style::default().fg(mem_fg)),
|
||||||
])
|
])
|
||||||
.style(emphasis)
|
.style(emphasis)
|
||||||
@@ -293,45 +378,29 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw scrollbar like CPU pane
|
// Scrollbar (ratatui built-in). Skip drawing when content fits in viewport.
|
||||||
let scroll_area = Rect {
|
let scroll_area = Rect {
|
||||||
x: inner.x + inner.width.saturating_sub(1),
|
x: inner.x + inner.width.saturating_sub(1),
|
||||||
y: inner.y,
|
y: inner.y,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: inner.height,
|
height: inner.height,
|
||||||
};
|
};
|
||||||
if scroll_area.height >= 3 {
|
let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
|
||||||
let track = (scroll_area.height - 2) as usize;
|
if scroll_area.height >= 3 && max_off_for_bar > 0 {
|
||||||
let total = total_rows.max(1);
|
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||||
let view = viewport_rows.clamp(1, total);
|
.begin_symbol(Some("▲"))
|
||||||
let max_off = total.saturating_sub(view);
|
.end_symbol(Some("▼"))
|
||||||
|
.thumb_symbol("█")
|
||||||
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
|
.track_symbol(Some("│"))
|
||||||
let thumb_top = if max_off == 0 {
|
.thumb_style(Style::default().fg(SB_THUMB))
|
||||||
0
|
.track_style(Style::default().fg(SB_TRACK))
|
||||||
} else {
|
.begin_style(Style::default().fg(SB_ARROW))
|
||||||
((track - thumb_len) * offset + max_off / 2) / max_off
|
.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);
|
||||||
// Build lines: top arrow, track (with thumb), bottom arrow
|
|
||||||
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
|
|
||||||
lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW))));
|
|
||||||
for i in 0..track {
|
|
||||||
if i >= thumb_top && i < thumb_top + thumb_len {
|
|
||||||
lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB))));
|
|
||||||
} else {
|
|
||||||
lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW))));
|
|
||||||
f.render_widget(Paragraph::new(lines), scroll_area);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fmt_cpu_pct(v: f32) -> String {
|
|
||||||
format!("{:>5.1}", v.clamp(0.0, 100.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
||||||
/// Parameters for process key event handling
|
/// Parameters for process key event handling
|
||||||
pub struct ProcessKeyParams<'a> {
|
pub struct ProcessKeyParams<'a> {
|
||||||
@@ -339,8 +408,7 @@ pub struct ProcessKeyParams<'a> {
|
|||||||
pub selected_process_index: &'a mut Option<usize>,
|
pub selected_process_index: &'a mut Option<usize>,
|
||||||
pub key: crossterm::event::KeyEvent,
|
pub key: crossterm::event::KeyEvent,
|
||||||
pub metrics: Option<&'a Metrics>,
|
pub metrics: Option<&'a Metrics>,
|
||||||
pub sort_by: ProcSortBy,
|
pub filtered_indices: &'a [usize],
|
||||||
pub search_query: &'a str,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
|
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
|
||||||
@@ -356,87 +424,71 @@ pub fn processes_handle_key(
|
|||||||
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
let move_selection = |delta: isize,
|
||||||
|
sel_idx: &mut Option<usize>,
|
||||||
|
sel_pid: &mut Option<u32>,
|
||||||
|
metrics: Option<&Metrics>,
|
||||||
|
idxs: &[usize]| {
|
||||||
|
let Some(m) = metrics else { return };
|
||||||
|
if idxs.is_empty() {
|
||||||
|
*sel_idx = None;
|
||||||
|
*sel_pid = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if sel_idx.is_none() || sel_pid.is_none() {
|
||||||
|
let first_idx = idxs[0];
|
||||||
|
*sel_idx = Some(first_idx);
|
||||||
|
*sel_pid = Some(m.top_processes[first_idx].pid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = sel_idx.unwrap();
|
||||||
|
match idxs.iter().position(|&idx| idx == current_idx) {
|
||||||
|
Some(pos) => {
|
||||||
|
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
|
||||||
|
if new_pos != pos {
|
||||||
|
let new_idx = idxs[new_pos];
|
||||||
|
*sel_idx = Some(new_idx);
|
||||||
|
*sel_pid = Some(m.top_processes[new_idx].pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Current selection no longer in filtered list
|
||||||
|
let first_idx = idxs[0];
|
||||||
|
*sel_idx = Some(first_idx);
|
||||||
|
*sel_pid = Some(m.top_processes[first_idx].pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match params.key.code {
|
match params.key.code {
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
// Navigate through filtered and sorted results
|
move_selection(
|
||||||
if let Some(m) = params.metrics {
|
-1,
|
||||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
params.selected_process_index,
|
||||||
|
params.selected_process_pid,
|
||||||
if idxs.is_empty() {
|
params.metrics,
|
||||||
// No filtered results, clear selection
|
params.filtered_indices,
|
||||||
*params.selected_process_index = None;
|
);
|
||||||
*params.selected_process_pid = None;
|
true
|
||||||
} else if params.selected_process_index.is_none()
|
|
||||||
|| params.selected_process_pid.is_none()
|
|
||||||
{
|
|
||||||
// No selection - select the first process in filtered/sorted order
|
|
||||||
let first_idx = idxs[0];
|
|
||||||
*params.selected_process_index = Some(first_idx);
|
|
||||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
|
||||||
} else if let Some(current_idx) = *params.selected_process_index {
|
|
||||||
// Find current position in filtered/sorted list
|
|
||||||
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
|
|
||||||
if pos > 0 {
|
|
||||||
// Move up in filtered/sorted list
|
|
||||||
let new_idx = idxs[pos - 1];
|
|
||||||
*params.selected_process_index = Some(new_idx);
|
|
||||||
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Current selection not in filtered list, select first result
|
|
||||||
let first_idx = idxs[0];
|
|
||||||
*params.selected_process_index = Some(first_idx);
|
|
||||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true // Handled
|
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
// Navigate through filtered and sorted results
|
move_selection(
|
||||||
if let Some(m) = params.metrics {
|
1,
|
||||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
params.selected_process_index,
|
||||||
|
params.selected_process_pid,
|
||||||
if idxs.is_empty() {
|
params.metrics,
|
||||||
// No filtered results, clear selection
|
params.filtered_indices,
|
||||||
*params.selected_process_index = None;
|
);
|
||||||
*params.selected_process_pid = None;
|
true
|
||||||
} else if params.selected_process_index.is_none()
|
|
||||||
|| params.selected_process_pid.is_none()
|
|
||||||
{
|
|
||||||
// No selection - select the first process in filtered/sorted order
|
|
||||||
let first_idx = idxs[0];
|
|
||||||
*params.selected_process_index = Some(first_idx);
|
|
||||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
|
||||||
} else if let Some(current_idx) = *params.selected_process_index {
|
|
||||||
// Find current position in filtered/sorted list
|
|
||||||
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) {
|
|
||||||
if pos + 1 < idxs.len() {
|
|
||||||
// Move down in filtered/sorted list
|
|
||||||
let new_idx = idxs[pos + 1];
|
|
||||||
*params.selected_process_index = Some(new_idx);
|
|
||||||
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Current selection not in filtered list, select first result
|
|
||||||
let first_idx = idxs[0];
|
|
||||||
*params.selected_process_index = Some(first_idx);
|
|
||||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true // Handled
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('x') | KeyCode::Char('X') => {
|
KeyCode::Char('x') | KeyCode::Char('X')
|
||||||
// Unselect any selected process
|
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
|
||||||
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() {
|
{
|
||||||
*params.selected_process_pid = None;
|
*params.selected_process_pid = None;
|
||||||
*params.selected_process_index = None;
|
*params.selected_process_index = None;
|
||||||
true // Handled
|
true
|
||||||
} else {
|
|
||||||
false // No selection to clear
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('x') | KeyCode::Char('X') => false,
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
// Signal that Enter was pressed with a selection
|
// Signal that Enter was pressed with a selection
|
||||||
params.selected_process_pid.is_some() // Return true if we have a selection to handle
|
params.selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||||
@@ -526,8 +578,11 @@ pub struct ProcessMouseParams<'a> {
|
|||||||
pub area: Rect,
|
pub area: Rect,
|
||||||
pub total_rows: usize,
|
pub total_rows: usize,
|
||||||
pub metrics: Option<&'a Metrics>,
|
pub metrics: Option<&'a Metrics>,
|
||||||
pub sort_by: ProcSortBy,
|
/// True when the on-screen search box is currently being drawn (active
|
||||||
pub search_query: &'a str,
|
/// 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],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enhanced mouse handler that also manages process selection
|
/// Enhanced mouse handler that also manages process selection
|
||||||
@@ -545,9 +600,13 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate content area - must match draw_top_processes exactly!
|
// Calculate content area - must match draw_top_processes exactly!
|
||||||
// If search is active or query exists, content starts after search box (3 lines)
|
// If a search box is being drawn (active edit mode OR a filter showing),
|
||||||
let search_active = !params.search_query.is_empty();
|
// content starts 3 rows below.
|
||||||
let content_start_y = if search_active { inner.y + 3 } else { inner.y };
|
let content_start_y = if params.search_box_visible {
|
||||||
|
inner.y + 3
|
||||||
|
} else {
|
||||||
|
inner.y
|
||||||
|
};
|
||||||
|
|
||||||
let content = Rect {
|
let content = Rect {
|
||||||
x: inner.x,
|
x: inner.x,
|
||||||
@@ -555,7 +614,7 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
|||||||
width: inner.width.saturating_sub(2),
|
width: inner.width.saturating_sub(2),
|
||||||
height: inner
|
height: inner
|
||||||
.height
|
.height
|
||||||
.saturating_sub(if search_active { 3 } else { 0 }),
|
.saturating_sub(if params.search_box_visible { 3 } else { 0 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scrollbar interactions (click arrows/page/drag)
|
// Scrollbar interactions (click arrows/page/drag)
|
||||||
@@ -612,12 +671,8 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
|||||||
{
|
{
|
||||||
let clicked_row = (params.mouse.row - data_start_row) as usize;
|
let clicked_row = (params.mouse.row - data_start_row) as usize;
|
||||||
|
|
||||||
// Find the actual process using the same filtering/sorting logic as the drawing code
|
|
||||||
if let Some(m) = params.metrics {
|
if let Some(m) = params.metrics {
|
||||||
// Use the same filtered and sorted indices as display
|
let idxs = params.filtered_indices;
|
||||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
|
||||||
|
|
||||||
// Calculate which process was actually clicked based on filtered/sorted order
|
|
||||||
let visible_process_position = *params.scroll_offset + clicked_row;
|
let visible_process_position = *params.scroll_offset + clicked_row;
|
||||||
if visible_process_position < idxs.len() {
|
if visible_process_position < idxs.len() {
|
||||||
let actual_process_index = idxs[visible_process_position];
|
let actual_process_index = idxs[visible_process_position];
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
//! 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+331
-234
@@ -23,45 +23,40 @@ use tracing::warn;
|
|||||||
|
|
||||||
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
|
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
|
||||||
|
|
||||||
// Helper functions to get CPU time from /proc/stat on Linux
|
// Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go.
|
||||||
|
// Returns (0, 0) if the file can't be read.
|
||||||
|
//
|
||||||
|
// We use `rfind(')')` to step past the `comm` field, which can contain
|
||||||
|
// arbitrary characters (including spaces and parens), then index the
|
||||||
|
// post-comm fields by position. This is the same trick `read_proc_jiffies`
|
||||||
|
// uses below — `split_whitespace().collect::<Vec<_>>()` from the start of
|
||||||
|
// the file would mis-parse process names with spaces, and also wastes an
|
||||||
|
// allocation per call. Two callers used to read this file twice (once for
|
||||||
|
// user, once for system); now it's one syscall per detailed-process record.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn get_cpu_time_user(pid: u32) -> u64 {
|
fn get_cpu_times_ms(pid: u32) -> (u64, u64) {
|
||||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
return (0, 0);
|
||||||
if fields.len() > 13 {
|
};
|
||||||
// Field 13 (0-indexed) is utime (user CPU time in clock ticks)
|
let Some(rpar) = s.rfind(')') else {
|
||||||
if let Ok(utime) = fields[13].parse::<u64>() {
|
return (0, 0);
|
||||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
};
|
||||||
return utime * 10; // 1 tick = 10ms at 100 Hz
|
let Some(after) = s.get(rpar + 2..) else {
|
||||||
}
|
return (0, 0);
|
||||||
}
|
};
|
||||||
}
|
let mut it = after.split_whitespace();
|
||||||
0
|
// Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid,
|
||||||
}
|
// flags, minflt, cminflt, majflt, cmajflt, utime, stime, ...
|
||||||
|
// utime is offset 11; stime follows.
|
||||||
#[cfg(target_os = "linux")]
|
let utime = it.nth(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||||
fn get_cpu_time_system(pid: u32) -> u64 {
|
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
// 1 tick = 10ms at 100 Hz (USER_HZ).
|
||||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
(utime * 10, stime * 10)
|
||||||
if fields.len() > 14 {
|
|
||||||
// Field 14 (0-indexed) is stime (system CPU time in clock ticks)
|
|
||||||
if let Ok(stime) = fields[14].parse::<u64>() {
|
|
||||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
|
||||||
return stime * 10; // 1 tick = 10ms at 100 Hz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn get_cpu_time_user(_pid: u32) -> u64 {
|
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
|
||||||
0 // Not implemented for non-Linux platforms
|
(0, 0)
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
fn get_cpu_time_system(_pid: u32) -> u64 {
|
|
||||||
0 // Not implemented for non-Linux platforms
|
|
||||||
}
|
}
|
||||||
// Runtime toggles (read once)
|
// Runtime toggles (read once)
|
||||||
fn gpu_enabled() -> bool {
|
fn gpu_enabled() -> bool {
|
||||||
@@ -81,6 +76,47 @@ fn temp_enabled() -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTL knobs read once at first use, then cached. These hit the hot polling
|
||||||
|
// paths (every 250ms-1.5s), so re-reading via libc getenv per call is wasted.
|
||||||
|
fn metrics_ttl_ms() -> u64 {
|
||||||
|
static V: OnceCell<u64> = OnceCell::new();
|
||||||
|
*V.get_or_init(|| {
|
||||||
|
std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(250)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn disks_ttl_ms() -> u64 {
|
||||||
|
static V: OnceCell<u64> = OnceCell::new();
|
||||||
|
*V.get_or_init(|| {
|
||||||
|
std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1_000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn processes_ttl_ms() -> u64 {
|
||||||
|
static V: OnceCell<u64> = OnceCell::new();
|
||||||
|
*V.get_or_init(|| {
|
||||||
|
std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1_500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn name_cache_cleanup_threshold() -> usize {
|
||||||
|
static V: OnceCell<usize> = OnceCell::new();
|
||||||
|
*V.get_or_init(|| {
|
||||||
|
std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Tiny TTL caches to avoid rescanning sensors every 500ms
|
// Tiny TTL caches to avoid rescanning sensors every 500ms
|
||||||
const TTL: Duration = Duration::from_millis(1500);
|
const TTL: Duration = Duration::from_millis(1500);
|
||||||
struct TempCache {
|
struct TempCache {
|
||||||
@@ -89,6 +125,32 @@ struct TempCache {
|
|||||||
}
|
}
|
||||||
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
|
static TEMP: OnceCell<Mutex<TempCache>> = OnceCell::new();
|
||||||
|
|
||||||
|
// Last time `state.components` was refreshed (by any caller). Both
|
||||||
|
// collect_fast_metrics and collect_disks need fresh sensor values; without
|
||||||
|
// this gate they were each doing their own `Components::refresh` on their
|
||||||
|
// own cadence, paying the hwmon syscall cost twice per polling cycle.
|
||||||
|
// 1s is short enough that disk temps stay accurate (they change slowly) and
|
||||||
|
// long enough to suppress back-to-back refreshes from concurrent endpoints.
|
||||||
|
const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000);
|
||||||
|
static COMPONENTS_LAST_REFRESH: OnceCell<Mutex<Option<Instant>>> = OnceCell::new();
|
||||||
|
|
||||||
|
/// Refresh `state.components` only if the cached refresh timestamp is older
|
||||||
|
/// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components
|
||||||
|
/// lock.
|
||||||
|
fn refresh_components_if_stale(components: &mut sysinfo::Components) {
|
||||||
|
let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None));
|
||||||
|
let mut last = match lock.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => return, // Poisoned — skip; values stay as-is until next call
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL);
|
||||||
|
if stale {
|
||||||
|
components.refresh(false);
|
||||||
|
*last = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct GpuCache {
|
struct GpuCache {
|
||||||
at: Option<Instant>,
|
at: Option<Instant>,
|
||||||
v: Option<Vec<crate::gpu::GpuMetrics>>,
|
v: Option<Vec<crate::gpu::GpuMetrics>>,
|
||||||
@@ -154,12 +216,7 @@ fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
|
|||||||
|
|
||||||
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
|
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
|
||||||
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||||
// TTL (ms) overridable via env, default 250ms
|
let ttl = StdDuration::from_millis(metrics_ttl_ms());
|
||||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(250);
|
|
||||||
let ttl = StdDuration::from_millis(ttl_ms);
|
|
||||||
{
|
{
|
||||||
let cache = state.cache_metrics.lock().await;
|
let cache = state.cache_metrics.lock().await;
|
||||||
if cache.is_fresh(ttl)
|
if cache.is_fresh(ttl)
|
||||||
@@ -202,7 +259,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
|||||||
} else if temp_enabled() {
|
} else if temp_enabled() {
|
||||||
let val = {
|
let val = {
|
||||||
let mut components = state.components.lock().await;
|
let mut components = state.components.lock().await;
|
||||||
components.refresh(false);
|
refresh_components_if_stale(&mut components);
|
||||||
components.iter().find_map(|c| {
|
components.iter().find_map(|c| {
|
||||||
let l = c.label().to_ascii_lowercase();
|
let l = c.label().to_ascii_lowercase();
|
||||||
if l.contains("cpu")
|
if l.contains("cpu")
|
||||||
@@ -236,12 +293,19 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
|||||||
});
|
});
|
||||||
let mut cache = cache.lock().unwrap();
|
let mut cache = cache.lock().unwrap();
|
||||||
|
|
||||||
// Collect current network names
|
// Detect a topology change without allocating: compare lengths first,
|
||||||
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
|
// then zip and walk. Only on a real diff do we materialize the new
|
||||||
|
// names list. Was: `nets.keys().map(to_string).collect::<Vec<_>>()`
|
||||||
// Update cached network names if they changed
|
// every tick — a fresh Vec<String> just to compare.
|
||||||
if cache.names != current_names {
|
let topology_changed = cache.names.len() != nets.keys().count()
|
||||||
cache.names = current_names;
|
|| 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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse NetworkInfo objects
|
// Reuse NetworkInfo objects
|
||||||
@@ -319,11 +383,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
|||||||
|
|
||||||
// Cached disks
|
// Cached disks
|
||||||
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
|
let ttl = StdDuration::from_millis(disks_ttl_ms());
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1_000);
|
|
||||||
let ttl = StdDuration::from_millis(ttl_ms);
|
|
||||||
{
|
{
|
||||||
let cache = state.cache_disks.lock().await;
|
let cache = state.cache_disks.lock().await;
|
||||||
if cache.is_fresh(ttl)
|
if cache.is_fresh(ttl)
|
||||||
@@ -339,7 +399,9 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
|||||||
// NVMe temps show up as "Composite" under different chip names
|
// NVMe temps show up as "Composite" under different chip names
|
||||||
let disk_temps = {
|
let disk_temps = {
|
||||||
let mut components = state.components.lock().await;
|
let mut components = state.components.lock().await;
|
||||||
components.refresh(true); // true = refresh values, not just the list
|
// Shared TTL-gated refresh: avoids paying the hwmon scan twice when
|
||||||
|
// both endpoints converge in the same second.
|
||||||
|
refresh_components_if_stale(&mut components);
|
||||||
|
|
||||||
let mut composite_temps = Vec::new();
|
let mut composite_temps = Vec::new();
|
||||||
|
|
||||||
@@ -572,12 +634,7 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
|
|||||||
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
|
/// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
|
let ttl = StdDuration::from_millis(processes_ttl_ms());
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
// Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates).
|
|
||||||
.unwrap_or(1_500);
|
|
||||||
let ttl = StdDuration::from_millis(ttl_ms);
|
|
||||||
{
|
{
|
||||||
let cache = state.cache_processes.lock().await;
|
let cache = state.cache_processes.lock().await;
|
||||||
if cache.is_fresh(ttl)
|
if cache.is_fresh(ttl)
|
||||||
@@ -586,13 +643,24 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
|||||||
return c.clone();
|
return c.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reuse shared System to avoid reallocation; refresh processes fully.
|
// 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.
|
||||||
let mut sys_guard = state.sys.lock().await;
|
let mut sys_guard = state.sys.lock().await;
|
||||||
let sys = &mut *sys_guard;
|
let sys = &mut *sys_guard;
|
||||||
sys.refresh_processes_specifics(
|
sys.refresh_processes_specifics(
|
||||||
ProcessesToUpdate::All,
|
ProcessesToUpdate::All,
|
||||||
false,
|
false,
|
||||||
ProcessRefreshKind::everything().without_tasks(),
|
ProcessRefreshKind::nothing().with_memory().without_tasks(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let total_count = sys.processes().len();
|
let total_count = sys.processes().len();
|
||||||
@@ -607,36 +675,50 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
|||||||
}
|
}
|
||||||
let total_now = read_total_jiffies().unwrap_or(0);
|
let total_now = read_total_jiffies().unwrap_or(0);
|
||||||
|
|
||||||
// Compute deltas vs last sample
|
// Compute deltas vs last sample. We hold the proc_cpu lock for the whole
|
||||||
let (last_total, mut last_map) = {
|
// collection below so we can read+update the per-pid name cache in one
|
||||||
#[cfg(target_os = "linux")]
|
// critical section.
|
||||||
{
|
let mut tracker = state.proc_cpu.lock().await;
|
||||||
let mut t = state.proc_cpu.lock().await;
|
let last_total = tracker.last_total;
|
||||||
let lt = t.last_total;
|
// Move the old per-pid jiffies map out for delta computation.
|
||||||
let lm = std::mem::take(&mut t.last_per_pid);
|
let mut last_map = std::mem::take(&mut tracker.last_per_pid);
|
||||||
t.last_total = total_now;
|
tracker.last_total = total_now;
|
||||||
t.last_per_pid = current.clone();
|
|
||||||
(lt, lm)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
let _: u64 = total_now; // silence unused warning
|
|
||||||
(0u64, HashMap::new())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// On first run or if total delta is tiny, report zeros
|
// 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.
|
||||||
if last_total == 0 || total_now <= last_total {
|
if last_total == 0 || total_now <= last_total {
|
||||||
let procs: Vec<ProcessInfo> = sys
|
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
|
||||||
.processes()
|
for p in sys.processes().values() {
|
||||||
.values()
|
let pid = p.pid().as_u32();
|
||||||
.map(|p| ProcessInfo {
|
let name = resolve_name(&mut tracker, pid, p);
|
||||||
pid: p.pid().as_u32(),
|
procs.push(ProcessInfo {
|
||||||
name: p.name().to_string_lossy().into_owned(),
|
pid,
|
||||||
|
name,
|
||||||
cpu_usage: 0.0,
|
cpu_usage: 0.0,
|
||||||
mem_bytes: p.memory(),
|
mem_bytes: p.memory(),
|
||||||
})
|
});
|
||||||
.collect();
|
}
|
||||||
|
// Stash the just-collected jiffies for next call's delta, then prune
|
||||||
|
// dead pids from the name cache. Borrowing dance: retain reads
|
||||||
|
// `tracker.last_per_pid` through the closure, which conflicts with
|
||||||
|
// the mutable borrow of `tracker.names.retain`. Split via split-borrow:
|
||||||
|
tracker.last_per_pid = current;
|
||||||
|
let crate::state::ProcCpuTracker {
|
||||||
|
ref last_per_pid,
|
||||||
|
ref mut names,
|
||||||
|
..
|
||||||
|
} = *tracker;
|
||||||
|
names.retain(|pid, _| last_per_pid.contains_key(pid));
|
||||||
return ProcessesPayload {
|
return ProcessesPayload {
|
||||||
process_count: total_count,
|
process_count: total_count,
|
||||||
top_processes: procs,
|
top_processes: procs,
|
||||||
@@ -645,23 +727,31 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
|||||||
|
|
||||||
let dt = total_now.saturating_sub(last_total).max(1) as f32;
|
let dt = total_now.saturating_sub(last_total).max(1) as f32;
|
||||||
|
|
||||||
let procs: Vec<ProcessInfo> = sys
|
let mut procs: Vec<ProcessInfo> = Vec::with_capacity(total_count);
|
||||||
.processes()
|
for p in sys.processes().values() {
|
||||||
.values()
|
let pid = p.pid().as_u32();
|
||||||
.map(|p| {
|
let now = current.get(&pid).copied().unwrap_or(0);
|
||||||
let pid = p.pid().as_u32();
|
let prev = last_map.remove(&pid).unwrap_or(0);
|
||||||
let now = current.get(&pid).copied().unwrap_or(0);
|
let du = now.saturating_sub(prev) as f32;
|
||||||
let prev = last_map.remove(&pid).unwrap_or(0);
|
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
|
||||||
let du = now.saturating_sub(prev) as f32;
|
let name = resolve_name(&mut tracker, pid, p);
|
||||||
let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0);
|
procs.push(ProcessInfo {
|
||||||
ProcessInfo {
|
pid,
|
||||||
pid,
|
name,
|
||||||
name: p.name().to_string_lossy().into_owned(),
|
cpu_usage: cpu,
|
||||||
cpu_usage: cpu,
|
mem_bytes: p.memory(),
|
||||||
mem_bytes: p.memory(),
|
});
|
||||||
}
|
}
|
||||||
})
|
// Save current jiffies map for next call and prune dead pids from the
|
||||||
.collect();
|
// 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 payload = ProcessesPayload {
|
let payload = ProcessesPayload {
|
||||||
process_count: total_count,
|
process_count: total_count,
|
||||||
@@ -749,11 +839,8 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
|||||||
// .unwrap_or(std::cmp::Ordering::Equal)
|
// .unwrap_or(std::cmp::Ordering::Equal)
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// Clean up old process names cache when it grows too large
|
// Clean up old process names cache when it grows too large.
|
||||||
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
|
let cache_cleanup_threshold = name_cache_cleanup_threshold();
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
|
|
||||||
|
|
||||||
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
|
if total_count > proc_cache.names.len() + cache_cleanup_threshold {
|
||||||
let now = std::time::Instant::now();
|
let now = std::time::Instant::now();
|
||||||
@@ -811,17 +898,94 @@ fn enumerate_child_processes_lightweight(
|
|||||||
children
|
children
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Single-read extraction of the /proc/{pid}/status fields the detail
|
||||||
|
/// endpoint cares about. Callers used to open this file twice per
|
||||||
|
/// detail-process record (once for VmRSS/VmSize, once for Uid/Gid/Threads/
|
||||||
|
/// State); now it's one read + one scan.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProcStatus {
|
||||||
|
rss_kb: u64,
|
||||||
|
vsize_kb: u64,
|
||||||
|
uid: u32,
|
||||||
|
gid: u32,
|
||||||
|
threads: u32,
|
||||||
|
/// Raw status letter from `State:` (e.g. 'R', 'S'). '?' if missing.
|
||||||
|
state_ch: char,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn read_proc_status(pid: u32) -> Option<ProcStatus> {
|
||||||
|
let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||||
|
let mut out = ProcStatus {
|
||||||
|
state_ch: '?',
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Some(v) = line.strip_prefix("VmRSS:") {
|
||||||
|
out.rss_kb = v
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
} else if let Some(v) = line.strip_prefix("VmSize:") {
|
||||||
|
out.vsize_kb = v
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
} else if let Some(v) = line.strip_prefix("Uid:") {
|
||||||
|
out.uid = v
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
} else if let Some(v) = line.strip_prefix("Gid:") {
|
||||||
|
out.gid = v
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
} else if let Some(v) = line.strip_prefix("Threads:") {
|
||||||
|
out.threads = v.trim().parse().unwrap_or(0);
|
||||||
|
} else if let Some(v) = line.strip_prefix("State:") {
|
||||||
|
out.state_ch = v.trim().chars().next().unwrap_or('?');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn proc_state_label(c: char) -> &'static str {
|
||||||
|
match c {
|
||||||
|
'R' => "Running",
|
||||||
|
'S' => "Sleeping",
|
||||||
|
'D' => "Disk Sleep",
|
||||||
|
'Z' => "Zombie",
|
||||||
|
'T' => "Stopped",
|
||||||
|
't' => "Tracing Stop",
|
||||||
|
'X' | 'x' => "Dead",
|
||||||
|
'K' => "Wakekill",
|
||||||
|
'W' => "Waking",
|
||||||
|
'P' => "Parked",
|
||||||
|
'I' => "Idle",
|
||||||
|
_ => "Unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read parent PID from /proc/{pid}/stat
|
/// Read parent PID from /proc/{pid}/stat
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
|
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
|
||||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
// Format: pid (comm) state ppid ...
|
// Format: pid (comm) state ppid ... — comm can contain spaces/parens,
|
||||||
// We need to handle process names with spaces/parentheses
|
// so we step past the closing paren first.
|
||||||
let ppid_start = stat.rfind(')')?;
|
let ppid_start = stat.rfind(')')?;
|
||||||
let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect();
|
// After ") ": state, ppid, ... — ppid is the second field.
|
||||||
// After the closing paren: state ppid ...
|
stat[ppid_start + 1..]
|
||||||
// Field 1 (0-indexed) is ppid
|
.split_whitespace()
|
||||||
fields.get(1)?.parse::<u32>().ok()
|
.nth(1)?
|
||||||
|
.parse::<u32>()
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect process information from /proc files
|
/// Collect process information from /proc files
|
||||||
@@ -830,8 +994,11 @@ fn collect_process_info_from_proc(
|
|||||||
pid: u32,
|
pid: u32,
|
||||||
system: &sysinfo::System,
|
system: &sysinfo::System,
|
||||||
) -> Option<DetailedProcessInfo> {
|
) -> Option<DetailedProcessInfo> {
|
||||||
// Try to get basic info from sysinfo if it's already loaded (cheap lookup)
|
// One read of /proc/{pid}/status gets us everything the detail endpoint
|
||||||
// Otherwise read from /proc directly
|
// 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)?;
|
||||||
|
|
||||||
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
|
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
|
||||||
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
|
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
|
||||||
(
|
(
|
||||||
@@ -841,30 +1008,13 @@ fn collect_process_info_from_proc(
|
|||||||
proc.virtual_memory(),
|
proc.virtual_memory(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Process not in sysinfo cache, read minimal info from /proc
|
// Process not in sysinfo cache — derive name from /proc/{pid}/comm
|
||||||
|
// and memory from the status read above.
|
||||||
let name = fs::read_to_string(format!("/proc/{pid}/comm"))
|
let name = fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||||
.ok()?
|
.ok()?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
(name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024)
|
||||||
// Read memory from /proc/{pid}/status
|
|
||||||
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
|
||||||
let mut mem_bytes = 0u64;
|
|
||||||
let mut virtual_mem_bytes = 0u64;
|
|
||||||
|
|
||||||
for line in status_content.lines() {
|
|
||||||
if let Some(value) = line.strip_prefix("VmRSS:") {
|
|
||||||
if let Some(kb) = value.split_whitespace().next() {
|
|
||||||
mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
|
|
||||||
}
|
|
||||||
} else if let Some(value) = line.strip_prefix("VmSize:")
|
|
||||||
&& let Some(kb) = value.split_whitespace().next()
|
|
||||||
{
|
|
||||||
virtual_mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(name, 0.0, mem_bytes, virtual_mem_bytes)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read command line
|
// Read command line
|
||||||
@@ -873,54 +1023,21 @@ fn collect_process_info_from_proc(
|
|||||||
.map(|s| s.replace('\0', " ").trim().to_string())
|
.map(|s| s.replace('\0', " ").trim().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Read status information
|
let uid = st.uid;
|
||||||
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
let gid = st.gid;
|
||||||
let mut uid = 0u32;
|
let thread_count = st.threads;
|
||||||
let mut gid = 0u32;
|
let status = proc_state_label(st.state_ch).to_string();
|
||||||
let mut thread_count = 0u32;
|
|
||||||
let mut status = "Unknown".to_string();
|
|
||||||
|
|
||||||
for line in status_content.lines() {
|
// Read start time from stat — comm-safe via rfind(')').
|
||||||
if let Some(value) = line.strip_prefix("Uid:") {
|
|
||||||
if let Some(uid_str) = value.split_whitespace().next() {
|
|
||||||
uid = uid_str.parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
} else if let Some(value) = line.strip_prefix("Gid:") {
|
|
||||||
if let Some(gid_str) = value.split_whitespace().next() {
|
|
||||||
gid = gid_str.parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
} else if let Some(value) = line.strip_prefix("Threads:") {
|
|
||||||
thread_count = value.trim().parse().unwrap_or(0);
|
|
||||||
} else if let Some(value) = line.strip_prefix("State:") {
|
|
||||||
status = value
|
|
||||||
.trim()
|
|
||||||
.chars()
|
|
||||||
.next()
|
|
||||||
.map(|c| match c {
|
|
||||||
'R' => "Running",
|
|
||||||
'S' => "Sleeping",
|
|
||||||
'D' => "Disk Sleep",
|
|
||||||
'Z' => "Zombie",
|
|
||||||
'T' => "Stopped",
|
|
||||||
't' => "Tracing Stop",
|
|
||||||
'X' | 'x' => "Dead",
|
|
||||||
'K' => "Wakekill",
|
|
||||||
'W' => "Waking",
|
|
||||||
'P' => "Parked",
|
|
||||||
'I' => "Idle",
|
|
||||||
_ => "Unknown",
|
|
||||||
})
|
|
||||||
.unwrap_or("Unknown")
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read start time from stat
|
|
||||||
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||||
let stat_end = stat.rfind(')')?;
|
let stat_end = stat.rfind(')')?;
|
||||||
let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect();
|
// After ") ": state, ppid, ..., starttime — starttime is the 20th
|
||||||
// Field 19 (0-indexed) is starttime in clock ticks since boot
|
// post-comm field (index 19).
|
||||||
fields.get(19)?.parse::<u64>().ok()?
|
stat[stat_end + 1..]
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(19)?
|
||||||
|
.parse::<u64>()
|
||||||
|
.ok()?
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -954,6 +1071,9 @@ fn collect_process_info_from_proc(
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|p| p.to_string_lossy().to_string());
|
.map(|p| p.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
// One read of /proc/{pid}/stat covers both user + system CPU times.
|
||||||
|
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
|
||||||
|
|
||||||
Some(DetailedProcessInfo {
|
Some(DetailedProcessInfo {
|
||||||
pid,
|
pid,
|
||||||
name,
|
name,
|
||||||
@@ -969,8 +1089,8 @@ fn collect_process_info_from_proc(
|
|||||||
user_id: uid,
|
user_id: uid,
|
||||||
group_id: gid,
|
group_id: gid,
|
||||||
start_time,
|
start_time,
|
||||||
cpu_time_user: get_cpu_time_user(pid),
|
cpu_time_user,
|
||||||
cpu_time_system: get_cpu_time_system(pid),
|
cpu_time_system,
|
||||||
read_bytes,
|
read_bytes,
|
||||||
write_bytes,
|
write_bytes,
|
||||||
working_directory,
|
working_directory,
|
||||||
@@ -1059,22 +1179,24 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Read thread stat for CPU times and status
|
// Read thread stat for CPU times and status.
|
||||||
let stat_path = format!("/proc/{pid}/task/{tid}/stat");
|
let stat_path = format!("/proc/{pid}/task/{tid}/stat");
|
||||||
let Ok(stat_content) = fs::read_to_string(&stat_path) else {
|
let Ok(stat_content) = fs::read_to_string(&stat_path) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse stat file (similar format to process stat)
|
// Thread/comm names can contain spaces or parens, so step past the
|
||||||
// Fields: pid comm state ... utime stime ...
|
// last ')' before parsing post-comm fields. Post-comm offsets:
|
||||||
let fields: Vec<&str> = stat_content.split_whitespace().collect();
|
// 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime
|
||||||
if fields.len() < 15 {
|
let Some(rpar) = stat_content.rfind(')') else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
let Some(after) = stat_content.get(rpar + 1..) else {
|
||||||
// Field 2 is state (R, S, D, Z, T, etc.)
|
continue;
|
||||||
let status = fields
|
};
|
||||||
.get(2)
|
let mut it = after.split_whitespace();
|
||||||
|
let status = it
|
||||||
|
.next()
|
||||||
.and_then(|s| s.chars().next())
|
.and_then(|s| s.chars().next())
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
'R' => "Running",
|
'R' => "Running",
|
||||||
@@ -1089,16 +1211,9 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
|||||||
.unwrap_or("Unknown")
|
.unwrap_or("Unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Field 13 is utime (user CPU time in clock ticks)
|
// 10 fields between state and utime (ppid..cmajflt).
|
||||||
// Field 14 is stime (system CPU time in clock ticks)
|
let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||||
let utime = fields
|
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||||
.get(13)
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let stime = fields
|
|
||||||
.get(14)
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
// Convert clock ticks to microseconds (assuming 100 Hz)
|
// Convert clock ticks to microseconds (assuming 100 Hz)
|
||||||
// 1 tick = 10ms = 10,000 microseconds
|
// 1 tick = 10ms = 10,000 microseconds
|
||||||
@@ -1167,34 +1282,13 @@ pub async fn collect_process_metrics(
|
|||||||
let parent_pid = process.parent().map(|p| p.as_u32());
|
let parent_pid = process.parent().map(|p| p.as_u32());
|
||||||
let start_time = process.start_time();
|
let start_time = process.start_time();
|
||||||
|
|
||||||
// Read UID and GID directly from /proc/{pid}/status for accuracy
|
// Read UID and GID directly from /proc/{pid}/status for accuracy.
|
||||||
|
// Uses the shared single-read helper (also extracts memory, threads,
|
||||||
|
// state — we discard those here since sysinfo already provided them).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let (user_id, group_id) =
|
let (user_id, group_id) = read_proc_status(pid)
|
||||||
if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
.map(|s| (s.uid, s.gid))
|
||||||
let mut uid = 0u32;
|
.unwrap_or((0, 0));
|
||||||
let mut gid = 0u32;
|
|
||||||
|
|
||||||
for line in status_content.lines() {
|
|
||||||
if let Some(value) = line.strip_prefix("Uid:") {
|
|
||||||
// Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
|
|
||||||
// We want the real UID (first value)
|
|
||||||
if let Some(uid_str) = value.split_whitespace().next() {
|
|
||||||
uid = uid_str.parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
} else if let Some(value) = line.strip_prefix("Gid:") {
|
|
||||||
// Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
|
|
||||||
// We want the real GID (first value)
|
|
||||||
if let Some(gid_str) = value.split_whitespace().next() {
|
|
||||||
gid = gid_str.parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(uid, gid)
|
|
||||||
} else {
|
|
||||||
// Fallback if /proc read fails (permission issue)
|
|
||||||
(0, 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let (user_id, group_id) = (0, 0);
|
let (user_id, group_id) = (0, 0);
|
||||||
@@ -1248,6 +1342,9 @@ pub async fn collect_process_metrics(
|
|||||||
// Collect thread information (Linux only)
|
// Collect thread information (Linux only)
|
||||||
let threads = collect_thread_info(pid);
|
let threads = collect_thread_info(pid);
|
||||||
|
|
||||||
|
// One read of /proc/{pid}/stat covers both user + system CPU times.
|
||||||
|
let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid);
|
||||||
|
|
||||||
// Now construct the detailed info without holding the lock
|
// Now construct the detailed info without holding the lock
|
||||||
let detailed_info = DetailedProcessInfo {
|
let detailed_info = DetailedProcessInfo {
|
||||||
pid,
|
pid,
|
||||||
@@ -1264,8 +1361,8 @@ pub async fn collect_process_metrics(
|
|||||||
user_id,
|
user_id,
|
||||||
group_id,
|
group_id,
|
||||||
start_time,
|
start_time,
|
||||||
cpu_time_user: get_cpu_time_user(pid),
|
cpu_time_user,
|
||||||
cpu_time_system: get_cpu_time_system(pid),
|
cpu_time_system,
|
||||||
read_bytes,
|
read_bytes,
|
||||||
write_bytes,
|
write_bytes,
|
||||||
working_directory,
|
working_directory,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
|
|||||||
pub struct ProcCpuTracker {
|
pub struct ProcCpuTracker {
|
||||||
pub last_total: u64,
|
pub last_total: u64,
|
||||||
pub last_per_pid: HashMap<u32, u64>,
|
pub last_per_pid: HashMap<u32, u64>,
|
||||||
|
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
|
||||||
|
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
|
||||||
|
/// the same `String`s on every processes poll (~once per 1.5s).
|
||||||
|
pub names: HashMap<u32, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
|||||||
+28
-19
@@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
|||||||
Message::Text(ref text) if text == "get_processes" => {
|
Message::Text(ref text) if text == "get_processes" => {
|
||||||
let payload = collect_processes_all(&state).await;
|
let payload = collect_processes_all(&state).await;
|
||||||
|
|
||||||
// Map to protobuf message
|
// Get cached buffers. The Vec capacity is preserved across
|
||||||
// Get cached buffers
|
// calls (with_capacity(512) seeds it, then we swap-back after
|
||||||
|
// encode so the allocation outlives any single request).
|
||||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||||
let mut cache = cache.lock().await;
|
let mut cache = cache.lock().await;
|
||||||
|
|
||||||
// Reuse process vector to build the list
|
|
||||||
cache.processes_vec.clear();
|
cache.processes_vec.clear();
|
||||||
cache
|
cache
|
||||||
.processes_vec
|
.processes_vec
|
||||||
@@ -85,29 +85,38 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
|||||||
mem_bytes: p.mem_bytes,
|
mem_bytes: p.mem_bytes,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let pb = pb::Processes {
|
// 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 {
|
||||||
process_count: payload.process_count as u64,
|
process_count: payload.process_count as u64,
|
||||||
rows: std::mem::take(&mut cache.processes_vec),
|
rows: std::mem::take(&mut cache.processes_vec),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(8 * 1024);
|
let mut buf = Vec::with_capacity(8 * 1024);
|
||||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
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() {
|
||||||
let _ = socket.send(Message::Close(None)).await;
|
let _ = socket.send(Message::Close(None)).await;
|
||||||
|
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
||||||
|
let _ = socket.send(Message::Binary(buf)).await;
|
||||||
} else {
|
} else {
|
||||||
// compress if large
|
// Create a new encoder for each message to ensure proper gzip headers
|
||||||
if buf.len() <= COMPRESSION_THRESHOLD {
|
let mut encoder =
|
||||||
let _ = socket.send(Message::Binary(buf)).await;
|
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||||
} else {
|
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||||
// Create a new encoder for each message to ensure proper gzip headers
|
Ok(compressed) => {
|
||||||
let mut encoder =
|
let _ = socket.send(Message::Binary(compressed)).await;
|
||||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
}
|
||||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
Err(_) => {
|
||||||
Ok(compressed) => {
|
let _ = socket.send(Message::Binary(buf)).await;
|
||||||
let _ = socket.send(Message::Binary(compressed)).await;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = socket.send(Message::Binary(buf)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user