initial check for process summary screen
This check in offers alpha support for per process metrics, you can view threads, process CPU usage over time, IO, memory, CPU time, parent process, command, uptime and journal entries. This is unfinished but all major functionality is available and I wanted to make it available to feedback and testing.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
//! Caching for process metrics and journal entries
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::types::{ProcessMetricsResponse, JournalResponse};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheEntry<T> {
|
||||
data: T,
|
||||
cached_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.cached_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessCache {
|
||||
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
|
||||
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
|
||||
}
|
||||
|
||||
impl ProcessCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
process_metrics: RwLock::new(HashMap::new()),
|
||||
journal_entries: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached process metrics if available and not expired (250ms TTL)
|
||||
pub async fn get_process_metrics(&self, pid: u32) -> Option<ProcessMetricsResponse> {
|
||||
let cache = self.process_metrics.read().await;
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
if !entry.is_expired() {
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Cache process metrics with 250ms TTL
|
||||
pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) {
|
||||
let mut cache = self.process_metrics.write().await;
|
||||
cache.insert(pid, CacheEntry {
|
||||
data,
|
||||
cached_at: Instant::now(),
|
||||
ttl: Duration::from_millis(250),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get cached journal entries if available and not expired (1s TTL)
|
||||
pub async fn get_journal_entries(&self, pid: u32) -> Option<JournalResponse> {
|
||||
let cache = self.journal_entries.read().await;
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
if !entry.is_expired() {
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Cache journal entries with 1s TTL
|
||||
pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) {
|
||||
let mut cache = self.journal_entries.write().await;
|
||||
cache.insert(pid, CacheEntry {
|
||||
data,
|
||||
cached_at: Instant::now(),
|
||||
ttl: Duration::from_secs(1),
|
||||
});
|
||||
}
|
||||
|
||||
/// Clean up expired entries periodically
|
||||
pub async fn cleanup_expired(&self) {
|
||||
{
|
||||
let mut cache = self.process_metrics.write().await;
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
}
|
||||
{
|
||||
let mut cache = self.journal_entries.write().await;
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Library interface for socktop_agent functionality
|
||||
//! This allows testing of agent functions.
|
||||
|
||||
pub mod gpu;
|
||||
pub mod metrics;
|
||||
pub mod proto;
|
||||
pub mod state;
|
||||
pub mod tls;
|
||||
pub mod types;
|
||||
pub mod ws;
|
||||
|
||||
// Re-export commonly used types and functions for testing
|
||||
pub use metrics::{collect_journal_entries, collect_process_metrics};
|
||||
pub use state::{AppState, CacheEntry};
|
||||
pub use types::{
|
||||
DetailedProcessInfo, JournalEntry, JournalResponse, LogLevel, ProcessMetricsResponse,
|
||||
};
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use crate::gpu::collect_all_gpus;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{DiskInfo, Metrics, NetworkInfo, ProcessInfo, ProcessesPayload};
|
||||
use crate::types::{
|
||||
DetailedProcessInfo, DiskInfo, JournalEntry, JournalResponse, LogLevel, Metrics, NetworkInfo,
|
||||
ProcessInfo, ProcessMetricsResponse, ProcessesPayload,
|
||||
};
|
||||
use once_cell::sync::OnceCell;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::collections::HashMap;
|
||||
@@ -10,13 +13,55 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::io;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration as StdDuration;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate};
|
||||
use tracing::warn;
|
||||
|
||||
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
|
||||
|
||||
// Helper functions to get CPU time from /proc/stat on Linux
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_cpu_time_user(pid: u32) -> u64 {
|
||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
||||
if fields.len() > 13 {
|
||||
// Field 13 (0-indexed) is utime (user CPU time in clock ticks)
|
||||
if let Ok(utime) = fields[13].parse::<u64>() {
|
||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
||||
return utime * 10; // 1 tick = 10ms at 100 Hz
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_cpu_time_system(pid: u32) -> u64 {
|
||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
||||
if fields.len() > 14 {
|
||||
// Field 14 (0-indexed) is stime (system CPU time in clock ticks)
|
||||
if let Ok(stime) = fields[14].parse::<u64>() {
|
||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
||||
return stime * 10; // 1 tick = 10ms at 100 Hz
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn get_cpu_time_user(_pid: u32) -> u64 {
|
||||
0 // Not implemented for non-Linux platforms
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn get_cpu_time_system(_pid: u32) -> u64 {
|
||||
0 // Not implemented for non-Linux platforms
|
||||
}
|
||||
// Runtime toggles (read once)
|
||||
fn gpu_enabled() -> bool {
|
||||
static ON: OnceCell<bool> = OnceCell::new();
|
||||
@@ -549,3 +594,616 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
/// Lightweight child process enumeration using direct /proc access
|
||||
/// This avoids the expensive refresh_processes_specifics(All) call
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enumerate_child_processes_lightweight(
|
||||
parent_pid: u32,
|
||||
system: &sysinfo::System,
|
||||
) -> Vec<DetailedProcessInfo> {
|
||||
let mut children = Vec::new();
|
||||
|
||||
// Read /proc to find all child processes
|
||||
// This is much faster than refresh_processes_specifics(All)
|
||||
if let Ok(entries) = fs::read_dir("/proc") {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(file_name) = entry.file_name().into_string() {
|
||||
if let Ok(pid) = file_name.parse::<u32>() {
|
||||
// Check if this process is a child of our target
|
||||
if let Some(child_parent_pid) = read_parent_pid_from_proc(pid) {
|
||||
if child_parent_pid == parent_pid {
|
||||
// Found a child! Collect its details from /proc
|
||||
if let Some(child_info) = collect_process_info_from_proc(pid, system) {
|
||||
children.push(child_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
children
|
||||
}
|
||||
|
||||
/// Read parent PID from /proc/{pid}/stat
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_parent_pid_from_proc(pid: u32) -> Option<u32> {
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
// Format: pid (comm) state ppid ...
|
||||
// We need to handle process names with spaces/parentheses
|
||||
let ppid_start = stat.rfind(')')?;
|
||||
let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect();
|
||||
// After the closing paren: state ppid ...
|
||||
// Field 1 (0-indexed) is ppid
|
||||
fields.get(1)?.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Collect process information from /proc files
|
||||
#[cfg(target_os = "linux")]
|
||||
fn collect_process_info_from_proc(
|
||||
pid: u32,
|
||||
system: &sysinfo::System,
|
||||
) -> Option<DetailedProcessInfo> {
|
||||
// Try to get basic info from sysinfo if it's already loaded (cheap lookup)
|
||||
// Otherwise read from /proc directly
|
||||
let (name, cpu_usage, mem_bytes, virtual_mem_bytes) =
|
||||
if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) {
|
||||
(
|
||||
proc.name().to_string_lossy().to_string(),
|
||||
proc.cpu_usage(),
|
||||
proc.memory(),
|
||||
proc.virtual_memory(),
|
||||
)
|
||||
} else {
|
||||
// Process not in sysinfo cache, read minimal info from /proc
|
||||
let name = fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// 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:") {
|
||||
if 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
|
||||
let command = fs::read_to_string(format!("/proc/{pid}/cmdline"))
|
||||
.ok()
|
||||
.map(|s| s.replace('\0', " ").trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Read status information
|
||||
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let mut uid = 0u32;
|
||||
let mut gid = 0u32;
|
||||
let mut thread_count = 0u32;
|
||||
let mut status = "Unknown".to_string();
|
||||
|
||||
for line in status_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Uid:") {
|
||||
if let Some(uid_str) = value.split_whitespace().next() {
|
||||
uid = uid_str.parse().unwrap_or(0);
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Gid:") {
|
||||
if let Some(gid_str) = value.split_whitespace().next() {
|
||||
gid = gid_str.parse().unwrap_or(0);
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Threads:") {
|
||||
thread_count = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("State:") {
|
||||
status = value
|
||||
.trim()
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| match c {
|
||||
'R' => "Running",
|
||||
'S' => "Sleeping",
|
||||
'D' => "Disk Sleep",
|
||||
'Z' => "Zombie",
|
||||
'T' => "Stopped",
|
||||
't' => "Tracing Stop",
|
||||
'X' | 'x' => "Dead",
|
||||
'K' => "Wakekill",
|
||||
'W' => "Waking",
|
||||
'P' => "Parked",
|
||||
'I' => "Idle",
|
||||
_ => "Unknown",
|
||||
})
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Read start time from stat
|
||||
let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
let stat_end = stat.rfind(')')?;
|
||||
let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect();
|
||||
// Field 19 (0-indexed) is starttime in clock ticks since boot
|
||||
fields.get(19)?.parse::<u64>().ok()?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Read I/O stats if available
|
||||
let (read_bytes, write_bytes) =
|
||||
if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) {
|
||||
let mut read_bytes = None;
|
||||
let mut write_bytes = None;
|
||||
|
||||
for line in io_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("read_bytes:") {
|
||||
read_bytes = value.trim().parse().ok();
|
||||
} else if let Some(value) = line.strip_prefix("write_bytes:") {
|
||||
write_bytes = value.trim().parse().ok();
|
||||
}
|
||||
}
|
||||
|
||||
(read_bytes, write_bytes)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Read working directory
|
||||
let working_directory = fs::read_link(format!("/proc/{pid}/cwd"))
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
|
||||
// Read executable path
|
||||
let executable_path = fs::read_link(format!("/proc/{pid}/exe"))
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
|
||||
Some(DetailedProcessInfo {
|
||||
pid,
|
||||
name,
|
||||
command,
|
||||
cpu_usage,
|
||||
mem_bytes,
|
||||
virtual_mem_bytes,
|
||||
shared_mem_bytes: None, // Would need to parse /proc/{pid}/statm for this
|
||||
thread_count,
|
||||
fd_count: None, // Would need to count entries in /proc/{pid}/fd
|
||||
status,
|
||||
parent_pid: None, // We already know the parent
|
||||
user_id: uid,
|
||||
group_id: gid,
|
||||
start_time,
|
||||
cpu_time_user: get_cpu_time_user(pid),
|
||||
cpu_time_system: get_cpu_time_system(pid),
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
working_directory,
|
||||
executable_path,
|
||||
child_processes: Vec::new(), // Don't recurse
|
||||
threads: Vec::new(), // Not collected for child processes
|
||||
})
|
||||
}
|
||||
|
||||
/// Fallback for non-Linux: use sysinfo (less efficient but functional)
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn enumerate_child_processes_lightweight(
|
||||
parent_pid: u32,
|
||||
system: &sysinfo::System,
|
||||
) -> Vec<DetailedProcessInfo> {
|
||||
let mut children = Vec::new();
|
||||
|
||||
// On non-Linux, we have to iterate through all processes in sysinfo
|
||||
// This is less efficient but maintains cross-platform compatibility
|
||||
for (child_pid, child_process) in system.processes() {
|
||||
if let Some(parent) = child_process.parent() {
|
||||
if parent.as_u32() == parent_pid {
|
||||
let child_info = DetailedProcessInfo {
|
||||
pid: child_pid.as_u32(),
|
||||
name: child_process.name().to_string_lossy().to_string(),
|
||||
command: child_process
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
cpu_usage: child_process.cpu_usage(),
|
||||
mem_bytes: child_process.memory(),
|
||||
virtual_mem_bytes: child_process.virtual_memory(),
|
||||
shared_mem_bytes: None,
|
||||
thread_count: child_process
|
||||
.tasks()
|
||||
.map(|tasks| tasks.len() as u32)
|
||||
.unwrap_or(0),
|
||||
fd_count: None,
|
||||
status: format!("{:?}", child_process.status()),
|
||||
parent_pid: Some(parent_pid),
|
||||
// On non-Linux platforms, sysinfo UID/GID might not be accurate
|
||||
// Just use 0 as placeholder since we can't read /proc
|
||||
user_id: 0,
|
||||
group_id: 0,
|
||||
start_time: child_process.start_time(),
|
||||
cpu_time_user: 0, // Not available on non-Linux in our implementation
|
||||
cpu_time_system: 0,
|
||||
read_bytes: Some(child_process.disk_usage().read_bytes),
|
||||
write_bytes: Some(child_process.disk_usage().written_bytes),
|
||||
working_directory: child_process.cwd().map(|p| p.to_string_lossy().to_string()),
|
||||
executable_path: child_process.exe().map(|p| p.to_string_lossy().to_string()),
|
||||
child_processes: Vec::new(),
|
||||
threads: Vec::new(), // Not collected for non-Linux
|
||||
};
|
||||
children.push(child_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
children
|
||||
}
|
||||
|
||||
/// Collect thread information for a specific process (Linux only)
|
||||
#[cfg(target_os = "linux")]
|
||||
fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
||||
let mut threads = Vec::new();
|
||||
|
||||
// Read /proc/{pid}/task directory
|
||||
let task_dir = format!("/proc/{pid}/task");
|
||||
let Ok(entries) = fs::read_dir(&task_dir) else {
|
||||
return threads;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let tid_str = file_name.to_string_lossy();
|
||||
let Ok(tid) = tid_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Read thread name from comm
|
||||
let name = fs::read_to_string(format!("/proc/{pid}/task/{tid}/comm"))
|
||||
.unwrap_or_else(|_| format!("Thread-{tid}"))
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Read thread stat for CPU times and status
|
||||
let stat_path = format!("/proc/{pid}/task/{tid}/stat");
|
||||
let Ok(stat_content) = fs::read_to_string(&stat_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Parse stat file (similar format to process stat)
|
||||
// Fields: pid comm state ... utime stime ...
|
||||
let fields: Vec<&str> = stat_content.split_whitespace().collect();
|
||||
if fields.len() < 15 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field 2 is state (R, S, D, Z, T, etc.)
|
||||
let status = fields
|
||||
.get(2)
|
||||
.and_then(|s| s.chars().next())
|
||||
.map(|c| match c {
|
||||
'R' => "Running",
|
||||
'S' => "Sleeping",
|
||||
'D' => "Disk Sleep",
|
||||
'Z' => "Zombie",
|
||||
'T' => "Stopped",
|
||||
't' => "Tracing Stop",
|
||||
'X' | 'x' => "Dead",
|
||||
_ => "Unknown",
|
||||
})
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
// Field 13 is utime (user CPU time in clock ticks)
|
||||
// Field 14 is stime (system CPU time in clock ticks)
|
||||
let utime = fields
|
||||
.get(13)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let stime = fields
|
||||
.get(14)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Convert clock ticks to microseconds (assuming 100 Hz)
|
||||
// 1 tick = 10ms = 10,000 microseconds
|
||||
let cpu_time_user = utime * 10_000;
|
||||
let cpu_time_system = stime * 10_000;
|
||||
|
||||
threads.push(crate::types::ThreadInfo {
|
||||
tid,
|
||||
name,
|
||||
cpu_time_user,
|
||||
cpu_time_system,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
threads
|
||||
}
|
||||
|
||||
/// Fallback for non-Linux: return empty thread list
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn collect_thread_info(_pid: u32) -> Vec<crate::types::ThreadInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Collect detailed metrics for a specific process
|
||||
pub async fn collect_process_metrics(
|
||||
pid: u32,
|
||||
state: &AppState,
|
||||
) -> Result<ProcessMetricsResponse, String> {
|
||||
let mut system = state.sys.lock().await;
|
||||
|
||||
// OPTIMIZED: Only refresh the specific process we care about
|
||||
// This avoids polluting the main process list with threads and prevents race conditions
|
||||
system.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]),
|
||||
false,
|
||||
ProcessRefreshKind::nothing()
|
||||
.with_memory()
|
||||
.with_cpu()
|
||||
.with_disk_usage(),
|
||||
);
|
||||
|
||||
let process = system
|
||||
.process(sysinfo::Pid::from_u32(pid))
|
||||
.ok_or_else(|| format!("Process {pid} not found"))?;
|
||||
|
||||
// Get current timestamp
|
||||
let cached_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| format!("Time error: {e}"))?
|
||||
.as_secs();
|
||||
|
||||
// Extract all needed data from process while we have the lock
|
||||
let name = process.name().to_string_lossy().to_string();
|
||||
let command = process
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let cpu_usage = process.cpu_usage();
|
||||
let mem_bytes = process.memory();
|
||||
let virtual_mem_bytes = process.virtual_memory();
|
||||
let thread_count = process.tasks().map(|tasks| tasks.len() as u32).unwrap_or(0);
|
||||
let status = format!("{:?}", process.status());
|
||||
let parent_pid = process.parent().map(|p| p.as_u32());
|
||||
let start_time = process.start_time();
|
||||
|
||||
// Read UID and GID directly from /proc/{pid}/status for accuracy
|
||||
let (user_id, group_id) =
|
||||
if let Ok(status_content) = fs::read_to_string(format!("/proc/{pid}/status")) {
|
||||
let mut uid = 0u32;
|
||||
let mut gid = 0u32;
|
||||
|
||||
for line in status_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Uid:") {
|
||||
// Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
|
||||
// We want the real UID (first value)
|
||||
if let Some(uid_str) = value.split_whitespace().next() {
|
||||
uid = uid_str.parse().unwrap_or(0);
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Gid:") {
|
||||
// Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
|
||||
// We want the real GID (first value)
|
||||
if let Some(gid_str) = value.split_whitespace().next() {
|
||||
gid = gid_str.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(uid, gid)
|
||||
} else {
|
||||
// Fallback if /proc read fails (non-Linux or permission issue)
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
// Read I/O stats directly from /proc/{pid}/io
|
||||
// Use rchar/wchar to capture ALL I/O including cached reads (like htop/btop do)
|
||||
// sysinfo's total_read_bytes/total_written_bytes only count actual disk I/O
|
||||
let (read_bytes, write_bytes) =
|
||||
if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) {
|
||||
let mut rchar = 0u64;
|
||||
let mut wchar = 0u64;
|
||||
|
||||
for line in io_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("rchar: ") {
|
||||
rchar = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("wchar: ") {
|
||||
wchar = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
(Some(rchar), Some(wchar))
|
||||
} else {
|
||||
// Fallback to sysinfo if we can't read /proc (permissions, non-Linux, etc.)
|
||||
let disk_usage = process.disk_usage();
|
||||
(
|
||||
Some(disk_usage.total_read_bytes),
|
||||
Some(disk_usage.total_written_bytes),
|
||||
)
|
||||
};
|
||||
let working_directory = process.cwd().map(|p| p.to_string_lossy().to_string());
|
||||
let executable_path = process.exe().map(|p| p.to_string_lossy().to_string());
|
||||
|
||||
// Collect child processes using lightweight /proc access
|
||||
// This avoids the expensive system.refresh_processes_specifics(All) call
|
||||
let child_processes = enumerate_child_processes_lightweight(pid, &system);
|
||||
|
||||
// Release the system lock early (automatic when system goes out of scope)
|
||||
drop(system);
|
||||
|
||||
// Collect thread information (Linux only)
|
||||
let threads = collect_thread_info(pid);
|
||||
|
||||
// Now construct the detailed info without holding the lock
|
||||
let detailed_info = DetailedProcessInfo {
|
||||
pid,
|
||||
name,
|
||||
command,
|
||||
cpu_usage,
|
||||
mem_bytes,
|
||||
virtual_mem_bytes,
|
||||
shared_mem_bytes: None, // Not available from sysinfo
|
||||
thread_count,
|
||||
fd_count: None, // Not available from sysinfo on all platforms
|
||||
status,
|
||||
parent_pid,
|
||||
user_id,
|
||||
group_id,
|
||||
start_time,
|
||||
cpu_time_user: get_cpu_time_user(pid),
|
||||
cpu_time_system: get_cpu_time_system(pid),
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
working_directory,
|
||||
executable_path,
|
||||
child_processes,
|
||||
threads,
|
||||
};
|
||||
|
||||
Ok(ProcessMetricsResponse {
|
||||
process: detailed_info,
|
||||
cached_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect journal entries for a specific process
|
||||
pub fn collect_journal_entries(pid: u32) -> Result<JournalResponse, String> {
|
||||
let output = Command::new("journalctl")
|
||||
.args([
|
||||
&format!("_PID={pid}"),
|
||||
"--output=json",
|
||||
"--lines=100",
|
||||
"--no-pager",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute journalctl: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"journalctl failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut entries = Vec::new();
|
||||
|
||||
// Parse each line as JSON (journalctl outputs one JSON object per line)
|
||||
for line in stdout.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("Failed to parse journal JSON: {e}"))?;
|
||||
|
||||
// Extract relevant fields
|
||||
let timestamp_str = json
|
||||
.get("__REALTIME_TIMESTAMP")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0");
|
||||
|
||||
// Convert timestamp to ISO 8601 format
|
||||
let timestamp = if let Ok(ts_micros) = timestamp_str.parse::<u64>() {
|
||||
let ts_secs = ts_micros / 1_000_000;
|
||||
let ts_nanos = (ts_micros % 1_000_000) * 1000;
|
||||
let time = SystemTime::UNIX_EPOCH
|
||||
+ Duration::from_secs(ts_secs)
|
||||
+ Duration::from_nanos(ts_nanos);
|
||||
// Simple ISO 8601 format - we can improve this if needed
|
||||
format!("{time:?}")
|
||||
.replace("SystemTime { tv_sec: ", "")
|
||||
.replace(", tv_nsec: ", ".")
|
||||
.replace(" }", "")
|
||||
} else {
|
||||
timestamp_str.to_string()
|
||||
};
|
||||
|
||||
let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) {
|
||||
Some("0") => LogLevel::Emergency,
|
||||
Some("1") => LogLevel::Alert,
|
||||
Some("2") => LogLevel::Critical,
|
||||
Some("3") => LogLevel::Error,
|
||||
Some("4") => LogLevel::Warning,
|
||||
Some("5") => LogLevel::Notice,
|
||||
Some("6") => LogLevel::Info,
|
||||
Some("7") => LogLevel::Debug,
|
||||
_ => LogLevel::Info,
|
||||
};
|
||||
|
||||
let message = json
|
||||
.get("MESSAGE")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let unit = json
|
||||
.get("_SYSTEMD_UNIT")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let entry_pid = json
|
||||
.get("_PID")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
let comm = json
|
||||
.get("_COMM")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let uid = json
|
||||
.get("_UID")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
let gid = json
|
||||
.get("_GID")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
entries.push(JournalEntry {
|
||||
timestamp,
|
||||
priority,
|
||||
message,
|
||||
unit,
|
||||
pid: entry_pid,
|
||||
comm,
|
||||
uid,
|
||||
gid,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
let response_timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| format!("Time error: {e}"))?
|
||||
.as_secs();
|
||||
|
||||
let total_count = entries.len() as u32;
|
||||
let truncated = entries.len() >= 100; // We requested 100 lines, so if we got 100, there might be more
|
||||
|
||||
Ok(JournalResponse {
|
||||
entries,
|
||||
total_count,
|
||||
truncated,
|
||||
cached_at: response_timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ pub struct AppState {
|
||||
pub cache_metrics: Arc<Mutex<CacheEntry<crate::types::Metrics>>>,
|
||||
pub cache_disks: Arc<Mutex<CacheEntry<Vec<crate::types::DiskInfo>>>>,
|
||||
pub cache_processes: Arc<Mutex<CacheEntry<crate::types::ProcessesPayload>>>,
|
||||
|
||||
// Process detail caches (per-PID)
|
||||
pub cache_process_metrics:
|
||||
Arc<Mutex<HashMap<u32, CacheEntry<crate::types::ProcessMetricsResponse>>>>,
|
||||
pub cache_journal_entries: Arc<Mutex<HashMap<u32, CacheEntry<crate::types::JournalResponse>>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -71,6 +76,12 @@ pub struct CacheEntry<T> {
|
||||
pub value: Option<T>,
|
||||
}
|
||||
|
||||
impl<T> Default for CacheEntry<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -90,6 +101,12 @@ impl<T> CacheEntry<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let sys = System::new();
|
||||
@@ -116,6 +133,8 @@ impl AppState {
|
||||
cache_metrics: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_disks: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_processes: Arc::new(Mutex::new(CacheEntry::new())),
|
||||
cache_process_metrics: Arc::new(Mutex::new(HashMap::new())),
|
||||
cache_journal_entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,76 @@ pub struct ProcessesPayload {
|
||||
pub process_count: usize,
|
||||
pub top_processes: Vec<ProcessInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub tid: u32, // Thread ID
|
||||
pub name: String, // Thread name (from /proc/{pid}/task/{tid}/comm)
|
||||
pub cpu_time_user: u64, // User CPU time in microseconds
|
||||
pub cpu_time_system: u64, // System CPU time in microseconds
|
||||
pub status: String, // Thread status (Running, Sleeping, etc.)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DetailedProcessInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub cpu_usage: f32,
|
||||
pub mem_bytes: u64,
|
||||
pub virtual_mem_bytes: u64,
|
||||
pub shared_mem_bytes: Option<u64>,
|
||||
pub thread_count: u32,
|
||||
pub fd_count: Option<u32>,
|
||||
pub status: String,
|
||||
pub parent_pid: Option<u32>,
|
||||
pub user_id: u32,
|
||||
pub group_id: u32,
|
||||
pub start_time: u64, // Unix timestamp
|
||||
pub cpu_time_user: u64, // Microseconds
|
||||
pub cpu_time_system: u64, // Microseconds
|
||||
pub read_bytes: Option<u64>,
|
||||
pub write_bytes: Option<u64>,
|
||||
pub working_directory: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub child_processes: Vec<DetailedProcessInfo>,
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProcessMetricsResponse {
|
||||
pub process: DetailedProcessInfo,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalEntry {
|
||||
pub timestamp: String, // ISO 8601 formatted timestamp
|
||||
pub priority: LogLevel,
|
||||
pub message: String,
|
||||
pub unit: Option<String>, // systemd unit name
|
||||
pub pid: Option<u32>,
|
||||
pub comm: Option<String>, // process command name
|
||||
pub uid: Option<u32>,
|
||||
pub gid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum LogLevel {
|
||||
Emergency = 0,
|
||||
Alert = 1,
|
||||
Critical = 2,
|
||||
Error = 3,
|
||||
Warning = 4,
|
||||
Notice = 5,
|
||||
Info = 6,
|
||||
Debug = 7,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JournalResponse {
|
||||
pub entries: Vec<JournalEntry>,
|
||||
pub total_count: u32,
|
||||
pub truncated: bool,
|
||||
pub cached_at: u64, // Unix timestamp when this data was cached
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::proto::pb;
|
||||
use crate::state::AppState;
|
||||
|
||||
// Compression threshold based on typical payload size
|
||||
// Temporarily increased for testing - revert to 768 for production
|
||||
//const COMPRESSION_THRESHOLD: usize = 50_000;
|
||||
const COMPRESSION_THRESHOLD: usize = 768;
|
||||
|
||||
// Reusable buffer for compression to avoid allocations
|
||||
@@ -111,6 +113,92 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
}
|
||||
drop(cache); // Explicit drop to release mutex early
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_process_metrics:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_process_metrics:") {
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
let ttl = std::time::Duration::from_millis(250); // 250ms TTL
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = state.cache_process_metrics.lock().await;
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
if entry.is_fresh(ttl)
|
||||
&& let Some(cached_response) = entry.get()
|
||||
{
|
||||
let _ = send_json(&mut socket, cached_response).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect fresh data
|
||||
match crate::metrics::collect_process_metrics(pid, &state).await {
|
||||
Ok(response) => {
|
||||
// Cache the response
|
||||
{
|
||||
let mut cache = state.cache_process_metrics.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(crate::state::CacheEntry::new)
|
||||
.set(response.clone());
|
||||
}
|
||||
let _ = send_json(&mut socket, &response).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error_response = serde_json::json!({
|
||||
"error": err,
|
||||
"request": "get_process_metrics",
|
||||
"pid": pid
|
||||
});
|
||||
let _ = send_json(&mut socket, &error_response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Text(ref text) if text.starts_with("get_journal_entries:") => {
|
||||
if let Some(pid_str) = text.strip_prefix("get_journal_entries:") {
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
let ttl = std::time::Duration::from_secs(1); // 1s TTL
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = state.cache_journal_entries.lock().await;
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
if entry.is_fresh(ttl)
|
||||
&& let Some(cached_response) = entry.get()
|
||||
{
|
||||
let _ = send_json(&mut socket, cached_response).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect fresh data
|
||||
match crate::metrics::collect_journal_entries(pid) {
|
||||
Ok(response) => {
|
||||
// Cache the response
|
||||
{
|
||||
let mut cache = state.cache_journal_entries.lock().await;
|
||||
cache
|
||||
.entry(pid)
|
||||
.or_insert_with(crate::state::CacheEntry::new)
|
||||
.set(response.clone());
|
||||
}
|
||||
let _ = send_json(&mut socket, &response).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error_response = serde_json::json!({
|
||||
"error": err,
|
||||
"request": "get_journal_entries",
|
||||
"pid": pid
|
||||
});
|
||||
let _ = send_json(&mut socket, &error_response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user