feat(shuma): shuma-sysmon — muestreo de CPU/memoria con historial
Lee /proc/stat y /proc/meminfo; calcula uso de CPU (delta entre muestras) y de memoria; mantiene un History circular para la curva del monitor. Parseo puro (parse_cpu_stat/parse_meminfo) separado de la lectura de archivos → testeable sin tocar el sistema. 8 tests. #![forbid(unsafe_code)], cero deps de UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+7
@@ -11439,6 +11439,13 @@ dependencies = [
|
||||
"shuma-intent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shuma-sysmon"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
|
||||
@@ -181,6 +181,7 @@ members = [
|
||||
"crates/modules/shuma/shuma-core",
|
||||
"crates/modules/shuma/shuma-intent",
|
||||
"crates/modules/shuma/shuma-line",
|
||||
"crates/modules/shuma/shuma-sysmon",
|
||||
"crates/modules/shuma/shuma-shell-render",
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "shuma-sysmon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "shuma — muestreo de CPU y memoria con historial para los monitores del shell. Parseo de /proc separado del cálculo, agnóstico de UI."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,297 @@
|
||||
//! `shuma-sysmon` — muestreo de CPU y memoria para los monitores del shell.
|
||||
//!
|
||||
//! Lee `/proc/stat` y `/proc/meminfo`, calcula el porcentaje de uso de
|
||||
//! CPU (delta entre dos muestras) y de memoria, y mantiene un historial
|
||||
//! corto para dibujar la curva del monitor.
|
||||
//!
|
||||
//! El parseo de `/proc` está separado del cálculo: las funciones puras
|
||||
//! [`parse_cpu_stat`] y [`parse_meminfo`] se prueban con texto fijo, y
|
||||
//! [`SystemSampler::sample`] sólo añade la lectura de archivos. Así la
|
||||
//! lógica es testeable sin depender del sistema y el crate es agnóstico
|
||||
//! de cualquier frontend.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Acumuladores de CPU de `/proc/stat` — tiempo ocupado y total.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CpuStat {
|
||||
pub busy: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Memoria de `/proc/meminfo`, en kibibytes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MemStat {
|
||||
pub total_kb: u64,
|
||||
pub available_kb: u64,
|
||||
}
|
||||
|
||||
/// Parsea la línea agregada `cpu` de `/proc/stat`. El uso instantáneo no
|
||||
/// se puede sacar de una sola muestra — hace falta el delta entre dos.
|
||||
pub fn parse_cpu_stat(text: &str) -> Option<CpuStat> {
|
||||
let line = text.lines().find(|l| {
|
||||
l.starts_with("cpu") && l[3..].starts_with(char::is_whitespace)
|
||||
})?;
|
||||
let fields: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1) // la etiqueta "cpu"
|
||||
.filter_map(|f| f.parse().ok())
|
||||
.collect();
|
||||
if fields.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
// Campos: user nice system idle iowait irq softirq steal …
|
||||
let total: u64 = fields.iter().sum();
|
||||
let idle = fields[3] + fields.get(4).copied().unwrap_or(0); // idle + iowait
|
||||
Some(CpuStat { busy: total.saturating_sub(idle), total })
|
||||
}
|
||||
|
||||
/// Parsea `MemTotal` y `MemAvailable` de `/proc/meminfo`.
|
||||
pub fn parse_meminfo(text: &str) -> Option<MemStat> {
|
||||
let field = |key: &str| -> Option<u64> {
|
||||
text.lines()
|
||||
.find(|l| l.starts_with(key))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()
|
||||
};
|
||||
Some(MemStat {
|
||||
total_kb: field("MemTotal:")?,
|
||||
available_kb: field("MemAvailable:")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Un historial circular de valores `f32` para dibujar una curva.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct History {
|
||||
samples: VecDeque<f32>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Historial vacío con capacidad para `capacity` muestras.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self { samples: VecDeque::with_capacity(capacity.max(1)), capacity: capacity.max(1) }
|
||||
}
|
||||
|
||||
/// Añade una muestra; descarta la más antigua si se llena.
|
||||
pub fn push(&mut self, value: f32) {
|
||||
if self.samples.len() == self.capacity {
|
||||
self.samples.pop_front();
|
||||
}
|
||||
self.samples.push_back(value);
|
||||
}
|
||||
|
||||
/// Muestras de la más antigua a la más reciente.
|
||||
pub fn values(&self) -> Vec<f32> {
|
||||
self.samples.iter().copied().collect()
|
||||
}
|
||||
|
||||
/// Muestra más reciente.
|
||||
pub fn last(&self) -> Option<f32> {
|
||||
self.samples.back().copied()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.samples.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.samples.is_empty()
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// Una lectura del estado del sistema en un instante.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
/// Uso de CPU, `0.0..=100.0`.
|
||||
pub cpu_percent: f32,
|
||||
/// Uso de memoria, `0.0..=100.0`.
|
||||
pub mem_percent: f32,
|
||||
pub mem_used_mb: u64,
|
||||
pub mem_total_mb: u64,
|
||||
/// `false` si no se pudo leer `/proc` (p. ej. fuera de Linux).
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
fn invalid() -> Self {
|
||||
Self { cpu_percent: 0.0, mem_percent: 0.0, mem_used_mb: 0, mem_total_mb: 0, valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Muestreador del sistema: guarda la muestra de CPU anterior (para el
|
||||
/// delta) y el historial de ambas curvas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemSampler {
|
||||
prev_cpu: Option<CpuStat>,
|
||||
cpu_history: History,
|
||||
mem_history: History,
|
||||
}
|
||||
|
||||
impl SystemSampler {
|
||||
/// Crea un muestreador cuyas curvas guardan `history` muestras.
|
||||
pub fn new(history: usize) -> Self {
|
||||
Self {
|
||||
prev_cpu: None,
|
||||
cpu_history: History::new(history),
|
||||
mem_history: History::new(history),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcula un `Snapshot` a partir del texto de `/proc/stat` y
|
||||
/// `/proc/meminfo`. Es la parte pura — `sample` sólo le añade la
|
||||
/// lectura de archivos.
|
||||
pub fn sample_from(&mut self, stat_text: &str, meminfo_text: &str) -> Snapshot {
|
||||
let (Some(cpu), Some(mem)) =
|
||||
(parse_cpu_stat(stat_text), parse_meminfo(meminfo_text))
|
||||
else {
|
||||
return Snapshot::invalid();
|
||||
};
|
||||
|
||||
// El uso de CPU es el delta de ocupación entre dos muestras.
|
||||
let cpu_percent = match self.prev_cpu {
|
||||
Some(prev) => {
|
||||
let total_delta = cpu.total.saturating_sub(prev.total);
|
||||
let busy_delta = cpu.busy.saturating_sub(prev.busy);
|
||||
if total_delta == 0 {
|
||||
self.cpu_history.last().unwrap_or(0.0)
|
||||
} else {
|
||||
(busy_delta as f32 / total_delta as f32 * 100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
}
|
||||
None => 0.0, // primera muestra: aún no hay delta
|
||||
};
|
||||
self.prev_cpu = Some(cpu);
|
||||
|
||||
let used_kb = mem.total_kb.saturating_sub(mem.available_kb);
|
||||
let mem_percent = if mem.total_kb == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used_kb as f32 / mem.total_kb as f32 * 100.0).clamp(0.0, 100.0)
|
||||
};
|
||||
|
||||
self.cpu_history.push(cpu_percent);
|
||||
self.mem_history.push(mem_percent);
|
||||
|
||||
Snapshot {
|
||||
cpu_percent,
|
||||
mem_percent,
|
||||
mem_used_mb: used_kb / 1024,
|
||||
mem_total_mb: mem.total_kb / 1024,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lee `/proc` y produce un `Snapshot`. Fuera de Linux, o si `/proc`
|
||||
/// no está disponible, devuelve un snapshot `valid: false`.
|
||||
pub fn sample(&mut self) -> Snapshot {
|
||||
let stat = std::fs::read_to_string("/proc/stat");
|
||||
let meminfo = std::fs::read_to_string("/proc/meminfo");
|
||||
match (stat, meminfo) {
|
||||
(Ok(s), Ok(m)) => self.sample_from(&s, &m),
|
||||
_ => Snapshot::invalid(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Historial de uso de CPU (curva del monitor).
|
||||
pub fn cpu_history(&self) -> &History {
|
||||
&self.cpu_history
|
||||
}
|
||||
|
||||
/// Historial de uso de memoria.
|
||||
pub fn mem_history(&self) -> &History {
|
||||
&self.mem_history
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STAT_1: &str = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 50 0 25 400 25 0 0\n";
|
||||
// 100 jiffies de ocupación más que STAT_1, 100 de inactividad más.
|
||||
const STAT_2: &str = "cpu 150 0 100 850 100 0 0 0 0 0\ncpu0 75 0 50 425 50 0 0\n";
|
||||
const MEMINFO: &str =
|
||||
"MemTotal: 16000000 kB\nMemFree: 2000000 kB\nMemAvailable: 4000000 kB\n";
|
||||
|
||||
#[test]
|
||||
fn parses_cpu_aggregate_line() {
|
||||
let c = parse_cpu_stat(STAT_1).unwrap();
|
||||
// total = 100+0+50+800+50 = 1000; idle = 800+50 = 850; busy = 150.
|
||||
assert_eq!(c.total, 1000);
|
||||
assert_eq!(c.busy, 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_meminfo() {
|
||||
let m = parse_meminfo(MEMINFO).unwrap();
|
||||
assert_eq!(m.total_kb, 16_000_000);
|
||||
assert_eq!(m.available_kb, 4_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_proc_text() {
|
||||
assert!(parse_cpu_stat("garbage").is_none());
|
||||
assert!(parse_meminfo("MemTotal: only").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_sample_has_zero_cpu_then_delta() {
|
||||
let mut s = SystemSampler::new(60);
|
||||
let first = s.sample_from(STAT_1, MEMINFO);
|
||||
assert_eq!(first.cpu_percent, 0.0); // sin muestra previa
|
||||
assert!(first.valid);
|
||||
|
||||
let second = s.sample_from(STAT_2, MEMINFO);
|
||||
// total_delta: 1200-1000=200; busy_delta: STAT_2 busy = 150+0+100=250
|
||||
// total2 = 150+0+100+850+100 = 1200; idle2 = 950; busy2 = 250.
|
||||
// busy_delta = 250-150 = 100; cpu% = 100/200 = 50%.
|
||||
assert!((second.cpu_percent - 50.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_percent_uses_available() {
|
||||
let mut s = SystemSampler::new(60);
|
||||
let snap = s.sample_from(STAT_1, MEMINFO);
|
||||
// used = 16000000 - 4000000 = 12000000 kB → 75%.
|
||||
assert!((snap.mem_percent - 75.0).abs() < 0.01);
|
||||
assert_eq!(snap.mem_total_mb, 16_000_000 / 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_proc_yields_invalid_snapshot() {
|
||||
let mut s = SystemSampler::new(60);
|
||||
let snap = s.sample_from("nonsense", "nonsense");
|
||||
assert!(!snap.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_fills_both_curves() {
|
||||
let mut s = SystemSampler::new(60);
|
||||
s.sample_from(STAT_1, MEMINFO);
|
||||
s.sample_from(STAT_2, MEMINFO);
|
||||
assert_eq!(s.cpu_history().len(), 2);
|
||||
assert_eq!(s.mem_history().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_a_bounded_ring() {
|
||||
let mut h = History::new(3);
|
||||
for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
|
||||
h.push(v);
|
||||
}
|
||||
assert_eq!(h.len(), 3);
|
||||
assert_eq!(h.values(), vec![3.0, 4.0, 5.0]); // las 3 más recientes
|
||||
assert_eq!(h.last(), Some(5.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user