use std::collections::HashMap;
use std::time::Instant;
use chrono::{DateTime, Local};
use crate::common::config::AlertConfig;
use crate::device::GpuInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AlertLevel {
Ok,
Warn,
Crit,
}
impl AlertLevel {
pub fn as_label(self) -> &'static str {
match self {
AlertLevel::Ok => "ok",
AlertLevel::Warn => "warn",
AlertLevel::Crit => "crit",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuleKind {
Temperature,
IdleUtilization,
Power,
}
impl RuleKind {
pub fn as_label(self) -> &'static str {
match self {
RuleKind::Temperature => "temperature",
RuleKind::IdleUtilization => "idle_utilization",
RuleKind::Power => "power",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct RuleKey {
device_id: String,
rule: RuleKind,
}
#[derive(Debug, Clone)]
struct RuleState {
level: AlertLevel,
idle_since: Option<Instant>,
}
impl Default for RuleState {
fn default() -> Self {
Self {
level: AlertLevel::Ok,
idle_since: None,
}
}
}
#[derive(Debug, Clone)]
pub struct AlertTransition {
pub timestamp: DateTime<Local>,
pub host: String,
pub gpu_index: Option<i32>,
pub rule: RuleKind,
pub from: AlertLevel,
pub to: AlertLevel,
pub value: f64,
pub threshold: f64,
pub message: String,
#[allow(dead_code)] pub card_key: String,
}
#[derive(Debug, Clone, Default)]
pub struct Alerter {
config: AlertConfig,
states: HashMap<RuleKey, RuleState>,
flashing: HashMap<String, Instant>,
}
impl Alerter {
pub fn new(config: AlertConfig) -> Self {
Self {
config,
states: HashMap::new(),
flashing: HashMap::new(),
}
}
#[allow(dead_code)] pub fn set_config(&mut self, config: AlertConfig) {
self.config = config;
}
pub fn config(&self) -> &AlertConfig {
&self.config
}
pub fn mark_flash(&mut self, card_key: &str) {
self.flashing.insert(
card_key.to_string(),
Instant::now() + std::time::Duration::from_secs(self.config.flash_duration_secs),
);
}
pub fn is_flashing(&self, card_key: &str) -> bool {
match self.flashing.get(card_key) {
Some(deadline) => *deadline > Instant::now(),
None => false,
}
}
pub fn evaluate(&mut self, gpus: &[GpuInfo]) -> Vec<AlertTransition> {
let mut transitions = Vec::new();
let now = Instant::now();
let mut seen: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(gpus.len());
for gpu in gpus {
seen.insert(device_id(gpu));
self.evaluate_temperature(gpu, &mut transitions);
self.evaluate_idle_utilization(gpu, now, &mut transitions);
self.evaluate_power(gpu, &mut transitions);
}
let now_cmp = Instant::now();
self.flashing.retain(|_, d| *d > now_cmp);
self.states.retain(|k, _| seen.contains(&k.device_id));
transitions
}
fn evaluate_temperature(&mut self, gpu: &GpuInfo, out: &mut Vec<AlertTransition>) {
let Some(temp) = gpu.temperature_reading().map(f64::from) else {
return;
};
let warn_on = self.config.temp_warn_c as f64;
let crit_on = self.config.temp_crit_c as f64;
let warn_off = warn_on - self.config.hysteresis_c as f64;
let crit_off = crit_on - self.config.hysteresis_c as f64;
let key = RuleKey {
device_id: device_id(gpu),
rule: RuleKind::Temperature,
};
let current = self.states.entry(key).or_default().level;
let target = match current {
AlertLevel::Crit => {
if temp <= crit_off {
if warn_on <= 0.0 || temp <= warn_off {
AlertLevel::Ok
} else {
AlertLevel::Warn
}
} else {
AlertLevel::Crit
}
}
AlertLevel::Warn => {
if temp >= crit_on && crit_on > 0.0 {
AlertLevel::Crit
} else if temp <= warn_off {
AlertLevel::Ok
} else {
AlertLevel::Warn
}
}
AlertLevel::Ok => {
if crit_on > 0.0 && temp >= crit_on {
AlertLevel::Crit
} else if warn_on > 0.0 && temp >= warn_on {
AlertLevel::Warn
} else {
AlertLevel::Ok
}
}
};
if target != current {
let key = RuleKey {
device_id: device_id(gpu),
rule: RuleKind::Temperature,
};
if let Some(state) = self.states.get_mut(&key) {
state.level = target;
}
let threshold = if target == AlertLevel::Crit {
crit_on
} else {
warn_on
};
let message =
build_message(gpu, RuleKind::Temperature, current, target, temp, threshold);
let card_key = device_id(gpu);
self.mark_flash(&card_key);
out.push(AlertTransition {
timestamp: Local::now(),
host: gpu.hostname.clone(),
gpu_index: gpu.detail.get("index").and_then(|s| s.parse().ok()),
rule: RuleKind::Temperature,
from: current,
to: target,
value: temp,
threshold,
message,
card_key,
});
}
}
fn evaluate_idle_utilization(
&mut self,
gpu: &GpuInfo,
now: Instant,
out: &mut Vec<AlertTransition>,
) {
if self.config.util_idle_warn_mins == 0 {
return;
}
let Some(util) = gpu.utilization_reading() else {
return;
};
let threshold_pct = self.config.util_idle_pct as f64;
let warn_after =
std::time::Duration::from_secs(self.config.util_idle_warn_mins as u64 * 60);
let key = RuleKey {
device_id: device_id(gpu),
rule: RuleKind::IdleUtilization,
};
let state = self.states.entry(key).or_default();
let prev_level = state.level;
if util <= threshold_pct {
let start = state.idle_since.get_or_insert(now);
let elapsed = now.duration_since(*start);
let target = if elapsed >= warn_after {
AlertLevel::Warn
} else {
AlertLevel::Ok
};
if target != prev_level {
state.level = target;
let threshold = self.config.util_idle_warn_mins as f64;
let message = build_message(
gpu,
RuleKind::IdleUtilization,
prev_level,
target,
util,
threshold,
);
let card_key = device_id(gpu);
self.mark_flash(&card_key);
out.push(AlertTransition {
timestamp: Local::now(),
host: gpu.hostname.clone(),
gpu_index: gpu.detail.get("index").and_then(|s| s.parse().ok()),
rule: RuleKind::IdleUtilization,
from: prev_level,
to: target,
value: util,
threshold,
message,
card_key,
});
}
} else if state.idle_since.is_some() || prev_level != AlertLevel::Ok {
state.idle_since = None;
if prev_level != AlertLevel::Ok {
state.level = AlertLevel::Ok;
let threshold = self.config.util_idle_warn_mins as f64;
let message = build_message(
gpu,
RuleKind::IdleUtilization,
prev_level,
AlertLevel::Ok,
util,
threshold,
);
let card_key = device_id(gpu);
self.mark_flash(&card_key);
out.push(AlertTransition {
timestamp: Local::now(),
host: gpu.hostname.clone(),
gpu_index: gpu.detail.get("index").and_then(|s| s.parse().ok()),
rule: RuleKind::IdleUtilization,
from: prev_level,
to: AlertLevel::Ok,
value: util,
threshold,
message,
card_key,
});
}
}
}
fn evaluate_power(&mut self, gpu: &GpuInfo, out: &mut Vec<AlertTransition>) {
let limit = self.config.power_crit_w as f64;
if limit <= 0.0 {
return;
}
let Some(value) = gpu.power_consumption_reading() else {
return;
};
let off = limit - self.config.hysteresis_c as f64;
let key = RuleKey {
device_id: device_id(gpu),
rule: RuleKind::Power,
};
let current = self.states.entry(key).or_default().level;
let target = match current {
AlertLevel::Crit => {
if value <= off {
AlertLevel::Ok
} else {
AlertLevel::Crit
}
}
_ => {
if value >= limit {
AlertLevel::Crit
} else {
AlertLevel::Ok
}
}
};
if target != current {
let key = RuleKey {
device_id: device_id(gpu),
rule: RuleKind::Power,
};
if let Some(state) = self.states.get_mut(&key) {
state.level = target;
}
let message = build_message(gpu, RuleKind::Power, current, target, value, limit);
let card_key = device_id(gpu);
self.mark_flash(&card_key);
out.push(AlertTransition {
timestamp: Local::now(),
host: gpu.hostname.clone(),
gpu_index: gpu.detail.get("index").and_then(|s| s.parse().ok()),
rule: RuleKind::Power,
from: current,
to: target,
value,
threshold: limit,
message,
card_key,
});
}
}
}
fn build_message(
gpu: &GpuInfo,
rule: RuleKind,
from: AlertLevel,
to: AlertLevel,
value: f64,
threshold: f64,
) -> String {
let ix = gpu.detail.get("index").map(|s| s.as_str()).unwrap_or("?");
let hn = &gpu.hostname;
let label = rule.as_label();
let from_s = from.as_label();
let to_s = to.as_label();
match rule {
RuleKind::Temperature => {
format!("{hn} gpu{ix} {label}: {from_s}->{to_s} ({value:.0}C / thr {threshold:.0}C)",)
}
RuleKind::IdleUtilization => {
format!("{hn} gpu{ix} idle: {from_s}->{to_s} ({value:.0}% for >= {threshold:.0}m)",)
}
RuleKind::Power => {
format!("{hn} gpu{ix} power: {from_s}->{to_s} ({value:.0}W / thr {threshold:.0}W)",)
}
}
}
fn device_id(gpu: &GpuInfo) -> String {
if !gpu.uuid.is_empty() {
gpu.uuid.clone()
} else {
format!(
"{}@{}",
gpu.detail.get("index").map(|s| s.as_str()).unwrap_or("?"),
gpu.hostname
)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WebhookPayload {
pub timestamp: String,
pub host: String,
pub gpu_index: Option<i32>,
pub rule: String,
pub from: String,
pub to: String,
pub value: f64,
pub threshold: f64,
}
impl From<&AlertTransition> for WebhookPayload {
fn from(t: &AlertTransition) -> Self {
Self {
timestamp: t.timestamp.to_rfc3339(),
host: t.host.clone(),
gpu_index: t.gpu_index,
rule: t.rule.as_label().to_string(),
from: t.from.as_label().to_string(),
to: t.to.as_label().to_string(),
value: t.value,
threshold: t.threshold,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn gpu(temp: u32, util: f64, power: f64) -> GpuInfo {
let mut detail = HashMap::new();
detail.insert("index".to_string(), "0".to_string());
GpuInfo {
uuid: "GPU-0".to_string(),
time: String::new(),
name: "TestGPU".to_string(),
device_type: "GPU".to_string(),
host_id: "h".to_string(),
hostname: "n01".to_string(),
instance: String::new(),
utilization: util,
ane_utilization: 0.0,
dla_utilization: None,
tensorcore_utilization: None,
temperature: temp,
used_memory: 0,
total_memory: 0,
frequency: 0,
power_consumption: power,
gpu_core_count: None,
temperature_threshold_slowdown: None,
temperature_threshold_shutdown: None,
temperature_threshold_max_operating: None,
temperature_threshold_acoustic: None,
performance_state: None,
fan_speed_rpm: None,
numa_node_id: None,
gsp_firmware_mode: None,
gsp_firmware_version: None,
nvlink_remote_devices: Vec::new(),
gpm_metrics: None,
detail,
}
}
fn default_cfg() -> AlertConfig {
AlertConfig {
temp_warn_c: 80,
temp_crit_c: 90,
util_idle_pct: 5,
util_idle_warn_mins: 0, power_crit_w: 0, bell_on_critical: false,
webhook_url: String::new(),
hysteresis_c: 2,
flash_duration_secs: 2,
}
}
#[test]
fn no_transition_when_below_warn() {
let mut a = Alerter::new(default_cfg());
let t = a.evaluate(&[gpu(70, 50.0, 0.0)]);
assert!(t.is_empty());
}
#[test]
fn ok_to_warn_emits_one_transition() {
let mut a = Alerter::new(default_cfg());
let t = a.evaluate(&[gpu(81, 50.0, 0.0)]);
assert_eq!(t.len(), 1);
assert_eq!(t[0].rule, RuleKind::Temperature);
assert_eq!(t[0].from, AlertLevel::Ok);
assert_eq!(t[0].to, AlertLevel::Warn);
}
#[test]
fn warn_to_crit_emits_transition() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(81, 50.0, 0.0)]); let t = a.evaluate(&[gpu(91, 50.0, 0.0)]);
assert_eq!(t.len(), 1);
assert_eq!(t[0].from, AlertLevel::Warn);
assert_eq!(t[0].to, AlertLevel::Crit);
}
#[test]
fn hysteresis_keeps_crit_until_drop() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(91, 0.0, 0.0)]); let t = a.evaluate(&[gpu(89, 0.0, 0.0)]);
assert!(t.is_empty(), "expected no transition at 89°C, got {t:?}");
let t = a.evaluate(&[gpu(88, 0.0, 0.0)]);
assert_eq!(t.len(), 1);
assert_eq!(t[0].from, AlertLevel::Crit);
assert_eq!(t[0].to, AlertLevel::Warn);
}
#[test]
fn hysteresis_boundary_exactly_at_crit_off() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(91, 0.0, 0.0)]);
let t = a.evaluate(&[gpu(88, 0.0, 0.0)]);
assert_eq!(t.len(), 1);
}
#[test]
fn recovery_to_ok_emits_transition() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(85, 0.0, 0.0)]); let t = a.evaluate(&[gpu(77, 0.0, 0.0)]);
assert_eq!(t.len(), 1);
assert_eq!(t[0].to, AlertLevel::Ok);
}
#[test]
fn zero_thresholds_disable_rule() {
let mut cfg = default_cfg();
cfg.temp_warn_c = 0;
cfg.temp_crit_c = 0;
let mut a = Alerter::new(cfg);
let t = a.evaluate(&[gpu(95, 0.0, 0.0)]);
assert!(t.is_empty());
}
#[test]
fn zero_temperature_is_treated_as_absent() {
let mut a = Alerter::new(default_cfg());
let t = a.evaluate(&[gpu(0, 50.0, 0.0)]);
assert!(t.is_empty());
}
#[test]
fn power_rule_disabled_when_zero() {
let mut a = Alerter::new(default_cfg());
let t = a.evaluate(&[gpu(60, 50.0, 500.0)]);
assert!(t.is_empty());
}
#[test]
fn power_rule_triggers_when_enabled() {
let mut cfg = default_cfg();
cfg.power_crit_w = 400;
let mut a = Alerter::new(cfg);
let t = a.evaluate(&[gpu(60, 50.0, 450.0)]);
assert_eq!(t.len(), 1);
assert_eq!(t[0].rule, RuleKind::Power);
assert_eq!(t[0].to, AlertLevel::Crit);
}
#[test]
fn flash_is_registered_on_transition() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(91, 0.0, 0.0)]);
assert!(a.is_flashing("GPU-0"));
}
#[test]
fn webhook_payload_contains_expected_fields() {
let mut a = Alerter::new(default_cfg());
let trans = a.evaluate(&[gpu(95, 0.0, 0.0)]);
assert_eq!(trans.len(), 1);
let payload = WebhookPayload::from(&trans[0]);
assert_eq!(payload.rule, "temperature");
assert_eq!(payload.to, "crit");
assert_eq!(payload.value, 95.0);
assert_eq!(payload.threshold, 90.0);
assert_eq!(payload.host, "n01");
assert_eq!(payload.gpu_index, Some(0));
let serialised = serde_json::to_string(&payload).unwrap();
assert!(serialised.contains("\"rule\":\"temperature\""));
assert!(serialised.contains("\"from\":\"ok\""));
assert!(serialised.contains("\"to\":\"crit\""));
}
#[test]
fn config_update_preserves_states() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(85, 0.0, 0.0)]); let mut cfg = default_cfg();
cfg.temp_warn_c = 70; a.set_config(cfg);
let t = a.evaluate(&[gpu(85, 0.0, 0.0)]);
assert!(t.is_empty());
}
#[test]
fn warn_disabled_crit_enabled_recovers_straight_to_ok() {
let mut cfg = default_cfg();
cfg.temp_warn_c = 0; cfg.temp_crit_c = 90;
cfg.hysteresis_c = 2;
let mut a = Alerter::new(cfg);
let t1 = a.evaluate(&[gpu(95, 0.0, 0.0)]);
assert_eq!(t1.len(), 1);
assert_eq!(t1[0].from, AlertLevel::Ok);
assert_eq!(t1[0].to, AlertLevel::Crit);
let t2 = a.evaluate(&[gpu(50, 0.0, 0.0)]);
assert_eq!(t2.len(), 1, "expected exactly one transition, got {t2:?}");
assert_eq!(t2[0].from, AlertLevel::Crit);
assert_eq!(
t2[0].to,
AlertLevel::Ok,
"must recover straight to Ok when warn rule disabled"
);
}
#[test]
fn evaluate_garbage_collects_states_for_vanished_devices() {
let mut cfg = default_cfg();
cfg.util_idle_warn_mins = 0; let mut a = Alerter::new(cfg);
let mut g1 = gpu(85, 0.0, 0.0);
g1.uuid = "GPU-A".to_string();
let mut g2 = gpu(85, 0.0, 0.0);
g2.uuid = "GPU-B".to_string();
a.evaluate(&[g1.clone(), g2.clone()]);
assert_eq!(a.states.len(), 2, "two rule states after first tick");
a.evaluate(&[g1]);
assert_eq!(a.states.len(), 1, "stale state must be pruned");
assert!(a.states.keys().any(|k| k.device_id == "GPU-A"));
}
#[test]
fn evaluate_empty_snapshot_clears_states() {
let mut a = Alerter::new(default_cfg());
a.evaluate(&[gpu(85, 0.0, 0.0)]);
assert_eq!(a.states.len(), 1);
a.evaluate(&[]);
assert_eq!(a.states.len(), 0);
}
}