use serde::{Deserialize, Serialize};
use std::time::Instant;
use sysinfo::{CpuRefreshKind, 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,
}
impl HostSampler {
#[must_use]
pub fn new() -> Self {
Self::with_thresholds(HostThresholds::default())
}
#[must_use]
pub fn with_thresholds(thresholds: HostThresholds) -> 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,
}
}
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),
};
self.disks.refresh(true);
let disks = self.build_disk_metrics();
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 total = disk.total_space();
let avail = disk.available_space();
let used = total.saturating_sub(avail);
let removable = disk.is_removable();
if !removable {
agg_total = agg_total.saturating_add(total);
agg_avail = agg_avail.saturating_add(avail);
}
let usage_pct = pct(used, total);
mounts.push(MountMetrics {
mount_point: disk.mount_point().to_string_lossy().into_owned(),
name: disk.name().to_string_lossy().into_owned(),
total_bytes: total,
available_bytes: avail,
used_bytes: used,
usage_pct,
is_removable: removable,
pressure: Pressure::classify(usage_pct, t.disk_warning_pct, t.disk_critical_pct),
});
}
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,
}
}
#[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 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);
}
}