use std::collections::HashMap;
use stackpatrol_core::event::Event;
use sysinfo::{Disks, System};
use crate::config::ResourceProbeConfig;
const HYSTERESIS_MARGIN: u8 = 5;
const LOAD_CLEAR_RATIO: f32 = 0.9;
const DEFAULT_WARNING_OFFSET: u8 = 10;
const MIN_WARNING_PERCENT: u8 = 50;
const LOAD_WARNING_RATIO: f32 = 0.75;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Zone {
Ok,
Warning,
Critical,
}
pub struct ResourceProbe {
disk_warning: u8,
disk_critical: u8,
memory_warning: u8,
memory_critical: u8,
load_warning: f32,
load_critical: f32,
sys: System,
disks: Disks,
last_disk_zone: HashMap<String, Zone>,
last_memory_zone: Zone,
last_load_zone: Zone,
}
impl ResourceProbe {
pub fn new(cfg: &ResourceProbeConfig) -> Self {
let disk_critical = cfg.disk_high_percent;
let disk_warning = resolve_warning_u8(cfg.disk_warning_percent, disk_critical);
let memory_critical = cfg.memory_high_percent;
let memory_warning = resolve_warning_u8(cfg.memory_warning_percent, memory_critical);
let load_critical = if cfg.load_1m_high > 0.0 {
cfg.load_1m_high
} else {
let cpu_count = std::thread::available_parallelism()
.map(|n| n.get() as f32)
.unwrap_or(1.0);
2.0 * cpu_count
};
let load_warning = if cfg.load_1m_warning > 0.0 {
cfg.load_1m_warning
} else {
load_critical * LOAD_WARNING_RATIO
};
Self {
disk_warning,
disk_critical,
memory_warning,
memory_critical,
load_warning,
load_critical,
sys: System::new(),
disks: Disks::new_with_refreshed_list(),
last_disk_zone: HashMap::new(),
last_memory_zone: Zone::Ok,
last_load_zone: Zone::Ok,
}
}
pub fn load_threshold(&self) -> f32 {
self.load_critical
}
#[cfg(test)]
pub fn load_warning_threshold(&self) -> f32 {
self.load_warning
}
#[cfg(test)]
pub fn disk_warning_threshold(&self) -> u8 {
self.disk_warning
}
#[cfg(test)]
pub fn memory_warning_threshold(&self) -> u8 {
self.memory_warning
}
pub fn tick(&mut self) -> Vec<Event> {
let mut events = Vec::new();
self.sys.refresh_memory();
self.disks.refresh();
for disk in &self.disks {
let total = disk.total_space();
if total == 0 {
continue;
}
let mount = disk.mount_point().to_string_lossy().to_string();
let used = total.saturating_sub(disk.available_space());
let percent = ((used as f64 / total as f64) * 100.0).round().min(100.0) as u8;
let prev = self.last_disk_zone.get(&mount).copied().unwrap_or(Zone::Ok);
let next = next_zone_u8(prev, percent, self.disk_warning, self.disk_critical);
if let Some(ev) = disk_transition_event(prev, next, &mount, percent) {
events.push(ev);
}
self.last_disk_zone.insert(mount, next);
}
let mem_total = self.sys.total_memory();
if mem_total > 0 {
let used = self.sys.used_memory();
let percent = ((used as f64 / mem_total as f64) * 100.0)
.round()
.min(100.0) as u8;
let next = next_zone_u8(
self.last_memory_zone,
percent,
self.memory_warning,
self.memory_critical,
);
if let Some(ev) = memory_transition_event(self.last_memory_zone, next, percent) {
events.push(ev);
}
self.last_memory_zone = next;
}
let load = System::load_average();
let load_1m = load.one as f32;
let next = next_zone_f32(
self.last_load_zone,
load_1m,
self.load_warning,
self.load_critical,
);
if let Some(ev) = load_transition_event(self.last_load_zone, next, load_1m) {
events.push(ev);
}
self.last_load_zone = next;
events
}
}
fn resolve_warning_u8(configured: u8, critical: u8) -> u8 {
if configured > 0 && configured < critical {
configured
} else {
critical
.saturating_sub(DEFAULT_WARNING_OFFSET)
.max(MIN_WARNING_PERCENT)
}
}
fn next_zone_u8(current: Zone, value: u8, warn_t: u8, crit_t: u8) -> Zone {
match current {
Zone::Ok => {
if value >= crit_t {
Zone::Critical
} else if value >= warn_t {
Zone::Warning
} else {
Zone::Ok
}
}
Zone::Warning => {
if value >= crit_t {
Zone::Critical
} else if value < warn_t.saturating_sub(HYSTERESIS_MARGIN) {
Zone::Ok
} else {
Zone::Warning
}
}
Zone::Critical => {
if value < crit_t.saturating_sub(HYSTERESIS_MARGIN) {
if value >= warn_t {
Zone::Warning
} else {
Zone::Ok
}
} else {
Zone::Critical
}
}
}
}
fn next_zone_f32(current: Zone, value: f32, warn_t: f32, crit_t: f32) -> Zone {
match current {
Zone::Ok => {
if value >= crit_t {
Zone::Critical
} else if value >= warn_t {
Zone::Warning
} else {
Zone::Ok
}
}
Zone::Warning => {
if value >= crit_t {
Zone::Critical
} else if value < warn_t * LOAD_CLEAR_RATIO {
Zone::Ok
} else {
Zone::Warning
}
}
Zone::Critical => {
if value < crit_t * LOAD_CLEAR_RATIO {
if value >= warn_t {
Zone::Warning
} else {
Zone::Ok
}
} else {
Zone::Critical
}
}
}
}
fn disk_transition_event(prev: Zone, next: Zone, mount: &str, percent: u8) -> Option<Event> {
match (prev, next) {
(Zone::Ok, Zone::Warning) => Some(Event::DiskWarning {
mount: mount.to_string(),
percent,
}),
(Zone::Ok, Zone::Critical) | (Zone::Warning, Zone::Critical) => Some(Event::DiskHigh {
mount: mount.to_string(),
percent,
}),
_ => None,
}
}
fn memory_transition_event(prev: Zone, next: Zone, percent: u8) -> Option<Event> {
match (prev, next) {
(Zone::Ok, Zone::Warning) => Some(Event::MemoryWarning { percent }),
(Zone::Ok, Zone::Critical) | (Zone::Warning, Zone::Critical) => {
Some(Event::MemoryHigh { percent })
}
_ => None,
}
}
fn load_transition_event(prev: Zone, next: Zone, load_1m: f32) -> Option<Event> {
match (prev, next) {
(Zone::Ok, Zone::Warning) => Some(Event::LoadWarning { load_1m }),
(Zone::Ok, Zone::Critical) | (Zone::Warning, Zone::Critical) => {
Some(Event::LoadHigh { load_1m })
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(disk: u8, mem: u8, load: f32) -> ResourceProbeConfig {
ResourceProbeConfig {
disk_high_percent: disk,
disk_warning_percent: 0,
memory_high_percent: mem,
memory_warning_percent: 0,
load_1m_high: load,
load_1m_warning: 0.0,
}
}
#[test]
fn auto_load_threshold_when_unset() {
let p = ResourceProbe::new(&cfg(90, 90, 0.0));
assert!(p.load_threshold() > 0.0);
assert!(p.load_warning_threshold() < p.load_threshold());
assert!(p.load_warning_threshold() > 0.0);
}
#[test]
fn explicit_load_threshold_is_honored() {
let p = ResourceProbe::new(&cfg(90, 90, 7.5));
assert!((p.load_threshold() - 7.5).abs() < f32::EPSILON);
}
#[test]
fn auto_disk_warning_is_10pp_below_critical() {
let p = ResourceProbe::new(&cfg(90, 90, 0.0));
assert_eq!(p.disk_warning_threshold(), 80);
assert_eq!(p.memory_warning_threshold(), 80);
}
#[test]
fn explicit_disk_warning_is_honored() {
let mut c = cfg(90, 90, 0.0);
c.disk_warning_percent = 75;
c.memory_warning_percent = 70;
let p = ResourceProbe::new(&c);
assert_eq!(p.disk_warning_threshold(), 75);
assert_eq!(p.memory_warning_threshold(), 70);
}
#[test]
fn warning_floors_at_50_percent_for_low_critical() {
let mut c = cfg(55, 55, 0.0);
let p = ResourceProbe::new(&c);
assert_eq!(p.disk_warning_threshold(), 50);
c.disk_high_percent = 30;
let p = ResourceProbe::new(&c);
assert_eq!(p.disk_warning_threshold(), 50);
}
#[test]
fn invalid_explicit_warning_falls_back_to_default() {
let mut c = cfg(80, 80, 0.0);
c.disk_warning_percent = 85; let p = ResourceProbe::new(&c);
assert_eq!(p.disk_warning_threshold(), 70); }
#[test]
fn high_threshold_disables_alerts_on_healthy_host() {
let mut p = ResourceProbe::new(&cfg(100, 100, 1_000_000.0));
let events = p.tick();
for e in &events {
assert!(
!matches!(
e,
Event::DiskHigh { .. }
| Event::DiskWarning { .. }
| Event::MemoryHigh { .. }
| Event::MemoryWarning { .. }
| Event::LoadHigh { .. }
| Event::LoadWarning { .. }
),
"unexpected alert at impossible thresholds: {e:?}"
);
}
}
#[test]
fn zone_ok_to_warning_to_critical() {
assert_eq!(next_zone_u8(Zone::Ok, 75, 80, 90), Zone::Ok);
assert_eq!(next_zone_u8(Zone::Ok, 80, 80, 90), Zone::Warning);
assert_eq!(next_zone_u8(Zone::Warning, 85, 80, 90), Zone::Warning);
assert_eq!(next_zone_u8(Zone::Warning, 90, 80, 90), Zone::Critical);
}
#[test]
fn zone_ok_jumps_directly_to_critical_when_value_spikes() {
assert_eq!(next_zone_u8(Zone::Ok, 95, 80, 90), Zone::Critical);
}
#[test]
fn zone_critical_clears_through_warning_with_hysteresis() {
assert_eq!(next_zone_u8(Zone::Critical, 88, 80, 90), Zone::Critical); assert_eq!(next_zone_u8(Zone::Critical, 84, 80, 90), Zone::Warning); assert_eq!(next_zone_u8(Zone::Critical, 70, 80, 90), Zone::Ok); }
#[test]
fn zone_warning_clears_to_ok_with_hysteresis() {
assert_eq!(next_zone_u8(Zone::Warning, 76, 80, 90), Zone::Warning);
assert_eq!(next_zone_u8(Zone::Warning, 74, 80, 90), Zone::Ok);
}
#[test]
fn transition_events_match_expected_severity() {
assert!(matches!(
disk_transition_event(Zone::Ok, Zone::Warning, "/", 82),
Some(Event::DiskWarning { percent: 82, .. })
));
assert!(matches!(
disk_transition_event(Zone::Ok, Zone::Critical, "/", 95),
Some(Event::DiskHigh { percent: 95, .. })
));
assert!(matches!(
disk_transition_event(Zone::Warning, Zone::Critical, "/", 92),
Some(Event::DiskHigh { percent: 92, .. })
));
assert!(disk_transition_event(Zone::Critical, Zone::Warning, "/", 85).is_none());
assert!(disk_transition_event(Zone::Warning, Zone::Ok, "/", 50).is_none());
}
#[test]
fn load_zone_uses_ratio_hysteresis() {
assert_eq!(
next_zone_f32(Zone::Critical, 9.5, 7.5, 10.0),
Zone::Critical
);
assert_eq!(next_zone_f32(Zone::Critical, 8.5, 7.5, 10.0), Zone::Warning);
assert_eq!(next_zone_f32(Zone::Warning, 7.0, 7.5, 10.0), Zone::Warning);
assert_eq!(next_zone_f32(Zone::Warning, 6.5, 7.5, 10.0), Zone::Ok);
}
}