use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use sysinfo::{CpuRefreshKind, Disk, Disks, MemoryRefreshKind, Networks, RefreshKind, System};
pub mod history;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Pressure {
Nominal,
Warning,
Critical,
}
impl Pressure {
#[must_use]
pub fn worst(self, other: Self) -> Self {
self.max(other)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HostThresholds {
pub cpu_warning_pct: f32,
pub cpu_critical_pct: f32,
pub memory_warning_pct: f32,
pub memory_critical_pct: f32,
pub disk_warning_pct: f32,
pub disk_critical_pct: f32,
}
impl Default for HostThresholds {
fn default() -> Self {
Self {
cpu_warning_pct: 80.0,
cpu_critical_pct: 95.0,
memory_warning_pct: 80.0,
memory_critical_pct: 95.0,
disk_warning_pct: 85.0,
disk_critical_pct: 95.0,
}
}
}
impl Pressure {
#[must_use]
pub fn classify(usage_pct: f32, warning: f32, critical: f32) -> Self {
if usage_pct >= critical {
Pressure::Critical
} else if usage_pct >= warning {
Pressure::Warning
} else {
Pressure::Nominal
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CpuMetrics {
pub usage_pct: f32,
pub logical_cores: usize,
pub physical_cores: Option<usize>,
pub pressure: Pressure,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryMetrics {
pub total_bytes: u64,
pub used_bytes: u64,
pub available_bytes: u64,
pub usage_pct: f32,
pub swap_total_bytes: u64,
pub swap_used_bytes: u64,
pub pressure: Pressure,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MountMetrics {
pub mount_point: String,
pub name: String,
pub total_bytes: u64,
pub available_bytes: u64,
pub used_bytes: u64,
pub usage_pct: f32,
pub is_removable: bool,
pub pressure: Pressure,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskMetrics {
pub aggregate_total_bytes: u64,
pub aggregate_available_bytes: u64,
pub aggregate_used_bytes: u64,
pub aggregate_usage_pct: f32,
pub pressure: Pressure,
pub mounts: Vec<MountMetrics>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkMetrics {
pub rx_bytes_per_sec: f64,
pub tx_bytes_per_sec: f64,
pub rx_total_bytes: u64,
pub tx_total_bytes: u64,
pub window_secs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostMetrics {
pub cpu: CpuMetrics,
pub memory: MemoryMetrics,
pub disks: DiskMetrics,
pub network: NetworkMetrics,
pub overall_pressure: Pressure,
pub sampled_at_unix: Option<u64>,
}
fn pct(used: u64, total: u64) -> f32 {
if total == 0 {
return 0.0;
}
(used as f64 / total as f64 * 100.0) as f32
}
pub struct HostSampler {
sys: System,
networks: Networks,
disks: Disks,
last_net_refresh: Instant,
thresholds: HostThresholds,
disk_interval: Duration,
last_disk_refresh: Option<Instant>,
cached_disks: Option<DiskMetrics>,
disk_refreshes: u64,
}
impl HostSampler {
#[must_use]
pub fn new() -> Self {
Self::with_thresholds(HostThresholds::default())
}
#[must_use]
pub fn with_thresholds(thresholds: HostThresholds) -> Self {
Self::with_thresholds_and_disk_interval(
thresholds,
Duration::from_secs(history::DISK_SAMPLE_INTERVAL_SECS),
)
}
#[must_use]
pub fn with_thresholds_and_disk_interval(
thresholds: HostThresholds,
disk_interval: Duration,
) -> Self {
let sys = System::new_with_specifics(
RefreshKind::nothing()
.with_cpu(CpuRefreshKind::nothing().with_cpu_usage())
.with_memory(MemoryRefreshKind::nothing().with_ram().with_swap()),
);
let networks = Networks::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
Self {
sys,
networks,
disks,
last_net_refresh: Instant::now(),
thresholds,
disk_interval,
last_disk_refresh: None,
cached_disks: None,
disk_refreshes: 0,
}
}
#[cfg(test)]
pub(crate) fn disk_refresh_count(&self) -> u64 {
self.disk_refreshes
}
pub fn sample(&mut self) -> HostMetrics {
let t = &self.thresholds;
self.sys.refresh_cpu_usage();
let cpu_usage = self.sys.global_cpu_usage();
let cpu = CpuMetrics {
usage_pct: cpu_usage,
logical_cores: self.sys.cpus().len(),
physical_cores: self.sys.physical_core_count(),
pressure: Pressure::classify(cpu_usage, t.cpu_warning_pct, t.cpu_critical_pct),
};
self.sys.refresh_memory();
let total = self.sys.total_memory();
let used = self.sys.used_memory();
let mem_pct = pct(used, total);
let memory = MemoryMetrics {
total_bytes: total,
used_bytes: used,
available_bytes: self.sys.available_memory(),
usage_pct: mem_pct,
swap_total_bytes: self.sys.total_swap(),
swap_used_bytes: self.sys.used_swap(),
pressure: Pressure::classify(mem_pct, t.memory_warning_pct, t.memory_critical_pct),
};
let due = self
.last_disk_refresh
.is_none_or(|last| last.elapsed() >= self.disk_interval);
if due {
self.disks.refresh(true);
self.last_disk_refresh = Some(Instant::now());
self.disk_refreshes = self.disk_refreshes.saturating_add(1);
let fresh = self.build_disk_metrics();
self.cached_disks = Some(fresh);
}
let disks = self
.cached_disks
.clone()
.expect("the first sample always refreshes, so the cache is populated here");
self.networks.refresh(true);
let window = self.last_net_refresh.elapsed().as_secs_f64();
self.last_net_refresh = Instant::now();
let network = build_network_metrics(&self.networks, window);
let overall_pressure = cpu.pressure.worst(memory.pressure).worst(disks.pressure);
HostMetrics {
cpu,
memory,
disks,
network,
overall_pressure,
sampled_at_unix: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs()),
}
}
fn build_disk_metrics(&self) -> DiskMetrics {
let t = &self.thresholds;
let mut mounts = Vec::with_capacity(self.disks.list().len());
let (mut agg_total, mut agg_avail) = (0u64, 0u64);
for disk in self.disks.list() {
let mount = mount_metrics_from(disk, t);
if !mount.is_removable {
agg_total = agg_total.saturating_add(mount.total_bytes);
agg_avail = agg_avail.saturating_add(mount.available_bytes);
}
mounts.push(mount);
}
let agg_used = agg_total.saturating_sub(agg_avail);
let agg_pct = pct(agg_used, agg_total);
DiskMetrics {
aggregate_total_bytes: agg_total,
aggregate_available_bytes: agg_avail,
aggregate_used_bytes: agg_used,
aggregate_usage_pct: agg_pct,
pressure: Pressure::classify(agg_pct, t.disk_warning_pct, t.disk_critical_pct),
mounts,
}
}
}
impl Default for HostSampler {
fn default() -> Self {
Self::new()
}
}
fn build_network_metrics(networks: &Networks, window: f64) -> NetworkMetrics {
let (mut rx_delta, mut tx_delta, mut rx_total, mut tx_total) = (0u64, 0u64, 0u64, 0u64);
for (_iface, data) in networks {
rx_delta = rx_delta.saturating_add(data.received());
tx_delta = tx_delta.saturating_add(data.transmitted());
rx_total = rx_total.saturating_add(data.total_received());
tx_total = tx_total.saturating_add(data.total_transmitted());
}
let (rx_rate, tx_rate) = if window > 0.0 {
(rx_delta as f64 / window, tx_delta as f64 / window)
} else {
(0.0, 0.0)
};
NetworkMetrics {
rx_bytes_per_sec: rx_rate,
tx_bytes_per_sec: tx_rate,
rx_total_bytes: rx_total,
tx_total_bytes: tx_total,
window_secs: window,
}
}
fn mount_metrics_from(disk: &Disk, t: &HostThresholds) -> MountMetrics {
let total = disk.total_space();
let available = disk.available_space();
let used = total.saturating_sub(available);
let usage_pct = pct(used, total);
MountMetrics {
mount_point: disk.mount_point().to_string_lossy().into_owned(),
name: disk.name().to_string_lossy().into_owned(),
total_bytes: total,
available_bytes: available,
used_bytes: used,
usage_pct,
is_removable: disk.is_removable(),
pressure: Pressure::classify(usage_pct, t.disk_warning_pct, t.disk_critical_pct),
}
}
#[must_use]
pub fn mount_for_path(path: &Path) -> Option<MountMetrics> {
let probe = nearest_existing_ancestor(path)?;
let mounts = sample_mounts(&HostThresholds::default());
match device_id(&probe) {
Some(device) => select_mount_with_device(&mounts, device, device_id).cloned(),
None => select_mount_for_path(&mounts, &probe).cloned(),
}
}
#[must_use]
pub fn select_mount_for_path<'a>(
mounts: &'a [MountMetrics],
path: &Path,
) -> Option<&'a MountMetrics> {
mounts
.iter()
.filter(|m| path.starts_with(Path::new(&m.mount_point)))
.max_by_key(|m| Path::new(&m.mount_point).components().count())
}
fn sample_mounts(thresholds: &HostThresholds) -> Vec<MountMetrics> {
Disks::new_with_refreshed_list()
.list()
.iter()
.map(|disk| mount_metrics_from(disk, thresholds))
.collect()
}
fn select_mount_with_device(
mounts: &[MountMetrics],
device: u64,
device_of: impl Fn(&Path) -> Option<u64>,
) -> Option<&MountMetrics> {
mounts
.iter()
.filter(|m| device_of(Path::new(&m.mount_point)) == Some(device))
.max_by_key(|m| Path::new(&m.mount_point).components().count())
}
#[cfg(unix)]
fn device_id(path: &Path) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).ok().map(|m| m.dev())
}
#[cfg(not(unix))]
fn device_id(_path: &Path) -> Option<u64> {
None
}
fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
absolute
.ancestors()
.find(|a| a.exists())
.map(|a| a.canonicalize().unwrap_or_else(|_| a.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sampler_produces_plausible_snapshot() {
let mut s = HostSampler::new();
let _first = s.sample();
let m = s.sample();
assert!(m.cpu.usage_pct >= 0.0 && m.cpu.usage_pct <= 100.0);
assert!(m.cpu.logical_cores >= 1, "at least one logical core");
assert!(m.memory.usage_pct >= 0.0 && m.memory.usage_pct <= 100.0);
assert!(
m.memory.total_bytes > 0,
"a real host reports some total memory"
);
assert!(m.disks.aggregate_usage_pct >= 0.0 && m.disks.aggregate_usage_pct <= 100.0);
for mount in &m.disks.mounts {
assert!(mount.usage_pct >= 0.0 && mount.usage_pct <= 100.0);
assert!(mount.used_bytes <= mount.total_bytes.max(mount.used_bytes));
}
assert!(m.network.rx_bytes_per_sec >= 0.0);
assert!(m.network.tx_bytes_per_sec >= 0.0);
assert!(m.network.window_secs >= 0.0);
}
#[test]
fn disk_refresh_is_skipped_inside_the_interval() {
let mut s = HostSampler::with_thresholds_and_disk_interval(
HostThresholds::default(),
Duration::from_secs(60),
);
let first = s.sample();
for _ in 0..3 {
let later = s.sample();
assert_eq!(
later.disks.mounts.len(),
first.disks.mounts.len(),
"the cached DiskMetrics is served unchanged between refreshes"
);
assert_eq!(
later.disks.aggregate_total_bytes,
first.disks.aggregate_total_bytes
);
assert_eq!(
later.disks.aggregate_available_bytes,
first.disks.aggregate_available_bytes
);
assert_eq!(later.disks.pressure, first.disks.pressure);
}
assert_eq!(
s.disk_refresh_count(),
1,
"four samples inside one 60s disk interval must walk the volumes exactly once"
);
}
#[test]
fn disk_refresh_resumes_after_the_interval() {
let mut s = HostSampler::with_thresholds_and_disk_interval(
HostThresholds::default(),
Duration::from_millis(10),
);
let _ = s.sample();
assert_eq!(s.disk_refresh_count(), 1);
std::thread::sleep(Duration::from_millis(25));
let _ = s.sample();
assert_eq!(
s.disk_refresh_count(),
2,
"a sample taken after the disk interval elapsed must refresh again"
);
}
#[test]
fn first_sample_always_refreshes_disks() {
let mut s = HostSampler::with_thresholds_and_disk_interval(
HostThresholds::default(),
Duration::from_secs(3600),
);
let m = s.sample();
assert_eq!(
s.disk_refresh_count(),
1,
"the first sample refreshes disks regardless of the interval"
);
assert!(m.disks.aggregate_usage_pct >= 0.0 && m.disks.aggregate_usage_pct <= 100.0);
}
#[test]
fn pressure_classification_boundaries() {
assert_eq!(Pressure::classify(79.9, 80.0, 95.0), Pressure::Nominal);
assert_eq!(Pressure::classify(80.0, 80.0, 95.0), Pressure::Warning);
assert_eq!(Pressure::classify(94.9, 80.0, 95.0), Pressure::Warning);
assert_eq!(Pressure::classify(95.0, 80.0, 95.0), Pressure::Critical);
assert_eq!(Pressure::classify(100.0, 80.0, 95.0), Pressure::Critical);
assert_eq!(Pressure::classify(f32::NAN, 80.0, 95.0), Pressure::Nominal);
}
#[test]
fn overall_is_worst_subsystem() {
assert_eq!(
Pressure::Nominal.worst(Pressure::Critical),
Pressure::Critical
);
assert_eq!(
Pressure::Warning.worst(Pressure::Nominal),
Pressure::Warning
);
assert_eq!(
Pressure::Critical.worst(Pressure::Warning),
Pressure::Critical
);
assert_eq!(
Pressure::Nominal.worst(Pressure::Nominal),
Pressure::Nominal
);
}
#[test]
fn thresholds_are_configurable() {
let custom = HostThresholds {
cpu_warning_pct: 1.0,
cpu_critical_pct: 2.0,
..HostThresholds::default()
};
let _s = HostSampler::with_thresholds(custom);
assert_eq!(
Pressure::classify(1.5, custom.cpu_warning_pct, custom.cpu_critical_pct),
Pressure::Warning
);
assert_eq!(
Pressure::classify(2.0, custom.cpu_warning_pct, custom.cpu_critical_pct),
Pressure::Critical
);
}
#[test]
fn network_rate_over_window() {
let networks = Networks::new(); let m = build_network_metrics(&networks, 2.0);
assert_eq!(m.rx_bytes_per_sec, 0.0);
assert_eq!(m.tx_bytes_per_sec, 0.0);
assert_eq!(m.window_secs, 2.0);
let m0 = build_network_metrics(&networks, 0.0);
assert_eq!(m0.rx_bytes_per_sec, 0.0);
}
#[test]
fn snapshot_serde_round_trip() {
let mut s = HostSampler::new();
let m = s.sample();
let json = serde_json::to_string(&m).expect("serialise HostMetrics");
let back: HostMetrics = serde_json::from_str(&json).expect("deserialise HostMetrics");
assert_eq!(back.cpu.logical_cores, m.cpu.logical_cores);
assert_eq!(back.memory.total_bytes, m.memory.total_bytes);
assert_eq!(back.disks.mounts.len(), m.disks.mounts.len());
assert_eq!(back.overall_pressure, m.overall_pressure);
}
fn mount(mount_point: &str, usage_pct: f32) -> MountMetrics {
MountMetrics {
mount_point: mount_point.to_string(),
name: "synthetic".to_string(),
total_bytes: 100,
available_bytes: 100 - usage_pct as u64,
used_bytes: usage_pct as u64,
usage_pct,
is_removable: false,
pressure: Pressure::Nominal,
}
}
#[test]
fn select_mount_for_path_picks_the_deepest_matching_mount() {
let mounts = vec![mount("/", 4.0), mount("/System/Volumes/Data", 92.0)];
let picked = select_mount_for_path(&mounts, Path::new("/System/Volumes/Data/Users/x"))
.expect("a mount must match an absolute path when `/` is listed");
assert_eq!(picked.mount_point, "/System/Volumes/Data");
assert_eq!(picked.usage_pct, 92.0);
}
#[test]
fn select_mount_for_path_is_component_wise() {
let mounts = vec![mount("/", 4.0), mount("/var", 92.0)];
assert_eq!(
select_mount_for_path(&mounts, Path::new("/variable/x"))
.expect("`/` matches")
.mount_point,
"/"
);
assert!(
select_mount_for_path(&[mount("/var", 92.0)], Path::new("/home/x")).is_none(),
"a path under no listed mount must be unmeasurable, not defaulted"
);
}
#[test]
fn a_device_matching_no_enumerated_mount_is_unmeasurable() {
let mounts = vec![mount("/", 4.0), mount("/System/Volumes/Data", 92.0)];
assert!(
select_mount_with_device(&mounts, 42, |_| Some(7)).is_none(),
"no enumerated mount sits on the probe's device — that is \
unmeasurable, not `/`"
);
assert_eq!(
select_mount_with_device(&mounts, 7, |p| (p == Path::new("/System/Volumes/Data"))
.then_some(7))
.expect("the matching mount is selected")
.mount_point,
"/System/Volumes/Data"
);
}
#[test]
fn mount_for_path_reports_a_plausible_mount_for_the_cwd() {
let target = std::env::current_dir()
.expect("cwd")
.join("does-not-exist-7497")
.join("nor-this");
let m = mount_for_path(&target).expect("the working directory sits on some mount");
assert!(!m.mount_point.is_empty());
assert!(
(0.0..=100.0).contains(&m.usage_pct),
"usage must be a percentage, got {}",
m.usage_pct
);
}
}