1mod scan;
2#[cfg(test)]
3mod tests;
4
5use std::collections::{
6 BTreeMap,
7 HashSet,
8};
9
10use chrono::{
11 DateTime,
12 Utc,
13};
14use serde::{
15 Deserialize,
16 Serialize,
17};
18
19pub use self::scan::refresh_notifications;
20use super::alerts::{
21 seeded_keys,
22 Alert,
23 AlertTracker,
24};
25use super::{
26 connection_subject,
27 instance_subject,
28};
29use crate::domain::{
30 AlertKind,
31 HealthLevel,
32 NotificationPreferences,
33 OnceWatch,
34 Pipeline,
35 PipelineStatus,
36 ProviderSummary,
37 RunFinish,
38 SignalKind,
39};
40use crate::event::{
41 AlertLink,
42 CoreEvent,
43};
44
45const KEYS_LIMIT: usize = 16;
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct LocalNotification {
49 pub key: String,
50 pub title: String,
51 pub body: String,
52 pub pipeline_id: Option<String>,
53 pub provider_id: Option<i64>,
54 #[serde(default)]
55 pub provider_ids: Vec<i64>,
56 pub kind: AlertKind,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub finish: Option<RunFinish>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub level: Option<HealthLevel>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub link: Option<AlertLink>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ObservedNotification {
67 pub id: u64,
68 pub at: DateTime<Utc>,
69 pub notice: LocalNotification,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct ShownNotification {
74 pub id: u64,
75 pub at: DateTime<Utc>,
76 pub tone: &'static str,
77 #[serde(flatten)]
78 pub notice: LocalNotification,
79 pub fired: Option<OnceWatch>,
80}
81
82impl ObservedNotification {
83 pub fn admit(&self, preferences: &mut NotificationPreferences) -> Option<ShownNotification> {
84 let notice = &self.notice;
85 if !preferences.allows(
86 notice.kind,
87 ¬ice.provider_ids,
88 notice.pipeline_id.as_deref(),
89 notice.finish.as_ref(),
90 ) {
91 return None;
92 }
93 let fired = notice
94 .pipeline_id
95 .as_deref()
96 .zip(notice.finish.as_ref())
97 .and_then(|(pipeline_id, finish)| {
98 preferences
99 .fire_once(pipeline_id, finish)
100 .into_iter()
101 .next()
102 });
103
104 Some(ShownNotification {
105 id: self.id,
106 at: self.at,
107 tone: notice.kind.tone(),
108 notice: notice.clone(),
109 fired,
110 })
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115struct PipelineMark {
116 status: PipelineStatus,
117 last_run: Option<DateTime<Utc>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 overdue: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 checked_at: Option<DateTime<Utc>>,
122 #[serde(default)]
123 awaiting_success: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 finished_at: Option<DateTime<Utc>>,
126}
127
128impl PipelineMark {
129 fn of(pipeline: &Pipeline) -> Self {
130 Self {
131 status: pipeline.status.clone(),
132 last_run: pipeline.last_run,
133 overdue: pipeline.overdue_run.clone(),
134 checked_at: None,
135 awaiting_success: pipeline.status == PipelineStatus::Failed,
136 finished_at: None,
137 }
138 }
139
140 fn finish_of(&self, pipeline: &Pipeline) -> Option<RunFinish> {
141 pipeline
142 .finished_since(&self.status, self.last_run)
143 .then(|| RunFinish {
144 at: Utc::now(),
145 previous: self.finished_at,
146 })
147 }
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(default)]
152struct ProviderMark {
153 name: String,
154 version: Option<i64>,
155 failures: u32,
156 paused_until: Option<DateTime<Utc>>,
157 attempted_at: Option<DateTime<Utc>>,
158 overdue_at: Option<DateTime<Utc>>,
159 feed: Option<String>,
160 feed_seed: Option<Vec<String>>,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(default)]
165struct Delivered {
166 keys: Vec<String>,
167 recovered: bool,
168 providers: Vec<i64>,
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172#[serde(default)]
173pub struct NotificationState {
174 pipelines: BTreeMap<i64, BTreeMap<String, PipelineMark>>,
175 providers: BTreeMap<i64, ProviderMark>,
176 alerts: AlertTracker,
177 delivered: BTreeMap<String, Delivered>,
178}
179
180impl NotificationState {
181 pub fn seed(&mut self, pipelines: &[Pipeline], providers: &[ProviderSummary]) {
182 *self = Self::default();
183 for provider in providers {
184 self.track_provider(provider);
185 }
186 for pipeline in pipelines {
187 self.pipelines
188 .entry(pipeline.provider_id)
189 .or_default()
190 .insert(pipeline.id.clone(), PipelineMark::of(pipeline));
191 }
192 }
193
194 pub fn observe_event(&mut self, event: &CoreEvent) -> Vec<LocalNotification> {
195 match event {
196 CoreEvent::PipelinesUpdated {
197 pipelines,
198 provider_id,
199 ..
200 } => self.observe_pipelines(*provider_id, pipelines),
201 CoreEvent::PipelinesFailed { pipelines } => self.observe_failed(pipelines),
202 CoreEvent::ProviderHealthAlert {
203 alert_id,
204 alert_key,
205 notification_key,
206 provider_ids,
207 level,
208 title,
209 body,
210 link,
211 recovered,
212 } => {
213 if *recovered {
214 self.alerts.reset(notification_key);
215 } else {
216 self.alerts.arm(notification_key);
217 }
218 self.deliver(Alert {
219 alert_id: alert_id.clone(),
220 alert_key: alert_key.clone(),
221 notification_key: notification_key.clone(),
222 provider_ids: provider_ids.clone(),
223 level: *level,
224 title: title.clone(),
225 body: body.clone(),
226 link: link.clone(),
227 recovered: *recovered,
228 })
229 .into_iter()
230 .collect()
231 }
232 CoreEvent::ProviderAdded { provider, .. }
233 | CoreEvent::ProviderUpdated { provider, .. }
234 | CoreEvent::ProviderStatusUpdated { provider } => {
235 self.track_provider(provider);
236 Vec::new()
237 }
238 CoreEvent::ProviderRemoved { provider, .. } => {
239 let remaining: HashSet<i64> = self
240 .providers
241 .keys()
242 .chain(self.pipelines.keys())
243 .copied()
244 .filter(|id| *id != provider.id)
245 .collect();
246 self.retain_providers(&remaining);
247 Vec::new()
248 }
249 _ => Vec::new(),
250 }
251 }
252
253 pub fn retain_providers(&mut self, ids: &HashSet<i64>) {
254 self.pipelines.retain(|id, _| ids.contains(id));
255 self.providers.retain(|id, _| ids.contains(id));
256
257 let mut kept: HashSet<String> = ids
258 .iter()
259 .flat_map(|id| [connection_subject(*id), instance_subject(*id)])
260 .collect();
261 kept.extend(self.providers.values().filter_map(|mark| mark.feed.clone()));
262 kept.extend(
263 self.delivered
264 .iter()
265 .filter(|(_, memo)| memo.providers.iter().any(|id| ids.contains(id)))
266 .map(|(subject, _)| subject.clone()),
267 );
268 self.alerts.retain(|subject| kept.contains(subject));
269 self.delivered.retain(|subject, _| kept.contains(subject));
270 }
271
272 fn enroll(&mut self, id: i64, name: &str, version: Option<i64>) -> bool {
273 let renewed = self
274 .providers
275 .get(&id)
276 .is_none_or(|mark| mark.version != version);
277 if renewed {
278 self.alerts.close(&instance_subject(id));
279 self.alerts.reset(&connection_subject(id));
280 }
281 let mark = self.providers.entry(id).or_default();
282 if renewed {
283 mark.version = version;
284 mark.failures = 0;
285 mark.paused_until = None;
286 }
287 if mark.name != name {
288 mark.name = name.to_owned();
289 }
290
291 renewed
292 }
293
294 fn track_provider(&mut self, provider: &ProviderSummary) {
295 if !self.enroll(provider.id, &provider.name, Some(provider.version)) {
296 return;
297 }
298 let keys = |kind: SignalKind| {
299 provider
300 .health
301 .signals
302 .iter()
303 .find(|signal| signal.kind == kind)
304 .map(seeded_keys)
305 .unwrap_or_default()
306 };
307 let down = keys(SignalKind::Connection);
308 if down.is_empty() {
309 self.alerts.reset(&connection_subject(provider.id));
310 } else {
311 self.alerts.baseline(&connection_subject(provider.id), down);
312 }
313 self.alerts
314 .baseline(&instance_subject(provider.id), keys(SignalKind::Instance));
315 if let Some(mark) = self.providers.get_mut(&provider.id) {
316 mark.feed_seed = provider
317 .health
318 .status_page
319 .is_some()
320 .then(|| keys(SignalKind::StatusPage));
321 }
322 }
323
324 fn observe_pipelines(
325 &mut self, provider_id: i64, pipelines: &[Pipeline],
326 ) -> Vec<LocalNotification> {
327 let previous = self.pipelines.remove(&provider_id).unwrap_or_default();
328 let mut next = BTreeMap::new();
329 let mut found = Vec::new();
330 for pipeline in pipelines {
331 let mut mark = PipelineMark::of(pipeline);
332 if let Some(old) = previous.get(&pipeline.id) {
333 let finish = old.finish_of(pipeline);
334 mark.finished_at = finish.map(|finish| finish.at).or(old.finished_at);
335 let awaiting_success = old.awaiting_success || old.status == PipelineStatus::Failed;
336 let recovered = awaiting_success && pipeline.status == PipelineStatus::Success;
337 if pipeline.status != PipelineStatus::Success {
338 mark.awaiting_success |= awaiting_success;
339 }
340 let needs_attention = pipeline.status.needs_attention()
341 && (old.status != pipeline.status || old.last_run != pipeline.last_run);
342 if needs_attention {
343 found.push(LocalNotification {
344 finish,
345 ..self.pipeline_notice(
346 pipeline,
347 attention_title(pipeline),
348 attention_kind(pipeline),
349 )
350 });
351 } else if recovered {
352 found.push(LocalNotification {
353 finish,
354 ..self.pipeline_notice(
355 pipeline,
356 format!("{} passed again", pipeline.name),
357 AlertKind::Recovered,
358 )
359 });
360 } else if let Some(finish) = finish {
361 found.push(LocalNotification {
362 finish: Some(finish),
363 ..self.pipeline_notice(
364 pipeline,
365 finished_title(pipeline),
366 AlertKind::Finished,
367 )
368 });
369 }
370 if pipeline.overdue_run.is_some() && old.overdue != pipeline.overdue_run {
371 found.push(self.pipeline_notice(
372 pipeline,
373 format!("{} is running longer than usual", pipeline.name),
374 AlertKind::LongRun,
375 ));
376 }
377 if mark.overdue.is_none() {
378 mark.overdue = old.overdue.clone();
379 }
380 mark.checked_at = old.checked_at;
381 }
382 next.insert(pipeline.id.clone(), mark);
383 }
384 if !next.is_empty() {
385 self.pipelines.insert(provider_id, next);
386 }
387
388 found
389 }
390
391 fn observe_failed(&mut self, pipelines: &[Pipeline]) -> Vec<LocalNotification> {
392 let mut found = Vec::new();
393 for pipeline in pipelines
394 .iter()
395 .filter(|pipeline| pipeline.status.needs_attention())
396 {
397 let marks = self.pipelines.entry(pipeline.provider_id).or_default();
398 let old = marks.get(&pipeline.id);
399 let handled = old.is_some_and(|old| {
400 old.status == pipeline.status && old.last_run == pipeline.last_run
401 });
402 let finish = old.and_then(|old| old.finish_of(pipeline));
403 let mut mark = PipelineMark::of(pipeline);
404 if mark.overdue.is_none() {
405 mark.overdue = old.and_then(|old| old.overdue.clone());
406 }
407 mark.checked_at = old.and_then(|old| old.checked_at);
408 mark.awaiting_success |= old.is_some_and(|old| old.awaiting_success);
409 mark.finished_at = finish
410 .map(|finish| finish.at)
411 .or(old.and_then(|old| old.finished_at));
412 marks.insert(pipeline.id.clone(), mark);
413 if !handled {
414 found.push(LocalNotification {
415 finish,
416 ..self.pipeline_notice(
417 pipeline,
418 attention_title(pipeline),
419 attention_kind(pipeline),
420 )
421 });
422 }
423 }
424
425 found
426 }
427
428 fn pipeline_notice(
429 &self, pipeline: &Pipeline, title: String, kind: AlertKind,
430 ) -> LocalNotification {
431 let provider = self
432 .providers
433 .get(&pipeline.provider_id)
434 .map(|mark| mark.name.as_str())
435 .filter(|name| !name.is_empty())
436 .unwrap_or(&pipeline.provider_type);
437 let message = pipeline
438 .resource_state
439 .as_ref()
440 .and_then(|state| state.message.as_deref())
441 .unwrap_or_default();
442 let body = [pipeline.repository.as_str(), provider, message]
443 .iter()
444 .filter(|part| !part.is_empty())
445 .copied()
446 .collect::<Vec<_>>()
447 .join(" ยท ");
448
449 LocalNotification {
450 key: format!("pipeline:{}", pipeline.id),
451 title,
452 body,
453 pipeline_id: Some(pipeline.id.clone()),
454 provider_id: Some(pipeline.provider_id),
455 provider_ids: vec![pipeline.provider_id],
456 kind,
457 finish: None,
458 level: None,
459 link: None,
460 }
461 }
462
463 fn deliver(&mut self, alert: Alert) -> Option<LocalNotification> {
464 let memo = self
465 .delivered
466 .entry(alert.notification_key.clone())
467 .or_default();
468 memo.providers = alert.provider_ids.clone();
469 let duplicate = if alert.recovered {
470 memo.recovered
471 } else {
472 memo.keys.contains(&alert.alert_key)
473 };
474 if duplicate {
475 return None;
476 }
477 if alert.recovered {
478 memo.keys.clear();
479 } else {
480 if memo.keys.len() == KEYS_LIMIT {
481 memo.keys.remove(0);
482 }
483 memo.keys.push(alert.alert_key);
484 }
485 memo.recovered = alert.recovered;
486
487 Some(LocalNotification {
488 key: alert.notification_key,
489 title: alert.title,
490 body: alert.body,
491 pipeline_id: None,
492 provider_id: alert.provider_ids.first().copied(),
493 provider_ids: alert.provider_ids,
494 kind: if alert.recovered {
495 AlertKind::Recovered
496 } else {
497 AlertKind::Outage
498 },
499 finish: None,
500 level: Some(alert.level),
501 link: alert.link,
502 })
503 }
504
505 fn deliver_all(&mut self, alerts: Vec<Alert>) -> Vec<LocalNotification> {
506 alerts
507 .into_iter()
508 .filter_map(|alert| self.deliver(alert))
509 .collect()
510 }
511}
512
513fn attention_title(pipeline: &Pipeline) -> String {
514 match pipeline.status {
515 PipelineStatus::Degraded => format!("{} is degraded", pipeline.name),
516 _ => format!("{} failed", pipeline.name),
517 }
518}
519
520fn finished_title(pipeline: &Pipeline) -> String {
521 match pipeline.status {
522 PipelineStatus::Cancelled => format!("{} was cancelled", pipeline.name),
523 PipelineStatus::Skipped => format!("{} was skipped", pipeline.name),
524 _ => format!("{} succeeded", pipeline.name),
525 }
526}
527
528fn attention_kind(pipeline: &Pipeline) -> AlertKind {
529 match pipeline.status {
530 PipelineStatus::Degraded => AlertKind::Degraded,
531 _ => AlertKind::Failed,
532 }
533}