pub mod collector;
pub mod history;
pub mod parser;
pub mod platform;
pub use collector::HostMetricsCollector;
pub use platform::Platform;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum MetricState {
#[default]
Pending,
Collected,
Uncollected { reason: String, attempts: u32 },
Unsupported { reason: String },
}
impl MetricState {
pub fn is_collected(&self) -> bool {
matches!(self, MetricState::Collected)
}
#[allow(dead_code)]
pub fn needs_explanation(&self) -> bool {
matches!(
self,
MetricState::Uncollected { .. } | MetricState::Unsupported { .. }
)
}
pub fn explain(&self) -> Option<String> {
match self {
MetricState::Pending => Some("waiting for first sample".to_string()),
MetricState::Collected => None,
MetricState::Uncollected { reason, attempts } => {
if *attempts > 1 {
Some(format!("uncollected · {} {}×", reason, attempts))
} else {
Some(format!("uncollected · {}", reason))
}
}
MetricState::Unsupported { reason } => Some(format!("unsupported · {}", reason)),
}
}
pub fn fail(&mut self, reason: impl Into<String>) {
let reason = reason.into();
let attempts = match self {
MetricState::Uncollected {
reason: prev,
attempts,
} if *prev == reason => *attempts + 1,
_ => 1,
};
*self = MetricState::Uncollected { reason, attempts };
}
pub fn unsupported(&mut self, reason: impl Into<String>) {
if !matches!(self, MetricState::Unsupported { .. }) {
*self = MetricState::Unsupported {
reason: reason.into(),
};
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CollectionStatus {
pub platform: Platform,
pub cpu: MetricState,
pub mem: MetricState,
pub load: MetricState,
pub disk: MetricState,
pub net: MetricState,
pub procs: MetricState,
pub uptime: MetricState,
}
impl CollectionStatus {
pub fn fail_all(&mut self, reason: impl Into<String>) {
let reason = reason.into();
self.cpu.fail(reason.clone());
self.mem.fail(reason.clone());
self.load.fail(reason.clone());
self.disk.fail(reason.clone());
self.net.fail(reason.clone());
self.procs.fail(reason.clone());
self.uptime.fail(reason);
}
pub fn unsupported_all(&mut self, reason: impl Into<String>) {
let reason = reason.into();
self.cpu.unsupported(reason.clone());
self.mem.unsupported(reason.clone());
self.load.unsupported(reason.clone());
self.disk.unsupported(reason.clone());
self.net.unsupported(reason.clone());
self.procs.unsupported(reason.clone());
self.uptime.unsupported(reason);
}
#[allow(dead_code)]
pub fn problems(&self) -> Vec<(&'static str, String)> {
let groups: [(&'static str, &MetricState); 7] = [
("CPU", &self.cpu),
("MEM", &self.mem),
("LOAD", &self.load),
("DISK", &self.disk),
("NET", &self.net),
("PROCS", &self.procs),
("UPTIME", &self.uptime),
];
groups
.iter()
.filter(|(_, st)| st.needs_explanation())
.filter_map(|(name, st)| st.explain().map(|e| (*name, e)))
.collect()
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct HostMetrics {
pub cpu_percent: f64,
pub cpu_per_core: Vec<f64>,
pub mem_total_kb: u64,
pub mem_used_kb: u64,
pub mem_available_kb: u64,
pub mem_swap_total_kb: u64,
pub mem_swap_used_kb: u64,
pub load_1m: f64,
pub load_5m: f64,
pub load_15m: f64,
pub disks: Vec<DiskInfo>,
pub disk_read_bps: f64,
pub disk_write_bps: f64,
pub net_rx_bps: f64,
pub net_tx_bps: f64,
pub top_procs_cpu: Vec<ProcessInfo>,
pub top_procs_mem: Vec<ProcessInfo>,
pub uptime_secs: u64,
pub os_info: String,
pub status: CollectionStatus,
}
impl HostMetrics {
pub fn mem_percent(&self) -> Option<f64> {
if !self.status.mem.is_collected() || self.mem_total_kb == 0 {
return None;
}
Some(self.mem_used_kb as f64 / self.mem_total_kb as f64 * 100.0)
}
pub fn cpu_percent_opt(&self) -> Option<f64> {
self.status.cpu.is_collected().then_some(self.cpu_percent)
}
#[allow(dead_code)]
pub fn load_opt(&self) -> Option<(f64, f64, f64)> {
self.status
.load
.is_collected()
.then_some((self.load_1m, self.load_5m, self.load_15m))
}
pub fn uptime_opt(&self) -> Option<u64> {
self.status
.uptime
.is_collected()
.then_some(self.uptime_secs)
}
pub fn net_opt(&self) -> Option<(f64, f64)> {
self.status
.net
.is_collected()
.then_some((self.net_rx_bps, self.net_tx_bps))
}
pub fn user_disks(&self) -> Vec<&DiskInfo> {
if !self.status.disk.is_collected() {
return Vec::new();
}
let mut kept: Vec<&DiskInfo> = self.disks.iter().filter(|d| d.is_user_visible()).collect();
kept.sort_by(|a, b| {
b.use_pct
.partial_cmp(&a.use_pct)
.unwrap_or(std::cmp::Ordering::Equal)
});
kept
}
pub fn hidden_disk_count(&self) -> usize {
if !self.status.disk.is_collected() {
return 0;
}
self.disks.iter().filter(|d| !d.is_user_visible()).count()
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DiskInfo {
pub mount: String,
pub total_bytes: u64,
pub used_bytes: u64,
pub use_pct: f64,
}
const SMALL_MOUNT_BYTES: u64 = 1024 * 1024 * 1024;
impl DiskInfo {
pub fn is_user_visible(&self) -> bool {
let m = self.mount.as_str();
if m.contains("AppTranslocation") {
return false;
}
if m.starts_with("/System/Volumes/") && m != "/System/Volumes/Data" {
return false;
}
if m.starts_with("/snap/")
|| m.starts_with("/sys/")
|| m.starts_with("/proc/")
|| m == "/dev"
|| m == "/dev/shm"
|| m.starts_with("/run/")
{
return false;
}
if matches!(m, "/etc/hosts" | "/etc/hostname" | "/etc/resolv.conf") {
return false;
}
if self.total_bytes < SMALL_MOUNT_BYTES && self.use_pct < 90.0 {
return false;
}
true
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProcessInfo {
pub pid: u32,
pub name: String,
pub cpu_pct: f64,
pub mem_pct: f64,
pub mem_rss_kb: u64,
pub state: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_metric_state_claims_nothing() {
let m = HostMetrics::default();
assert_eq!(m.status.cpu, MetricState::Pending);
assert!(!m.status.cpu.is_collected());
assert!(m.cpu_percent_opt().is_none());
assert!(m.mem_percent().is_none());
assert!(m.load_opt().is_none());
assert!(m.uptime_opt().is_none());
assert!(m.net_opt().is_none());
}
#[test]
fn repeated_failures_accumulate_a_count() {
let mut st = MetricState::default();
st.fail("collector timed out");
assert_eq!(st.explain().unwrap(), "uncollected · collector timed out");
st.fail("collector timed out");
st.fail("collector timed out");
assert_eq!(
st.explain().unwrap(),
"uncollected · collector timed out 3×"
);
}
#[test]
fn a_different_reason_restarts_the_count() {
let mut st = MetricState::default();
st.fail("collector timed out");
st.fail("collector timed out");
st.fail("permission denied");
assert_eq!(st.explain().unwrap(), "uncollected · permission denied");
}
#[test]
fn unsupported_is_terminal_and_keeps_its_first_reason() {
let mut st = MetricState::default();
st.unsupported("macOS reports no per-device queue depth");
st.unsupported("something else");
assert_eq!(
st.explain().unwrap(),
"unsupported · macOS reports no per-device queue depth"
);
}
#[test]
fn disk_filter_keeps_user_data_and_drops_system_noise() {
let d = |mount: &str, total: u64, pct: f64| DiskInfo {
mount: mount.to_string(),
total_bytes: total,
used_bytes: 0,
use_pct: pct,
};
let gb = 1024 * 1024 * 1024;
assert!(d("/", 200 * gb, 42.0).is_user_visible());
assert!(d("/System/Volumes/Data", 200 * gb, 42.0).is_user_visible());
assert!(!d("/System/Volumes/xarts", 12_600_000, 1.0).is_user_visible());
assert!(!d("/System/Volumes/Preboot", 5 * gb, 10.0).is_user_visible());
assert!(!d(
"/private/var/folders/6c/T/AppTranslocation/foo",
200 * gb,
42.0
)
.is_user_visible());
assert!(d("/boot", 500 * 1024 * 1024, 94.0).is_user_visible());
assert!(!d("/boot", 500 * 1024 * 1024, 30.0).is_user_visible());
}
#[test]
fn user_disks_are_empty_until_collected_and_sorted_fullest_first() {
let gb = 1024 * 1024 * 1024;
let mut m = HostMetrics {
disks: vec![
DiskInfo {
mount: "/".into(),
total_bytes: 200 * gb,
used_bytes: 0,
use_pct: 9.0,
},
DiskInfo {
mount: "/System/Volumes/Data".into(),
total_bytes: 200 * gb,
used_bytes: 0,
use_pct: 42.0,
},
DiskInfo {
mount: "/System/Volumes/xarts".into(),
total_bytes: 12_600_000,
used_bytes: 0,
use_pct: 1.0,
},
],
..Default::default()
};
assert!(m.user_disks().is_empty());
m.status.disk = MetricState::Collected;
let shown = m.user_disks();
assert_eq!(shown.len(), 2);
assert_eq!(shown[0].mount, "/System/Volumes/Data"); assert_eq!(shown[1].mount, "/");
assert_eq!(m.hidden_disk_count(), 1);
}
#[test]
fn fail_all_reports_one_problem_per_group_with_a_shared_reason() {
let mut st = CollectionStatus::default();
st.fail_all("ssh channel closed");
let problems = st.problems();
assert_eq!(problems.len(), 7);
assert!(problems
.iter()
.all(|(_, e)| e == "uncollected · ssh channel closed"));
}
}