diff --git a/stats-daemon/src/cpu.rs b/stats-daemon/src/cpu.rs new file mode 100644 index 0000000..34c8863 --- /dev/null +++ b/stats-daemon/src/cpu.rs @@ -0,0 +1,250 @@ +use std::fs; +use std::io::Write; + +pub struct Sample { + pub idle: u64, + pub total: u64, +} + +pub fn parse_stat(input: &str) -> Vec { + input + .lines() + .filter(|l| l.starts_with("cpu")) + .map(|l| { + let vals: Vec = l + .split_whitespace() + .skip(1) + .filter_map(|s| s.parse().ok()) + .collect(); + let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0); + let total = vals.iter().sum(); + Sample { idle, total } + }) + .collect() +} + +pub fn pct(prev: &Sample, curr: &Sample) -> u32 { + let dt = curr.total.saturating_sub(prev.total); + let di = curr.idle.saturating_sub(prev.idle); + if dt == 0 { + return 0; + } + (dt.saturating_sub(di) * 100 / dt) as u32 +} + +pub fn read_stat() -> Vec { + parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default()) +} + +pub fn read_core_freqs() -> Vec { + let mut freqs = Vec::new(); + for i in 0.. { + let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq"); + match fs::read_to_string(&path) { + Ok(s) => match s.trim().parse::() { + Ok(khz) => freqs.push(khz as f64 / 1_000_000.0), + Err(_) => break, + }, + Err(_) => break, + } + } + freqs +} + +pub fn emit_cpu(out: &mut impl Write, prev: &[Sample], curr: &[Sample], freqs: &[f64]) { + if curr.is_empty() { + return; + } + + let usage = prev + .first() + .zip(curr.first()) + .map(|(p, c)| pct(p, c)) + .unwrap_or(0); + + let core_usage: Vec = prev + .iter() + .skip(1) + .zip(curr.iter().skip(1)) + .map(|(p, c)| pct(p, c)) + .collect(); + + let avg_freq = if freqs.is_empty() { + 0.0 + } else { + freqs.iter().sum::() / freqs.len() as f64 + }; + + let n = core_usage.len().max(freqs.len()); + let _ = write!( + out, + "{{\"type\":\"cpu\",\"usage\":{usage},\"freq_ghz\":{avg_freq:.3},\"cores\":[" + ); + for i in 0..n { + if i > 0 { + let _ = write!(out, ","); + } + let u = core_usage.get(i).copied().unwrap_or(0); + let f = freqs.get(i).copied().unwrap_or(0.0); + let _ = write!(out, "{{\"usage\":{u},\"freq_ghz\":{f:.3}}}"); + } + let _ = writeln!(out, "]}}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(idle: u64, total: u64) -> Sample { + Sample { idle, total } + } + + // ── pct ────────────────────────────────────────────────────────────── + + #[test] + fn pct_zero_delta_returns_zero() { + let s = Sample { + idle: 100, + total: 400, + }; + assert_eq!(pct(&s, &s), 0); + } + + #[test] + fn pct_all_idle() { + let prev = Sample { + idle: 0, + total: 100, + }; + let curr = Sample { + idle: 100, + total: 200, + }; + assert_eq!(pct(&prev, &curr), 0); + } + + #[test] + fn pct_fully_busy() { + let prev = Sample { + idle: 100, + total: 200, + }; + let curr = Sample { + idle: 100, + total: 300, + }; + assert_eq!(pct(&prev, &curr), 100); + } + + #[test] + fn pct_half_busy() { + let prev = Sample { idle: 0, total: 0 }; + let curr = Sample { + idle: 50, + total: 100, + }; + assert_eq!(pct(&prev, &curr), 50); + } + + #[test] + fn pct_no_underflow_on_backwards_clock() { + let prev = Sample { + idle: 200, + total: 400, + }; + let curr = Sample { + idle: 100, + total: 300, + }; + assert_eq!(pct(&prev, &curr), 0); + } + + // ── parse_stat ─────────────────────────────────────────────────────── + + const STAT_SAMPLE: &str = "\ +cpu 100 10 50 700 40 0 0 0 0 0 +cpu0 50 5 25 350 20 0 0 0 0 0 +cpu1 50 5 25 350 20 0 0 0 0 0"; + + #[test] + fn parse_stat_count() { + assert_eq!(parse_stat(STAT_SAMPLE).len(), 3); + } + + #[test] + fn parse_stat_aggregate_idle() { + assert_eq!(parse_stat(STAT_SAMPLE)[0].idle, 740); + } + + #[test] + fn parse_stat_aggregate_total() { + assert_eq!(parse_stat(STAT_SAMPLE)[0].total, 900); + } + + #[test] + fn parse_stat_per_core_idle() { + let s = parse_stat(STAT_SAMPLE); + assert_eq!(s[1].idle, 370); + assert_eq!(s[2].idle, 370); + } + + #[test] + fn parse_stat_ignores_non_cpu_lines() { + let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0"; + assert_eq!(parse_stat(input).len(), 1); + } + + // ── emit_cpu ───────────────────────────────────────────────────────── + + #[test] + fn emit_cpu_valid_json_structure() { + let prev = vec![sample(0, 0), sample(0, 0)]; + let curr = vec![sample(50, 100), sample(25, 100)]; + let freqs = vec![3.2, 3.1]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &prev, &curr, &freqs); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"type\":\"cpu\"")); + assert!(s.contains("\"usage\":")); + assert!(s.contains("\"freq_ghz\":")); + assert!(s.contains("\"cores\":")); + assert!(s.trim().ends_with('}')); + } + + #[test] + fn emit_cpu_correct_usage() { + let prev = vec![sample(0, 0), sample(0, 0)]; + let curr = vec![sample(50, 100), sample(0, 0)]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &prev, &curr, &[]); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"usage\":50"), "got: {s}"); + } + + #[test] + fn emit_cpu_no_prev_gives_zero_usage() { + let curr = vec![sample(50, 100)]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &[], &curr, &[]); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"usage\":0"), "got: {s}"); + } + + #[test] + fn emit_cpu_empty_curr_produces_no_output() { + let mut buf = Vec::new(); + emit_cpu(&mut buf, &[], &[], &[]); + assert!(buf.is_empty()); + } + + #[test] + fn emit_cpu_core_freqs_in_output() { + let curr = vec![sample(0, 100)]; + let freqs = vec![3.200, 2.900]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &curr, &curr, &freqs); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"freq_ghz\":3.200"), "got: {s}"); + assert!(s.contains("\"freq_ghz\":2.900"), "got: {s}"); + } +} diff --git a/stats-daemon/src/gpu.rs b/stats-daemon/src/gpu.rs new file mode 100644 index 0000000..3c2a989 --- /dev/null +++ b/stats-daemon/src/gpu.rs @@ -0,0 +1,128 @@ +use std::fs; +use std::io::Write; + +pub struct GpuInfo { + pub usage: u32, + pub vram_used_gb: f64, + pub vram_total_gb: f64, + pub temp_c: i32, + pub vendor: &'static str, +} + +pub enum GpuBackend { + Amd { + card_path: String, + hwmon_path: Option, + }, + Nvidia, + None, +} + +pub fn detect_gpu() -> GpuBackend { + // AMD: look for gpu_busy_percent exposed by the amdgpu driver + for i in 0..8 { + let p = format!("/sys/class/drm/card{i}/device/gpu_busy_percent"); + if fs::read_to_string(&p).is_ok() { + let card = format!("/sys/class/drm/card{i}/device"); + let hwmon = find_amd_hwmon(); + return GpuBackend::Amd { + card_path: card, + hwmon_path: hwmon, + }; + } + } + // NVIDIA: probe nvidia-smi + let nvidia_ok = std::process::Command::new("nvidia-smi") + .args(["--query-gpu=name", "--format=csv,noheader"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if nvidia_ok { + return GpuBackend::Nvidia; + } + GpuBackend::None +} + +fn find_amd_hwmon() -> Option { + for i in 0..32 { + let name = format!("/sys/class/hwmon/hwmon{i}/name"); + if fs::read_to_string(&name).ok()?.trim() == "amdgpu" { + return Some(format!("/sys/class/hwmon/hwmon{i}")); + } + } + None +} + +fn read_amd(card: &str, hwmon: &Option) -> Option { + let usage: u32 = fs::read_to_string(format!("{card}/gpu_busy_percent")) + .ok()? + .trim() + .parse() + .ok()?; + let vram_used: u64 = fs::read_to_string(format!("{card}/mem_info_vram_used")) + .ok()? + .trim() + .parse() + .ok()?; + let vram_total: u64 = fs::read_to_string(format!("{card}/mem_info_vram_total")) + .ok()? + .trim() + .parse() + .ok()?; + let temp_c = hwmon + .as_ref() + .and_then(|h| fs::read_to_string(format!("{h}/temp1_input")).ok()) + .and_then(|s| s.trim().parse::().ok()) + .map(|mc| mc / 1000) + .unwrap_or(0); + Some(GpuInfo { + usage, + vram_used_gb: vram_used as f64 / 1_073_741_824.0, + vram_total_gb: vram_total as f64 / 1_073_741_824.0, + temp_c, + vendor: "amd", + }) +} + +fn read_nvidia() -> Option { + let out = std::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu", + "--format=csv,noheader,nounits", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout); + let p: Vec<&str> = s.trim().split(',').map(str::trim).collect(); + if p.len() < 4 { + return None; + } + Some(GpuInfo { + usage: p[0].parse().ok()?, + vram_used_gb: p[1].parse::().ok()? / 1024.0, + vram_total_gb: p[2].parse::().ok()? / 1024.0, + temp_c: p[3].parse().ok()?, + vendor: "nvidia", + }) +} + +pub fn emit_gpu(out: &mut impl Write, backend: &GpuBackend) { + let info = match backend { + GpuBackend::Amd { + card_path, + hwmon_path, + } => read_amd(card_path, hwmon_path), + GpuBackend::Nvidia => read_nvidia(), + GpuBackend::None => return, + }; + if let Some(g) = info { + let _ = writeln!( + out, + "{{\"type\":\"gpu\",\"usage\":{},\"vram_used_gb\":{:.3},\"vram_total_gb\":{:.3},\"temp_c\":{},\"vendor\":\"{}\"}}", + g.usage, g.vram_used_gb, g.vram_total_gb, g.temp_c, g.vendor + ); + } +} diff --git a/stats-daemon/src/main.rs b/stats-daemon/src/main.rs index 838d32f..572b864 100644 --- a/stats-daemon/src/main.rs +++ b/stats-daemon/src/main.rs @@ -1,186 +1,11 @@ -use std::fs; use std::io::{self, Write}; use std::thread; use std::time::{Duration, Instant}; -struct Sample { - idle: u64, - total: u64, -} - -struct MemInfo { - percent: u64, - used_gb: f64, - total_gb: f64, - avail_gb: f64, - cached_gb: f64, - buffers_gb: f64, -} - -fn parse_stat(input: &str) -> Vec { - input - .lines() - .filter(|l| l.starts_with("cpu")) - .map(|l| { - let vals: Vec = l - .split_whitespace() - .skip(1) - .filter_map(|s| s.parse().ok()) - .collect(); - let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0); - let total = vals.iter().sum(); - Sample { idle, total } - }) - .collect() -} - -fn parse_meminfo(input: &str) -> Option { - let mut total = 0u64; - let mut avail = 0u64; - let mut buffers = 0u64; - let mut cached = 0u64; - let mut sreclaimable = 0u64; - - for line in input.lines() { - let mut parts = line.splitn(2, ':'); - let key = parts.next().unwrap_or("").trim(); - let val: u64 = parts - .next() - .unwrap_or("") - .split_whitespace() - .next() - .unwrap_or("") - .parse() - .unwrap_or(0); - match key { - "MemTotal" => total = val, - "MemAvailable" => avail = val, - "Buffers" => buffers = val, - "Cached" => cached = val, - "SReclaimable" => sreclaimable = val, - _ => {} - } - } - - if total == 0 { - return None; - } - - let used = total.saturating_sub(avail); - let cached_total = cached + sreclaimable; - let gb = |kb: u64| kb as f64 / 1_048_576.0; - - Some(MemInfo { - percent: used * 100 / total, - used_gb: gb(used), - total_gb: gb(total), - avail_gb: gb(avail), - cached_gb: gb(cached_total), - buffers_gb: gb(buffers), - }) -} - -fn pct(prev: &Sample, curr: &Sample) -> u32 { - let dt = curr.total.saturating_sub(prev.total); - let di = curr.idle.saturating_sub(prev.idle); - if dt == 0 { - return 0; - } - (dt.saturating_sub(di) * 100 / dt) as u32 -} - -fn read_stat() -> Vec { - parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default()) -} - -fn read_temp_celsius() -> Option { - let mut max: Option = None; - for i in 0.. { - let path = format!("/sys/class/thermal/thermal_zone{i}/temp"); - match fs::read_to_string(&path) { - Ok(s) => { - if let Ok(millic) = s.trim().parse::() { - let c = millic / 1000; - max = Some(max.map_or(c, |m: i32| m.max(c))); - } - } - Err(_) => break, - } - } - max -} - -fn read_core_freqs() -> Vec { - let mut freqs = Vec::new(); - for i in 0.. { - let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq"); - match fs::read_to_string(&path) { - Ok(s) => match s.trim().parse::() { - Ok(khz) => freqs.push(khz as f64 / 1_000_000.0), - Err(_) => break, - }, - Err(_) => break, - } - } - freqs -} - -fn emit_cpu(out: &mut impl Write, prev: &[Sample], curr: &[Sample], freqs: &[f64]) { - if curr.is_empty() { - return; - } - - let usage = prev - .first() - .zip(curr.first()) - .map(|(p, c)| pct(p, c)) - .unwrap_or(0); - - let core_usage: Vec = prev - .iter() - .skip(1) - .zip(curr.iter().skip(1)) - .map(|(p, c)| pct(p, c)) - .collect(); - - let avg_freq = if freqs.is_empty() { - 0.0 - } else { - freqs.iter().sum::() / freqs.len() as f64 - }; - - let n = core_usage.len().max(freqs.len()); - let _ = write!( - out, - "{{\"type\":\"cpu\",\"usage\":{usage},\"freq_ghz\":{avg_freq:.3},\"cores\":[" - ); - for i in 0..n { - if i > 0 { - let _ = write!(out, ","); - } - let u = core_usage.get(i).copied().unwrap_or(0); - let f = freqs.get(i).copied().unwrap_or(0.0); - let _ = write!(out, "{{\"usage\":{u},\"freq_ghz\":{f:.3}}}"); - } - let _ = writeln!(out, "]}}"); -} - -fn emit_temp(out: &mut impl Write) { - if let Some(c) = read_temp_celsius() { - let _ = writeln!(out, "{{\"type\":\"temp\",\"celsius\":{c}}}"); - } -} - -fn emit_mem(out: &mut impl Write) { - let content = fs::read_to_string("/proc/meminfo").unwrap_or_default(); - if let Some(m) = parse_meminfo(&content) { - let _ = writeln!( - out, - "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", - m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, - ); - } -} +mod cpu; +mod gpu; +mod mem; +mod temp; fn parse_interval_ms() -> u64 { let args: Vec = std::env::args().collect(); @@ -200,23 +25,30 @@ fn main() { let interval = Duration::from_millis(parse_interval_ms()); let stdout = io::stdout(); let mut out = io::BufWriter::new(stdout.lock()); - let mut prev: Vec = vec![]; + let mut prev: Vec = vec![]; let mut freqs: Vec = vec![]; + let gpu = gpu::detect_gpu(); let mut tick = 0u64; loop { let t0 = Instant::now(); - let curr = read_stat(); + let curr = cpu::read_stat(); if tick.is_multiple_of(2) { - freqs = read_core_freqs(); - emit_mem(&mut out); + freqs = cpu::read_core_freqs(); + mem::emit_mem(&mut out); } - emit_cpu(&mut out, &prev, &curr, &freqs); + cpu::emit_cpu(&mut out, &prev, &curr, &freqs); prev = curr; if tick.is_multiple_of(4) { - emit_temp(&mut out); + temp::emit_temp(&mut out); + // AMD sysfs is instant; NVIDIA calls nvidia-smi so runs less often + match &gpu { + gpu::GpuBackend::Amd { .. } => gpu::emit_gpu(&mut out, &gpu), + gpu::GpuBackend::Nvidia if tick.is_multiple_of(8) => gpu::emit_gpu(&mut out, &gpu), + _ => {} + } } let _ = out.flush(); @@ -228,232 +60,3 @@ fn main() { } } } - -#[cfg(test)] -mod tests { - use super::*; - - // ── pct ────────────────────────────────────────────────────────────── - - #[test] - fn pct_zero_delta_returns_zero() { - let s = Sample { - idle: 100, - total: 400, - }; - assert_eq!(pct(&s, &s), 0); - } - - #[test] - fn pct_all_idle() { - let prev = Sample { - idle: 0, - total: 100, - }; - let curr = Sample { - idle: 100, - total: 200, - }; - assert_eq!(pct(&prev, &curr), 0); - } - - #[test] - fn pct_fully_busy() { - let prev = Sample { - idle: 100, - total: 200, - }; - let curr = Sample { - idle: 100, - total: 300, - }; - assert_eq!(pct(&prev, &curr), 100); - } - - #[test] - fn pct_half_busy() { - let prev = Sample { idle: 0, total: 0 }; - let curr = Sample { - idle: 50, - total: 100, - }; - assert_eq!(pct(&prev, &curr), 50); - } - - #[test] - fn pct_no_underflow_on_backwards_clock() { - let prev = Sample { - idle: 200, - total: 400, - }; - let curr = Sample { - idle: 100, - total: 300, - }; // idle went backwards - // dt=saturating 0, di=saturating 0 → returns 0 - assert_eq!(pct(&prev, &curr), 0); - } - - // ── parse_stat ─────────────────────────────────────────────────────── - - const STAT_SAMPLE: &str = "\ -cpu 100 10 50 700 40 0 0 0 0 0 -cpu0 50 5 25 350 20 0 0 0 0 0 -cpu1 50 5 25 350 20 0 0 0 0 0"; - - #[test] - fn parse_stat_count() { - // aggregate line + 2 cores = 3 samples - let samples = parse_stat(STAT_SAMPLE); - assert_eq!(samples.len(), 3); - } - - #[test] - fn parse_stat_aggregate_idle() { - // idle=field[3], iowait=field[4] → 700+40=740 - let samples = parse_stat(STAT_SAMPLE); - assert_eq!(samples[0].idle, 740); - } - - #[test] - fn parse_stat_aggregate_total() { - // sum of all fields: 100+10+50+700+40 = 900 - let samples = parse_stat(STAT_SAMPLE); - assert_eq!(samples[0].total, 900); - } - - #[test] - fn parse_stat_per_core_idle() { - let samples = parse_stat(STAT_SAMPLE); - assert_eq!(samples[1].idle, 370); // 350+20 - assert_eq!(samples[2].idle, 370); - } - - #[test] - fn parse_stat_ignores_non_cpu_lines() { - let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0"; - let samples = parse_stat(input); - assert_eq!(samples.len(), 1); - } - - // ── parse_meminfo ──────────────────────────────────────────────────── - - const MEMINFO_SAMPLE: &str = "\ -MemTotal: 16384000 kB -MemFree: 2048000 kB -MemAvailable: 4096000 kB -Buffers: 512000 kB -Cached: 3072000 kB -SReclaimable: 512000 kB -SwapTotal: 8192000 kB -SwapFree: 8192000 kB"; - - #[test] - fn parse_meminfo_percent() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - // used = total - avail = 16384000 - 4096000 = 12288000 - // percent = 12288000 * 100 / 16384000 = 75 - assert_eq!(m.percent, 75); - } - - #[test] - fn parse_meminfo_total_gb() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - // 16384000 kB / 1048576 ≈ 15.625 GB - assert!((m.total_gb - 15.625).abs() < 0.001); - } - - #[test] - fn parse_meminfo_cached_includes_sreclaimable() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - // cached = 3072000 + 512000 = 3584000 kB - let expected = 3_584_000.0 / 1_048_576.0; - assert!((m.cached_gb - expected).abs() < 0.001); - } - - #[test] - fn parse_meminfo_zero_total_returns_none() { - assert!(parse_meminfo("MemFree: 1000 kB\n").is_none()); - } - - #[test] - fn parse_meminfo_empty_returns_none() { - assert!(parse_meminfo("").is_none()); - } - - // ── emit_cpu ───────────────────────────────────────────────────────── - - fn sample(idle: u64, total: u64) -> Sample { - Sample { idle, total } - } - - #[test] - fn emit_cpu_valid_json_structure() { - let prev = vec![sample(0, 0), sample(0, 0)]; - let curr = vec![sample(50, 100), sample(25, 100)]; - let freqs = vec![3.2, 3.1]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &prev, &curr, &freqs); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"type\":\"cpu\"")); - assert!(s.contains("\"usage\":")); - assert!(s.contains("\"freq_ghz\":")); - assert!(s.contains("\"cores\":")); - assert!(s.trim().ends_with('}')); - } - - #[test] - fn emit_cpu_correct_usage() { - // prev aggregate: idle=0, total=0 → curr: idle=50, total=100 → 50% busy - let prev = vec![sample(0, 0), sample(0, 0)]; - let curr = vec![sample(50, 100), sample(0, 0)]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &prev, &curr, &[]); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"usage\":50"), "got: {s}"); - } - - #[test] - fn emit_cpu_no_prev_gives_zero_usage() { - let curr = vec![sample(50, 100)]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &[], &curr, &[]); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"usage\":0"), "got: {s}"); - } - - #[test] - fn emit_cpu_empty_curr_produces_no_output() { - let mut buf = Vec::new(); - emit_cpu(&mut buf, &[], &[], &[]); - assert!(buf.is_empty()); - } - - #[test] - fn emit_cpu_core_freqs_in_output() { - let curr = vec![sample(0, 100)]; - let freqs = vec![3.200, 2.900]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &curr, &curr, &freqs); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"freq_ghz\":3.200"), "got: {s}"); - assert!(s.contains("\"freq_ghz\":2.900"), "got: {s}"); - } - - // ── emit_mem (via parse_meminfo) ───────────────────────────────────── - - #[test] - fn emit_mem_valid_json_structure() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - let mut buf = Vec::new(); - let _ = writeln!( - &mut buf, - "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", - m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, - ); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"type\":\"mem\"")); - assert!(s.contains("\"percent\":75")); - assert!(s.trim().ends_with('}')); - } -} diff --git a/stats-daemon/src/mem.rs b/stats-daemon/src/mem.rs new file mode 100644 index 0000000..36fc2ab --- /dev/null +++ b/stats-daemon/src/mem.rs @@ -0,0 +1,112 @@ +use std::fs; +use std::io::Write; + +pub struct MemInfo { + pub percent: u64, + pub used_gb: f64, + pub total_gb: f64, + pub avail_gb: f64, + pub cached_gb: f64, + pub buffers_gb: f64, +} + +pub fn parse_meminfo(input: &str) -> Option { + let mut total = 0u64; + let mut avail = 0u64; + let mut buffers = 0u64; + let mut cached = 0u64; + let mut sreclaimable = 0u64; + + for line in input.lines() { + let mut parts = line.splitn(2, ':'); + let key = parts.next().unwrap_or("").trim(); + let val: u64 = parts + .next() + .unwrap_or("") + .split_whitespace() + .next() + .unwrap_or("") + .parse() + .unwrap_or(0); + match key { + "MemTotal" => total = val, + "MemAvailable" => avail = val, + "Buffers" => buffers = val, + "Cached" => cached = val, + "SReclaimable" => sreclaimable = val, + _ => {} + } + } + + if total == 0 { + return None; + } + + let used = total.saturating_sub(avail); + let cached_total = cached + sreclaimable; + let gb = |kb: u64| kb as f64 / 1_048_576.0; + + Some(MemInfo { + percent: used * 100 / total, + used_gb: gb(used), + total_gb: gb(total), + avail_gb: gb(avail), + cached_gb: gb(cached_total), + buffers_gb: gb(buffers), + }) +} + +pub fn emit_mem(out: &mut impl Write) { + let content = fs::read_to_string("/proc/meminfo").unwrap_or_default(); + if let Some(m) = parse_meminfo(&content) { + let _ = writeln!( + out, + "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", + m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MEMINFO_SAMPLE: &str = "\ +MemTotal: 16384000 kB +MemFree: 2048000 kB +MemAvailable: 4096000 kB +Buffers: 512000 kB +Cached: 3072000 kB +SReclaimable: 512000 kB +SwapTotal: 8192000 kB +SwapFree: 8192000 kB"; + + #[test] + fn parse_meminfo_percent() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + assert_eq!(m.percent, 75); + } + + #[test] + fn parse_meminfo_total_gb() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + assert!((m.total_gb - 15.625).abs() < 0.001); + } + + #[test] + fn parse_meminfo_cached_includes_sreclaimable() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + let expected = 3_584_000.0 / 1_048_576.0; + assert!((m.cached_gb - expected).abs() < 0.001); + } + + #[test] + fn parse_meminfo_zero_total_returns_none() { + assert!(parse_meminfo("MemFree: 1000 kB\n").is_none()); + } + + #[test] + fn parse_meminfo_empty_returns_none() { + assert!(parse_meminfo("").is_none()); + } +} diff --git a/stats-daemon/src/temp.rs b/stats-daemon/src/temp.rs new file mode 100644 index 0000000..54beb3c --- /dev/null +++ b/stats-daemon/src/temp.rs @@ -0,0 +1,25 @@ +use std::fs; +use std::io::Write; + +pub fn read_temp_celsius() -> Option { + let mut max: Option = None; + for i in 0.. { + let path = format!("/sys/class/thermal/thermal_zone{i}/temp"); + match fs::read_to_string(&path) { + Ok(s) => { + if let Ok(millic) = s.trim().parse::() { + let c = millic / 1000; + max = Some(max.map_or(c, |m: i32| m.max(c))); + } + } + Err(_) => break, + } + } + max +} + +pub fn emit_temp(out: &mut impl Write) { + if let Some(c) = read_temp_celsius() { + let _ = writeln!(out, "{{\"type\":\"temp\",\"celsius\":{c}}}"); + } +}