1use std::collections::HashMap;
23use std::time::{Duration, SystemTime};
24
25use crate::components::health::{Gate, GateStatus};
26
27pub const DEFAULT_DEBOUNCE_SECS: u64 = 5 * 60;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Alert {
36 pub gate: String,
37 pub from: GateStatus,
38 pub to: GateStatus,
39 pub value: String,
44 pub why: Option<String>,
47}
48
49impl Alert {
50 pub fn json_body(&self) -> serde_json::Value {
53 serde_json::json!({
54 "text": self.message_line(),
55 })
56 }
57
58 pub fn message_line(&self) -> String {
60 let arrow = match (self.from, self.to) {
61 (_, GateStatus::Fail) => "๐ด FAILED",
62 (_, GateStatus::Warn) => "๐ก WARN",
63 (_, GateStatus::Pass) => "๐ข RECOVERED",
64 (_, GateStatus::Unknown) => "โช UNKNOWN",
65 };
66 let mut s = format!(
67 "bee-tui: {} {} (was {:?}, now {:?}) โ {}",
68 arrow, self.gate, self.from, self.to, self.value,
69 );
70 if let Some(why) = &self.why {
71 s.push_str(" ยท ");
72 s.push_str(why);
73 }
74 s
75 }
76
77 pub fn is_worth_alerting(&self) -> bool {
81 if self.from == GateStatus::Unknown || self.to == GateStatus::Unknown {
82 return false;
83 }
84 self.from != self.to
85 }
86}
87
88#[derive(Debug, Default)]
92pub struct AlertState {
93 last_status: HashMap<String, GateStatus>,
95 last_fired: HashMap<String, SystemTime>,
98 debounce: Duration,
99}
100
101impl AlertState {
102 pub fn new(debounce_secs: u64) -> Self {
103 Self {
104 last_status: HashMap::new(),
105 last_fired: HashMap::new(),
106 debounce: Duration::from_secs(debounce_secs),
107 }
108 }
109
110 pub fn diff_and_record(&mut self, current: &[Gate]) -> Vec<Alert> {
114 self.diff_and_record_at(current, SystemTime::now())
115 }
116
117 pub fn diff_and_record_at(&mut self, current: &[Gate], now: SystemTime) -> Vec<Alert> {
121 let mut out = Vec::new();
122 for gate in current {
123 let prev = self
124 .last_status
125 .get(gate.label)
126 .copied()
127 .unwrap_or(GateStatus::Unknown);
128 self.last_status.insert(gate.label.to_string(), gate.status);
129 let alert = Alert {
130 gate: gate.label.to_string(),
131 from: prev,
132 to: gate.status,
133 value: gate.value.clone(),
134 why: gate.why.clone(),
135 };
136 if !alert.is_worth_alerting() {
137 continue;
138 }
139 if let Some(last) = self.last_fired.get(gate.label) {
141 if now.duration_since(*last).unwrap_or_default() < self.debounce {
142 continue;
143 }
144 }
145 self.last_fired.insert(gate.label.to_string(), now);
146 out.push(alert);
147 }
148 out
149 }
150}
151
152pub async fn fire(webhook_url: &str, alert: &Alert) -> Result<(), String> {
156 let client = reqwest::Client::builder()
157 .timeout(Duration::from_secs(10))
158 .user_agent(concat!("bee-tui/", env!("CARGO_PKG_VERSION")))
159 .build()
160 .map_err(|e| format!("client build: {e}"))?;
161 let resp = client
162 .post(webhook_url)
163 .json(&alert.json_body())
164 .send()
165 .await
166 .map_err(|e| format!("POST {webhook_url}: {e}"))?;
167 if !resp.status().is_success() {
168 return Err(format!("webhook returned HTTP {}", resp.status()));
169 }
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn gate(label: &'static str, status: GateStatus, value: &str) -> Gate {
178 Gate {
179 label,
180 status,
181 value: value.to_string(),
182 why: None,
183 }
184 }
185
186 #[test]
187 fn first_observation_is_unknown_baseline_and_silent() {
188 let mut s = AlertState::new(60);
189 let out = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
192 assert!(out.is_empty(), "fresh start should be silent: {out:?}");
193 }
194
195 #[test]
196 fn pass_to_fail_fires_alert() {
197 let mut s = AlertState::new(60);
198 let _ = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
199 let now = SystemTime::now();
200 let out = s.diff_and_record_at(&[gate("Health", GateStatus::Fail, "broken")], now);
201 assert_eq!(out.len(), 1);
202 assert_eq!(out[0].from, GateStatus::Pass);
203 assert_eq!(out[0].to, GateStatus::Fail);
204 assert!(out[0].message_line().contains("FAILED"));
205 }
206
207 #[test]
208 fn fail_to_pass_fires_recovery() {
209 let mut s = AlertState::new(60);
210 let _ = s.diff_and_record(&[gate("Health", GateStatus::Fail, "broken")]);
211 let out = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
212 assert_eq!(out.len(), 1);
213 assert!(out[0].message_line().contains("RECOVERED"));
214 }
215
216 #[test]
217 fn unchanged_status_is_silent() {
218 let mut s = AlertState::new(60);
219 let _ = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
220 let out = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
221 assert!(out.is_empty());
222 }
223
224 #[test]
225 fn unknown_transitions_are_ignored() {
226 let mut s = AlertState::new(60);
227 let _ = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
230 let out = s.diff_and_record(&[gate("Health", GateStatus::Unknown, "")]);
231 assert!(out.is_empty());
232 let mut s2 = AlertState::new(60);
234 let _ = s2.diff_and_record(&[gate("Health", GateStatus::Unknown, "")]);
235 let out = s2.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
236 assert!(out.is_empty());
237 }
238
239 #[test]
240 fn debounce_suppresses_repeat_within_window() {
241 let mut s = AlertState::new(60);
242 let _ = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
243 let t0 = SystemTime::now();
244 let out = s.diff_and_record_at(&[gate("Health", GateStatus::Fail, "broken")], t0);
246 assert_eq!(out.len(), 1);
247 let t1 = t0 + Duration::from_secs(30);
249 let _ = s.diff_and_record_at(&[gate("Health", GateStatus::Pass, "ok")], t1);
250 let t2 = t0 + Duration::from_secs(45);
251 let out = s.diff_and_record_at(&[gate("Health", GateStatus::Fail, "broken again")], t2);
252 assert!(
253 out.is_empty(),
254 "second fail within 60s should be debounced: {out:?}"
255 );
256 }
257
258 #[test]
259 fn debounce_releases_after_window() {
260 let mut s = AlertState::new(60);
261 let _ = s.diff_and_record(&[gate("Health", GateStatus::Pass, "ok")]);
262 let t0 = SystemTime::now();
263 let _ = s.diff_and_record_at(&[gate("Health", GateStatus::Fail, "x")], t0);
264 let _ = s.diff_and_record_at(
266 &[gate("Health", GateStatus::Pass, "ok")],
267 t0 + Duration::from_secs(61),
268 );
269 let out = s.diff_and_record_at(
270 &[gate("Health", GateStatus::Fail, "y")],
271 t0 + Duration::from_secs(122),
272 );
273 assert_eq!(out.len(), 1);
274 }
275
276 #[test]
277 fn json_body_uses_text_field() {
278 let alert = Alert {
279 gate: "Health".into(),
280 from: GateStatus::Pass,
281 to: GateStatus::Fail,
282 value: "broken".into(),
283 why: None,
284 };
285 let body = alert.json_body();
286 assert!(body["text"].is_string(), "json: {body}");
287 assert!(body["text"].as_str().unwrap().contains("FAILED"));
288 }
289
290 #[test]
291 fn message_line_includes_why_when_present() {
292 let alert = Alert {
293 gate: "StorageRadius".into(),
294 from: GateStatus::Pass,
295 to: GateStatus::Warn,
296 value: "below committed".into(),
297 why: Some("decreases ONLY on the 30-min reserve worker tick".into()),
298 };
299 let s = alert.message_line();
300 assert!(s.contains("30-min reserve worker tick"));
301 }
302}