use std::{ array, fs::File, io::{BufRead, BufReader}, sync::{atomic::Ordering, mpsc::Sender}, thread::JoinHandle, time::Duration, }; use regex_lite::Regex; use time::OffsetDateTime; use crate::{ db::DbMeminfo, griph::{self, Style, TwoLineOrder}, AwakeState, }; pub struct Gatherer { state: AwakeState, hwnd: Option>, } impl Gatherer { pub fn new(state: AwakeState) -> Self { Self { state, hwnd: None } } pub fn start(&mut self) { let state = self.state.clone(); let hwnd = std::thread::spawn(|| task(state)); self.hwnd = Some(hwnd); } } fn task(state: AwakeState) { tracing::info!("starting gatherer thread"); if !state.do_statistics { tracing::warn!("statistics disabled"); return; } // I just want a graph on first boot; don't care about divisions just yet make_mem_graph(&state); make_net_graph(&state); make_cpu_graph(&state); // If we collected a point less than a minute ago, like after // just being restarted, wait until it's been a minute let last_meminfo = state.database.get_last_host_meminfo(); let since = OffsetDateTime::now_utc() - last_meminfo.stamp; if since < time::Duration::minutes(1) { let to_minute = time::Duration::minutes(1) - since; tracing::info!( "waiting for {}s to space a minute apart", to_minute.whole_seconds() ); std::thread::sleep(to_minute.try_into().unwrap()); } let mut last_netinfo: Option = None; let mut last_cpuinfo: Option = None; // this is a `let _` because otherwise the attribute was // making the comiler mad #[rustfmt::skip] let _ = loop { tracing::debug!("collecting stats"); // Gather data let meminfo = Meminfo::current(); let netinfo = Netinfo::current(); let cpuinfo = Cpuinfo::current(); // Print traces, y'know, for tracing tracing::trace!("memory: {}MB used / {}MB total", meminfo.usage() / 1000, meminfo.total / 1000); tracing::trace!("net: rx {} / tx {}", data_human_fmt(netinfo.rx_bytes), data_human_fmt(netinfo.tx_bytes)); tracing::trace!("cpu: user {} // nice {} // system {}", cpuinfo.user, cpuinfo.nice, cpuinfo.system); // Store stats in database if let Some(lni) = last_netinfo { let rx_delta = netinfo.rx_bytes - lni.rx_bytes; let tx_delta = netinfo.tx_bytes - lni.tx_bytes; state.database.insert_hostnet(60, rx_delta, tx_delta); } last_netinfo = Some(netinfo); if let Some(lci) = last_cpuinfo { let user_delta = cpuinfo.user - lci.user; let nice_delta = cpuinfo.nice - lci.nice; let system_delta = cpuinfo.system - lci.system; state.database.insert_hostcpu(60, user_delta, nice_delta, system_delta); } last_cpuinfo = Some(cpuinfo); state.database.insert_host_meminfo(meminfo); // Only generate graphs every 15 minutes let now = OffsetDateTime::now_utc(); if now.minute() % 15 == 0 { make_mem_graph(&state); make_net_graph(&state); make_cpu_graph(&state); } std::thread::sleep(Duration::from_secs(60)); }; } pub fn make_mem_graph(state: &AwakeState) { tracing::debug!("generating meminfo graph"); let infos = state.database.get_last_n_host_meminfo(256); let max = infos[0].total_kb; let now = OffsetDateTime::now_utc(); let cleaned = clean_series(&infos, |mem| mem.stamp, now); let mut usages: Vec> = cleaned .into_iter() .map(|mi| mi.map(|mi| mi.usage())) .collect(); // Reversing here because we want latest valeus on on the // right side, so last in the array usages.reverse(); let gif = griph::make_1line(0, max, &usages, Style::Line); let path = state.cache_path.join("current_hostmeminfo.gif"); gif.save(path).unwrap(); } pub fn make_net_graph(state: &AwakeState) { tracing::debug!("generating netinfo graph"); let now = OffsetDateTime::now_utc(); let infos = state.database.get_last_n_hostnet(256); let cleaned = clean_series(&infos, |net| net.stamp, now); // 125 is (1000 / 8) so it converst Bytes to kiloBITS let mut rx_deltas = extract(&cleaned, |ni| ni.rx_bytes_per_sec() as usize / 125); let mut tx_deltas = extract(&cleaned, |ni| ni.tx_bytes_per_sec() as usize / 125); // Reversing to put latest values on the right side rx_deltas.reverse(); tx_deltas.reverse(); let rx_zeroed: Vec = cleaned .iter() .map(|m| m.map(|n| n.rx_bytes_per_sec() as usize / 125).unwrap_or(0)) .collect(); let tx_zeroed: Vec = cleaned .iter() .map(|m| m.map(|n| n.tx_bytes_per_sec() as usize / 125).unwrap_or(0)) .collect(); let rx_sum = rx_zeroed.iter().fold(0, |acc, x| acc + x); let tx_sum = tx_zeroed.iter().fold(0, |acc, x| acc + x); // Mixing the TX/RX delta so we can pick a range. let mut mixed = vec![0; 512]; mixed[..256].copy_from_slice(&rx_zeroed); mixed[256..].copy_from_slice(&tx_zeroed); mixed.sort(); let kinda_highest = mixed[511 - 32]; let high_bound = (kinda_highest as f32 / 64.0).ceil().max(1.0) as usize * 64; state .netinfo_upper_bound .store(high_bound, Ordering::Release); let order = if tx_sum > rx_sum { TwoLineOrder::SeriesAFirst } else { TwoLineOrder::SeriesBFirst }; tracing::debug!("tx_sum = {tx_sum} // rx_sum = {rx_sum} // order = {order:?}"); let gif = griph::make_2line(0, high_bound, &tx_deltas, &rx_deltas, order); let path = state.cache_path.join("current_hostnetinfo.gif"); gif.save(path).unwrap(); } pub fn make_cpu_graph(state: &AwakeState) { tracing::debug!("generating cpuinfo graph"); let infos = state.database.get_last_n_hostcpu(256); let now = OffsetDateTime::now_utc(); let cleaned = clean_series(&infos, |cpu| cpu.stamp, now); // Usages are of the unit of hundreths of a second of CPU usage averaged over a minute. // A value of 1 means the cpu saw 1% CPU usage let usages = extract(&cleaned, |cpu| cpu.average_usage()); let mut zeroed: Vec = usages.iter().map(|m| m.unwrap_or(0.0)).collect(); zeroed.sort_by(|a, b| a.partial_cmp(&b).unwrap()); let kinda_highest = zeroed[255 - 8]; tracing::debug!( "kinda_highest = {kinda_highest} // highest = {}", zeroed[255] ); // high_vound unit: hundreths of a second (1% / 1) let high_bound = kinda_highest.max(1.0); state .cpuinfo_upper_bound .store(high_bound as usize, Ordering::Release); // can't scale the range, so we have to scale the inputs. // Finally, we multiply by ten to put it in a 0-1000 range let scale_factor = 100.0 / high_bound; let mut scaled: Vec> = usages .iter() .map(|m| m.map(|v| (v * scale_factor) as usize * 10)) .collect(); // Reversing here because we want latest valeus on on the // right side, so last in the array scaled.reverse(); let gif = griph::make_1line(0, 1000, &scaled, Style::UnderfilledLine); let path = state.cache_path.join("current_hostcpuinfo.gif"); gif.save(path).unwrap(); } fn clean_series(series: &[T], time_extractor: F, end_time: OffsetDateTime) -> [Option; 256] where F: Fn(&T) -> OffsetDateTime, T: Clone, { let mut res = [const { None }; 256]; for value in series { let time = time_extractor(value); let delta = end_time - time; let mins = delta.whole_minutes(); if mins > 0 && mins < 256 { res[mins as usize] = Some(value.clone()); } } res } fn extract(series: &[Option], extractor: F) -> [Option; 256] where F: Fn(&T) -> V, { let mut res = [const { None }; 256]; for (idx, maybe) in series.iter().enumerate() { if let Some(value) = maybe { res[idx] = Some(extractor(value)); } } res } pub struct Meminfo { pub total: usize, pub free: usize, pub avaialable: usize, } impl Meminfo { pub fn current() -> Self { let procinfo = File::open("/proc/meminfo").unwrap(); let bread = BufReader::new(procinfo); let mut meminfo = Meminfo { total: 0, free: 0, avaialable: 0, }; for line in bread.lines() { let line = line.unwrap(); if let Some((raw_key, raw_value_kb)) = line.split_once(':') { let value = if let Some(raw_value) = raw_value_kb.trim().strip_suffix(" kB") { if let Ok(parsed) = raw_value.parse() { parsed } else { continue; } } else { continue; }; match raw_key.trim() { "MemTotal" => meminfo.total = value, "MemFree" => meminfo.free = value, "MemAvailable" => meminfo.avaialable = value, _ => (), } } } meminfo } pub fn usage(&self) -> usize { self.total - self.avaialable } } pub struct Netinfo { rx_bytes: usize, tx_bytes: usize, } impl Netinfo { pub fn current() -> Self { let procinfo = File::open("/proc/net/dev").unwrap(); let bread = BufReader::new(procinfo); let mut netinfo = Self { rx_bytes: 0, tx_bytes: 0, }; let interface = "eth0:"; for line in bread.lines() { let line = line.unwrap(); let trim = line.trim(); if let Some(data) = trim.strip_prefix(interface) { let mut splits = data.split_whitespace(); netinfo.rx_bytes = splits.next().unwrap().parse().unwrap(); netinfo.tx_bytes = splits.skip(7).next().unwrap().parse().unwrap(); break; } } netinfo } } /// Not strictly the correct name for this, but that's fine. /// Structure for parsed-from /proc/stat pub struct Cpuinfo { user: usize, nice: usize, system: usize, } impl Cpuinfo { pub fn current() -> Self { let procinfo = File::open("/proc/stat").unwrap(); let bread = BufReader::new(procinfo); let mut cpuinfo = Cpuinfo { user: 0, nice: 0, system: 0, }; for line in bread.lines() { let line = line.unwrap(); let Some(data) = line.strip_prefix("cpu ") else { continue; }; let mut splits = data.split(' '); cpuinfo.user = splits.next().unwrap().parse().unwrap(); cpuinfo.nice = splits.next().unwrap().parse().unwrap(); cpuinfo.system = splits.next().unwrap().parse().unwrap(); } cpuinfo } } fn data_human_fmt(bytes: usize) -> String { let (num, unit) = data_human(bytes); format!("{num}{unit}") } fn data_human(bytes: usize) -> (f32, &'static str) { const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB"]; let mut wrk = bytes as f32; let mut unit_idx = 0; loop { if wrk < 1500.0 || unit_idx == UNITS.len() - 1 { return (wrk, UNITS[unit_idx]); } wrk /= 1000.0; unit_idx += 1; } }