multiple feature and performance improvements (see description)

Here are concise release notes you can paste into your GitHub release.

Release notes — 2025-08-12

Highlights

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

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

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

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

Agent: cargo install socktop_agent --force, then restart your systemd service; if unit changed, systemctl daemon-reload.
TUI: cargo install socktop --force.
Optional envs to trim overhead: SOCKTOP_AGENT_GPU=0, SOCKTOP_AGENT_TEMP=0.
No config or API breaking changes.
This commit is contained in:
2025-08-12 15:52:46 -07:00
parent 5c002f0b2b
commit 0859f50897
18 changed files with 1244 additions and 748 deletions
+2 -1
View File
@@ -9,7 +9,6 @@ license = "MIT"
[dependencies]
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
tungstenite = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
serde = { workspace = true }
@@ -19,3 +18,5 @@ ratatui = { workspace = true }
crossterm = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
tungstenite = "0.27.0"
+109 -9
View File
@@ -25,11 +25,12 @@ use crate::ui::cpu::{
draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp, per_core_content_area,
per_core_handle_key, per_core_handle_mouse, per_core_handle_scrollbar_mouse, PerCoreScrollDrag,
};
use crate::ui::processes::{processes_handle_key, processes_handle_mouse, ProcSortBy};
use crate::ui::{
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
processes::draw_top_processes, swap::draw_swap,
swap::draw_swap,
};
use crate::ws::{connect, request_metrics};
use crate::ws::{connect, request_disks, request_metrics, request_processes};
pub struct App {
// Latest metrics + histories
@@ -53,6 +54,15 @@ pub struct App {
pub per_core_scroll: usize,
pub per_core_drag: Option<PerCoreScrollDrag>, // new: drag state
pub procs_scroll_offset: usize,
pub procs_drag: Option<PerCoreScrollDrag>,
pub procs_sort_by: ProcSortBy,
last_procs_area: Option<ratatui::layout::Rect>,
last_procs_poll: Instant,
last_disks_poll: Instant,
procs_interval: Duration,
disks_interval: Duration,
}
impl App {
@@ -69,6 +79,18 @@ impl App {
should_quit: false,
per_core_scroll: 0,
per_core_drag: None,
procs_scroll_offset: 0,
procs_drag: None,
procs_sort_by: ProcSortBy::CpuDesc,
last_procs_area: None,
last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop
last_disks_poll: Instant::now()
.checked_sub(Duration::from_secs(5))
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
}
}
@@ -143,6 +165,12 @@ impl App {
total_rows,
content.height as usize,
);
if let Some(p_area) = self.last_procs_area {
// page size = visible rows (inner height minus header = 1)
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
processes_handle_key(&mut self.procs_scroll_offset, k, page);
}
}
Event::Mouse(m) => {
// Layout to get areas
@@ -192,6 +220,21 @@ impl App {
total_rows,
content.height as usize,
);
// Processes table: sort by column on header click
if let (Some(mm), Some(p_area)) =
(self.last_metrics.as_ref(), self.last_procs_area)
{
if let Some(new_sort) = processes_handle_mouse(
&mut self.procs_scroll_offset,
&mut self.procs_drag,
m,
p_area,
mm.top_processes.len(),
) {
self.procs_sort_by = new_sort;
}
}
}
Event::Resize(_, _) => {}
_ => {}
@@ -204,6 +247,27 @@ impl App {
// Fetch and update
if let Some(m) = request_metrics(ws).await {
self.update_with_metrics(m);
// Only poll processes every 2s
if self.last_procs_poll.elapsed() >= self.procs_interval {
if let Some(procs) = request_processes(ws).await {
if let Some(mm) = self.last_metrics.as_mut() {
mm.top_processes = procs.top_processes;
mm.process_count = Some(procs.process_count);
}
}
self.last_procs_poll = Instant::now();
}
// Only poll disks every 5s
if self.last_disks_poll.elapsed() >= self.disks_interval {
if let Some(disks) = request_disks(ws).await {
if let Some(mm) = self.last_metrics.as_mut() {
mm.disks = disks;
}
}
self.last_disks_poll = Instant::now();
}
}
// Draw
@@ -216,7 +280,21 @@ impl App {
Ok(())
}
fn update_with_metrics(&mut self, m: Metrics) {
fn update_with_metrics(&mut self, mut m: Metrics) {
if let Some(prev) = &self.last_metrics {
// Preserve slower fields when the fast payload omits them
if m.disks.is_empty() {
m.disks = prev.disks.clone();
}
if m.top_processes.is_empty() {
m.top_processes = prev.top_processes.clone();
}
// Preserve total processes count across fast updates
if m.process_count.is_none() {
m.process_count = prev.process_count;
}
}
// CPU avg history
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
push_capped(&mut self.cpu_hist, v, 600);
@@ -243,6 +321,7 @@ impl App {
self.rx_peak = self.rx_peak.max(rx_kb);
self.tx_peak = self.tx_peak.max(tx_kb);
// Store merged snapshot
self.last_metrics = Some(m);
}
@@ -305,16 +384,16 @@ impl App {
// Bottom area: left = Disks + Network, right = Top Processes
let bottom_lr = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(rows[4]);
// Left bottom: Disks + Net stacked (network "back up")
// Left bottom: Disks + Net stacked (make net panes slightly taller)
let left_stack = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(7), // Disks grow
Constraint::Length(3), // Download
Constraint::Length(3), // Upload
Constraint::Min(4), // Disks shrink slightly
Constraint::Length(5), // Download taller
Constraint::Length(5), // Upload taller
])
.split(bottom_lr[0]);
@@ -343,7 +422,16 @@ impl App {
);
// Right bottom: Top Processes fills the column
draw_top_processes(f, bottom_lr[1], self.last_metrics.as_ref());
let procs_area = bottom_lr[1];
// Cache for input handlers
self.last_procs_area = Some(procs_area);
crate::ui::processes::draw_top_processes(
f,
procs_area,
self.last_metrics.as_ref(),
self.procs_scroll_offset,
self.procs_sort_by,
);
}
}
@@ -361,6 +449,18 @@ impl Default for App {
should_quit: false,
per_core_scroll: 0,
per_core_drag: None,
procs_scroll_offset: 0,
procs_drag: None,
procs_sort_by: ProcSortBy::CpuDesc,
last_procs_area: None,
last_procs_poll: Instant::now()
.checked_sub(Duration::from_secs(2))
.unwrap_or_else(Instant::now), // trigger immediately on first loop
last_disks_poll: Instant::now()
.checked_sub(Duration::from_secs(5))
.unwrap_or_else(Instant::now),
procs_interval: Duration::from_secs(2),
disks_interval: Duration::from_secs(5),
}
}
}
+53 -25
View File
@@ -2,21 +2,7 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct Disk {
pub name: String,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Network {
// cumulative totals; client diffs to compute rates
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone, Deserialize)]
pub struct ProcessInfo {
pub pid: u32,
pub name: String,
@@ -24,15 +10,48 @@ pub struct ProcessInfo {
pub mem_bytes: u64,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct GpuMetrics {
#[derive(Debug, Clone, Deserialize)]
pub struct DiskInfo {
pub name: String,
pub utilization_gpu_pct: u32,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub total: u64,
pub available: u64,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Clone, Deserialize)]
pub struct NetworkInfo {
#[allow(dead_code)]
pub name: String,
pub received: u64,
pub transmitted: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GpuInfo {
pub name: Option<String>,
#[allow(dead_code)]
pub vendor: Option<String>,
// Accept both the new and legacy keys
#[serde(
default,
alias = "utilization_gpu_pct",
alias = "gpu_util_pct",
alias = "gpu_utilization"
)]
pub utilization: Option<f32>,
#[serde(default, alias = "mem_used_bytes", alias = "vram_used_bytes")]
pub mem_used: Option<u64>,
#[serde(default, alias = "mem_total_bytes", alias = "vram_total_bytes")]
pub mem_total: Option<u64>,
#[allow(dead_code)]
#[serde(default, alias = "temp_c", alias = "temperature_c")]
pub temperature: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Metrics {
pub cpu_total: f32,
pub cpu_per_core: Vec<f32>,
@@ -40,11 +59,20 @@ pub struct Metrics {
pub mem_used: u64,
pub swap_total: u64,
pub swap_used: u64,
pub process_count: usize,
pub hostname: String,
pub cpu_temp_c: Option<f32>,
pub disks: Vec<Disk>,
pub networks: Vec<Network>,
pub disks: Vec<DiskInfo>,
pub networks: Vec<NetworkInfo>,
pub top_processes: Vec<ProcessInfo>,
pub gpus: Option<Vec<GpuInfo>>,
// New: keep the last reported total process count
#[serde(default)]
pub process_count: Option<usize>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct ProcessesPayload {
pub process_count: usize,
pub top_processes: Vec<ProcessInfo>,
pub gpus: Option<Vec<GpuMetrics>>,
}
+2 -6
View File
@@ -1,10 +1,11 @@
//! CPU average sparkline + per-core mini bars.
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::style::Modifier;
use ratatui::style::{Color, Style};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Sparkline},
};
@@ -12,11 +13,6 @@ use ratatui::{
use crate::history::PerCoreHistory;
use crate::types::Metrics;
/// Subtle grey theme for the custom scrollbar
const SB_ARROW: Color = Color::Rgb(170, 170, 180);
const SB_TRACK: Color = Color::Rgb(170, 170, 180);
const SB_THUMB: Color = Color::Rgb(170, 170, 180);
/// State for dragging the scrollbar thumb
#[derive(Clone, Copy, Debug, Default)]
pub struct PerCoreScrollDrag {
+6 -9
View File
@@ -82,15 +82,13 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let g = &gpus[i];
// Row 1: GPU name
let name_text = g.name.clone();
f.render_widget(
Paragraph::new(Span::raw(name_text)).style(Style::default().fg(Color::Gray)),
rows[i * 3],
);
let name_text = g.name.as_deref().unwrap_or("GPU");
let name_p = Paragraph::new(Span::raw(name_text)).style(Style::default().fg(Color::Gray));
f.render_widget(name_p, rows[i * 3]);
// Row 2: Utilization bar + right label
let util_cols = split_bar(rows[i * 3 + 1]);
let util = g.utilization_gpu_pct.min(100) as u16;
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
let util_gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.label(Span::raw(""))
@@ -104,8 +102,8 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
// Row 3: VRAM bar + right label
let mem_cols = split_bar(rows[i * 3 + 2]);
let used = g.mem_used_bytes;
let total = g.mem_total_bytes.max(1);
let used = g.mem_used.unwrap_or(0);
let total = g.mem_total.unwrap_or(1);
let mem_ratio = used as f64 / total as f64;
let mem_pct = (mem_ratio * 100.0).round() as u16;
@@ -114,7 +112,6 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
.label(Span::raw(""))
.ratio(mem_ratio);
f.render_widget(mem_gauge, mem_cols[0]);
// Prepare strings to enable captured identifiers in format!
let used_s = fmt_bytes(used);
let total_s = fmt_bytes(total);
f.render_widget(
+1
View File
@@ -8,4 +8,5 @@ pub mod mem;
pub mod net;
pub mod processes;
pub mod swap;
pub mod theme;
pub mod util;
+235 -63
View File
@@ -1,94 +1,266 @@
//! Top processes table with per-cell coloring and zebra striping.
//! Top processes table with per-cell coloring, zebra striping, sorting, and a scrollbar.
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::style::Modifier;
use ratatui::{
layout::{Constraint, Rect},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Cell, Row, Table},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Table},
};
use std::cmp::Ordering;
use crate::types::Metrics;
use crate::ui::cpu::{per_core_clamp, per_core_handle_scrollbar_mouse};
use crate::ui::theme::{SB_ARROW, SB_THUMB, SB_TRACK};
use crate::ui::util::human;
pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
let Some(mm) = m else {
f.render_widget(
Block::default()
.borders(Borders::ALL)
.title("Top Processes"),
area,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProcSortBy {
#[default]
CpuDesc,
MemDesc,
}
// Keep the original header widths here so drawing and hit-testing match.
const COLS: [Constraint; 5] = [
Constraint::Length(8), // PID
Constraint::Percentage(40), // Name
Constraint::Length(8), // CPU %
Constraint::Length(12), // Mem
Constraint::Length(8), // Mem %
];
pub fn draw_top_processes(
f: &mut ratatui::Frame<'_>,
area: Rect,
m: Option<&Metrics>,
scroll_offset: usize,
sort_by: ProcSortBy,
) {
// Draw outer block and title
let Some(mm) = m else { return };
let total = mm.process_count.unwrap_or(mm.top_processes.len());
let block = Block::default()
.borders(Borders::ALL)
.title(format!("Top Processes ({total} total)"));
f.render_widget(block, area);
// Inner area and content area (reserve 2 columns for scrollbar)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height < 1 || inner.width < 3 {
return;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
// Sort rows (by CPU% or Mem bytes), descending.
let mut idxs: Vec<usize> = (0..mm.top_processes.len()).collect();
match sort_by {
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].cpu_usage;
let bb = mm.top_processes[b].cpu_usage;
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
}),
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
let aa = mm.top_processes[a].mem_bytes;
let bb = mm.top_processes[b].mem_bytes;
bb.cmp(&aa)
}),
}
// Scrolling
let total_rows = idxs.len();
let header_rows = 1usize;
let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize;
let max_off = total_rows.saturating_sub(viewport_rows);
let offset = scroll_offset.min(max_off);
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
// Build visible rows
let total_mem_bytes = mm.mem_total.max(1);
let title = format!("Top Processes ({} total)", mm.process_count);
let peak_cpu = mm
.top_processes
.iter()
.map(|p| p.cpu_usage)
.fold(0.0_f32, f32::max);
let rows: Vec<Row> = mm
.top_processes
.iter()
.enumerate()
.map(|(i, p)| {
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
let p = &mm.top_processes[ix];
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
let cpu_fg = match p.cpu_usage {
x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow,
_ => Color::Red,
};
let mem_fg = match mem_pct {
x if x < 5.0 => Color::Blue,
x if x < 20.0 => Color::Magenta,
_ => Color::Red,
};
let cpu_val = p.cpu_usage;
let cpu_fg = match cpu_val {
x if x < 25.0 => Color::Green,
x if x < 60.0 => Color::Yellow,
_ => Color::Red,
};
let mem_fg = match mem_pct {
x if x < 5.0 => Color::Blue,
x if x < 20.0 => Color::Magenta,
_ => Color::Red,
};
let zebra = if i % 2 == 0 {
Style::default().fg(Color::Gray)
} else {
Style::default()
};
let emphasis = if (cpu_val - peak_cpu).abs() < f32::EPSILON {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let emphasis = if (p.cpu_usage - peak_cpu).abs() < f32::EPSILON {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let cpu_str = fmt_cpu_pct(cpu_val);
Row::new(vec![
Cell::from(p.pid.to_string()).style(Style::default().fg(Color::DarkGray)),
Cell::from(p.name.clone()),
Cell::from(format!("{:.1}%", p.cpu_usage)).style(Style::default().fg(cpu_fg)),
Cell::from(human(p.mem_bytes)),
Cell::from(format!("{mem_pct:.2}%")).style(Style::default().fg(mem_fg)),
])
.style(zebra.patch(emphasis))
})
.collect();
ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(p.pid.to_string())
.style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(p.name.clone()),
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
ratatui::widgets::Cell::from(human(p.mem_bytes)),
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
.style(Style::default().fg(mem_fg)),
])
.style(emphasis)
});
let header = Row::new(vec!["PID", "Name", "CPU %", "Mem", "Mem %"]).style(
// Header with sort indicator
let cpu_hdr = match sort_by {
ProcSortBy::CpuDesc => "CPU % •",
_ => "CPU %",
};
let mem_hdr = match sort_by {
ProcSortBy::MemDesc => "Mem •",
_ => "Mem",
};
let header = ratatui::widgets::Row::new(vec!["PID", "Name", cpu_hdr, mem_hdr, "Mem %"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let table = Table::new(
rows,
vec![
Constraint::Length(8),
Constraint::Percentage(40),
Constraint::Length(8),
Constraint::Length(12),
Constraint::Length(8),
],
)
.header(header)
.column_spacing(1)
.block(Block::default().borders(Borders::ALL).title(title));
// Render table inside content area (no borders here; outer block already drawn)
let table = Table::new(rows_iter, COLS.to_vec())
.header(header)
.column_spacing(1);
f.render_widget(table, content);
f.render_widget(table, area);
// Draw scrollbar like CPU pane
let scroll_area = Rect {
x: inner.x + inner.width.saturating_sub(1),
y: inner.y,
width: 1,
height: inner.height,
};
if scroll_area.height >= 3 {
let track = (scroll_area.height - 2) as usize;
let total = total_rows.max(1);
let view = viewport_rows.clamp(1, total);
let max_off = total.saturating_sub(view);
let thumb_len = (track * view).div_ceil(total).max(1).min(track);
let thumb_top = if max_off == 0 {
0
} else {
((track - thumb_len) * offset + max_off / 2) / max_off
};
// Build lines: top arrow, track (with thumb), bottom arrow
let mut lines: Vec<Line> = Vec::with_capacity(scroll_area.height as usize);
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
for i in 0..track {
if i >= thumb_top && i < thumb_top + thumb_len {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_THUMB))));
} else {
lines.push(Line::from(Span::styled("", Style::default().fg(SB_TRACK))));
}
}
lines.push(Line::from(Span::styled("", Style::default().fg(SB_ARROW))));
f.render_widget(Paragraph::new(lines), scroll_area);
}
}
fn fmt_cpu_pct(v: f32) -> String {
format!("{:>5.1}", v.clamp(0.0, 100.0))
}
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
pub fn processes_handle_key(
scroll_offset: &mut usize,
key: crossterm::event::KeyEvent,
page_size: usize,
) {
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
}
/// Handle mouse for content scrolling and scrollbar dragging.
/// Returns Some(new_sort) if the header "CPU %" or "Mem" was clicked.
pub fn processes_handle_mouse(
scroll_offset: &mut usize,
drag: &mut Option<crate::ui::cpu::PerCoreScrollDrag>,
mouse: MouseEvent,
area: Rect,
total_rows: usize,
) -> Option<ProcSortBy> {
// Inner and content areas (match draw_top_processes)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.height == 0 || inner.width <= 2 {
return None;
}
let content = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
};
// Scrollbar interactions (click arrows/page/drag)
per_core_handle_scrollbar_mouse(scroll_offset, drag, mouse, area, total_rows);
// Wheel scrolling when inside the content
crate::ui::cpu::per_core_handle_mouse(scroll_offset, mouse, content, content.height as usize);
// Header click to change sort
let header_area = Rect {
x: content.x,
y: content.y,
width: content.width,
height: 1,
};
let inside_header = mouse.row == header_area.y
&& mouse.column >= header_area.x
&& mouse.column < header_area.x + header_area.width;
if inside_header && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
// Split header into the same columns
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(COLS.to_vec())
.split(header_area);
if mouse.column >= cols[2].x && mouse.column < cols[2].x + cols[2].width {
return Some(ProcSortBy::CpuDesc);
}
if mouse.column >= cols[3].x && mouse.column < cols[3].x + cols[3].width {
return Some(ProcSortBy::MemDesc);
}
}
// Clamp to valid range
per_core_clamp(
scroll_offset,
total_rows,
(content.height.saturating_sub(1)) as usize,
);
None
}
+8
View File
@@ -0,0 +1,8 @@
//! Shared UI theme constants.
use ratatui::style::Color;
// Scrollbar colors (same look as before)
pub const SB_ARROW: Color = Color::Rgb(170, 170, 180);
pub const SB_TRACK: Color = Color::Rgb(170, 170, 180);
pub const SB_THUMB: Color = Color::Rgb(170, 170, 180);
+131 -3
View File
@@ -1,9 +1,13 @@
//! Minimal WebSocket client helpers for requesting metrics from the agent.
use flate2::bufread::GzDecoder;
use futures_util::{SinkExt, StreamExt};
use std::io::Read;
use tokio::net::TcpStream;
use tokio::time::{interval, Duration};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use crate::types::Metrics;
use crate::types::{DiskInfo, Metrics, ProcessesPayload};
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
@@ -19,10 +23,134 @@ pub async fn request_metrics(ws: &mut WsStream) -> Option<Metrics> {
return None;
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Metrics>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<Metrics>(&json).ok(),
_ => None,
}
}
// Re-export SinkExt/StreamExt for call sites
use futures_util::{SinkExt, StreamExt};
// Decompress a gzip-compressed binary frame into a String.
fn gunzip_to_string(bytes: &[u8]) -> Option<String> {
let mut dec = GzDecoder::new(bytes);
let mut out = String::new();
dec.read_to_string(&mut out).ok()?;
Some(out)
}
// Suppress dead_code until these are wired into the app
#[allow(dead_code)]
pub enum Payload {
Metrics(Metrics),
Disks(Vec<DiskInfo>),
Processes(ProcessesPayload),
}
#[allow(dead_code)]
fn parse_any_payload(json: &str) -> Result<Payload, serde_json::Error> {
if let Ok(m) = serde_json::from_str::<Metrics>(json) {
return Ok(Payload::Metrics(m));
}
if let Ok(d) = serde_json::from_str::<Vec<DiskInfo>>(json) {
return Ok(Payload::Disks(d));
}
if let Ok(p) = serde_json::from_str::<ProcessesPayload>(json) {
return Ok(Payload::Processes(p));
}
Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unknown payload",
)))
}
// Send a "get_disks" request and await a JSON Vec<DiskInfo>
pub async fn request_disks(ws: &mut WsStream) -> Option<Vec<DiskInfo>> {
if ws.send(Message::Text("get_disks".into())).await.is_err() {
return None;
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<Vec<DiskInfo>>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<Vec<DiskInfo>>(&json).ok(),
_ => None,
}
}
// Send a "get_processes" request and await a JSON ProcessesPayload
pub async fn request_processes(ws: &mut WsStream) -> Option<ProcessesPayload> {
if ws
.send(Message::Text("get_processes".into()))
.await
.is_err()
{
return None;
}
match ws.next().await {
Some(Ok(Message::Binary(b))) => {
gunzip_to_string(&b).and_then(|s| serde_json::from_str::<ProcessesPayload>(&s).ok())
}
Some(Ok(Message::Text(json))) => serde_json::from_str::<ProcessesPayload>(&json).ok(),
_ => None,
}
}
#[allow(dead_code)]
pub async fn start_ws_polling(mut ws: WsStream) {
let mut t_fast = interval(Duration::from_millis(500));
let mut t_procs = interval(Duration::from_secs(2));
let mut t_disks = interval(Duration::from_secs(5));
let _ = ws.send(Message::Text("get_metrics".into())).await;
let _ = ws.send(Message::Text("get_processes".into())).await;
let _ = ws.send(Message::Text("get_disks".into())).await;
loop {
tokio::select! {
_ = t_fast.tick() => {
let _ = ws.send(Message::Text("get_metrics".into())).await;
}
_ = t_procs.tick() => {
let _ = ws.send(Message::Text("get_processes".into())).await;
}
_ = t_disks.tick() => {
let _ = ws.send(Message::Text("get_disks".into())).await;
}
maybe = ws.next() => {
let Some(result) = maybe else { break; };
let Ok(msg) = result else { break; };
match msg {
Message::Binary(b) => {
if let Some(json) = gunzip_to_string(&b) {
if let Ok(payload) = parse_any_payload(&json) {
match payload {
Payload::Metrics(_m) => {
// update your app state with fast metrics
}
Payload::Disks(_d) => {
// update your app state with disks
}
Payload::Processes(_p) => {
// update your app state with processes
}
}
}
}
}
Message::Text(s) => {
if let Ok(payload) = parse_any_payload(&s) {
match payload {
Payload::Metrics(_m) => {}
Payload::Disks(_d) => {}
Payload::Processes(_p) => {}
}
}
}
Message::Close(_) => break,
_ => {}
}
}
}
}
}