Skip to main content

pipedash_core/application/services/health_service/
notifications.rs

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            &notice.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}