use std::collections::HashMap; use chrono::{DateTime, Utc}; use chrono_tz::Europe::Stockholm; use lettre::{Message, SmtpTransport, Transport}; use serde::{Deserialize, Serialize}; use tracing::{info, error, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationConfig { pub enabled: bool, pub smtp_host: String, pub smtp_port: u16, pub from_email: String, pub to_email: String, pub rate_limit_minutes: u64, } impl Default for NotificationConfig { fn default() -> Self { Self { enabled: false, smtp_host: "localhost".to_string(), smtp_port: 25, from_email: "".to_string(), to_email: "".to_string(), rate_limit_minutes: 30, // Don't spam notifications } } } #[derive(Debug, Clone, PartialEq)] pub struct StatusChange { pub component: String, pub metric: String, pub old_status: String, pub new_status: String, pub timestamp: DateTime, pub details: Option, } pub struct NotificationManager { config: NotificationConfig, last_status: HashMap, // key: "component.metric", value: status last_notification: HashMap>, // Rate limiting } impl NotificationManager { pub fn new(config: NotificationConfig) -> Self { Self { config, last_status: HashMap::new(), last_notification: HashMap::new(), } } pub fn update_status(&mut self, component: &str, metric: &str, status: &str) -> Option { self.update_status_with_details(component, metric, status, None) } pub fn update_status_with_details(&mut self, component: &str, metric: &str, status: &str, details: Option) -> Option { let key = format!("{}.{}", component, metric); let old_status = self.last_status.get(&key).cloned(); if let Some(old) = &old_status { if old != status { let change = StatusChange { component: component.to_string(), metric: metric.to_string(), old_status: old.clone(), new_status: status.to_string(), timestamp: Utc::now(), details, }; self.last_status.insert(key, status.to_string()); if self.should_notify(&change) { return Some(change); } } } else { // First time seeing this metric - store but don't notify self.last_status.insert(key, status.to_string()); } None } fn should_notify(&mut self, change: &StatusChange) -> bool { if !self.config.enabled { info!("Notifications disabled, skipping {}.{}", change.component, change.metric); return false; } // Only notify on transitions to warning/critical, or recovery to ok let should_send = match (change.old_status.as_str(), change.new_status.as_str()) { (_, "warning") | (_, "critical") => true, ("warning" | "critical", "ok") => true, _ => false, }; info!("Status change {}.{}: {} -> {} (notify: {})", change.component, change.metric, change.old_status, change.new_status, should_send); should_send } fn is_rate_limited(&mut self, change: &StatusChange) -> bool { let key = format!("{}.{}", change.component, change.metric); if let Some(last_time) = self.last_notification.get(&key) { let minutes_since = Utc::now().signed_duration_since(*last_time).num_minutes(); if minutes_since < self.config.rate_limit_minutes as i64 { info!("Rate limiting {}.{}: {} minutes since last notification (limit: {})", change.component, change.metric, minutes_since, self.config.rate_limit_minutes); return true; } } self.last_notification.insert(key.clone(), Utc::now()); info!("Not rate limited {}.{}, sending notification", change.component, change.metric); false } pub async fn send_notification(&mut self, change: StatusChange) { if !self.config.enabled { return; } if self.is_rate_limited(&change) { warn!("Rate limiting notification for {}.{}", change.component, change.metric); return; } let subject = self.format_subject(&change); let body = self.format_body(&change); if let Err(e) = self.send_email(&subject, &body).await { error!("Failed to send notification email: {}", e); } else { info!("Sent notification: {} {}.{} {} → {}", change.component, change.component, change.metric, change.old_status, change.new_status); } } fn format_subject(&self, change: &StatusChange) -> String { let urgency = match change.new_status.as_str() { "critical" => "🔴 CRITICAL", "warning" => "🟡 WARNING", "ok" => "✅ RESOLVED", _ => "â„šī¸ STATUS", }; format!("{}: {} {} on {}", urgency, change.component, change.metric, gethostname::gethostname().to_string_lossy()) } fn format_body(&self, change: &StatusChange) -> String { let mut body = format!( "Status Change Alert\n\ \n\ Host: {}\n\ Component: {}\n\ Metric: {}\n\ Status Change: {} → {}\n\ Time: {}", gethostname::gethostname().to_string_lossy(), change.component, change.metric, change.old_status, change.new_status, change.timestamp.with_timezone(&Stockholm).format("%Y-%m-%d %H:%M:%S CET/CEST") ); if let Some(details) = &change.details { body.push_str(&format!("\n\nDetails:\n{}", details)); } body.push_str(&format!( "\n\n--\n\ CM Dashboard Agent\n\ Generated at {}", Utc::now().with_timezone(&Stockholm).format("%Y-%m-%d %H:%M:%S CET/CEST") )); body } async fn send_email(&self, subject: &str, body: &str) -> Result<(), Box> { let email = Message::builder() .from(self.config.from_email.parse()?) .to(self.config.to_email.parse()?) .subject(subject) .body(body.to_string())?; let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host) .port(self.config.smtp_port) .build(); mailer.send(&email)?; Ok(()) } }