use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use tracing::{debug, warn};
use crate::model::{ContainerMetrics, ContainerState, HealthState};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Status {
Up,
Unhealthy,
Down,
}
fn status_of(c: &ContainerMetrics) -> Status {
match c.state {
ContainerState::Running => match c.health {
HealthState::Unhealthy => Status::Unhealthy,
_ => Status::Up,
},
_ => Status::Down,
}
}
struct Notification {
kind: &'static str,
body: String,
}
fn notification_for(c: &ContainerMetrics, to: Status, host: &str) -> Notification {
let who = match &c.stack {
Some(stack) => format!("Container '{}' (stack {stack}) on host '{host}'", c.name),
None => format!("Container '{}' on host '{host}'", c.name),
};
let (kind, body) = match to {
Status::Down => ("failure", format!("{who} is down — {}", c.status)),
Status::Unhealthy => ("warning", format!("{who} is unhealthy — {}", c.status)),
Status::Up => ("success", format!("{who} recovered — {}", c.status)),
};
Notification { kind, body }
}
struct Tracked {
notified: Status,
pending: Option<(Status, u64)>,
}
struct Tracker {
host: String,
delay_ms: u64,
states: HashMap<String, Tracked>,
}
impl Tracker {
fn evaluate(&mut self, ts_ms: u64, containers: &[ContainerMetrics]) -> Vec<Notification> {
let mut out = Vec::new();
let mut seen: HashSet<&str> = HashSet::with_capacity(containers.len());
for c in containers {
seen.insert(c.id.as_str());
let cur = status_of(c);
match self.states.entry(c.id.clone()) {
Entry::Vacant(slot) => {
slot.insert(Tracked {
notified: cur,
pending: None,
});
}
Entry::Occupied(mut slot) => {
let t = slot.get_mut();
if cur == t.notified {
t.pending = None;
continue;
}
let since = match t.pending {
Some((p, since)) if p == cur => since,
_ => {
t.pending = Some((cur, ts_ms));
ts_ms
}
};
if ts_ms.saturating_sub(since) >= self.delay_ms {
out.push(notification_for(c, cur, &self.host));
t.notified = cur;
t.pending = None;
}
}
}
}
self.states.retain(|id, _| seen.contains(id.as_str()));
out
}
}
pub struct Notifier {
url: String,
client: reqwest::Client,
tracker: Tracker,
}
impl Notifier {
#[must_use]
pub fn new(host: String, url: String, delay: Duration) -> Self {
let roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|der| reqwest::Certificate::from_der(der.as_ref()).ok());
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.tls_certs_only(roots)
.build()
.expect("building the reqwest client for Apprise notifications");
Self {
url,
client,
tracker: Tracker {
host,
delay_ms: u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
states: HashMap::new(),
},
}
}
pub fn observe(&mut self, ts_ms: u64, containers: &[ContainerMetrics]) {
for note in self.tracker.evaluate(ts_ms, containers) {
self.dispatch(note);
}
}
fn dispatch(&self, note: Notification) {
let client = self.client.clone();
let url = self.url.clone();
tokio::spawn(async move {
let payload = serde_json::json!({
"title": "DockDoe",
"body": note.body,
"type": note.kind,
});
match client.post(&url).json(&payload).send().await {
Ok(resp) if resp.status().is_success() => {
debug!(kind = note.kind, "apprise notification sent");
}
Ok(resp) => warn!(status = %resp.status(), "apprise rejected the notification"),
Err(err) => warn!(%err, "sending apprise notification failed"),
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn container(id: &str, state: ContainerState, health: HealthState) -> ContainerMetrics {
ContainerMetrics {
id: id.to_string(),
name: id.to_string(),
image: "img".to_string(),
state,
status: "status".to_string(),
health,
stack: None,
cpu_percent: None,
mem_used: None,
mem_limit: None,
net_rx_bps: None,
net_tx_bps: None,
disk_read_bps: None,
disk_write_bps: None,
ports: Vec::new(),
}
}
fn up(id: &str) -> ContainerMetrics {
container(id, ContainerState::Running, HealthState::None)
}
fn down(id: &str) -> ContainerMetrics {
container(id, ContainerState::Exited, HealthState::None)
}
fn unhealthy(id: &str) -> ContainerMetrics {
container(id, ContainerState::Running, HealthState::Unhealthy)
}
fn tracker(delay_ms: u64) -> Tracker {
Tracker {
host: "h1".to_string(),
delay_ms,
states: HashMap::new(),
}
}
#[test]
fn notification_body_names_the_host() {
let mut t = tracker(0);
t.evaluate(0, &[up("a")]); let notes = t.evaluate(1_000, &[down("a")]);
assert_eq!(notes.len(), 1);
assert!(
notes[0].body.contains("on host 'h1'"),
"body missing host: {}",
notes[0].body
);
}
#[test]
fn first_sighting_is_silent() {
let mut t = tracker(0);
assert!(t.evaluate(1000, &[down("a"), up("b")]).is_empty());
}
#[test]
fn change_waits_out_the_incubation_delay() {
let mut t = tracker(30_000);
assert!(t.evaluate(0, &[up("a")]).is_empty()); assert!(t.evaluate(1_000, &[down("a")]).is_empty()); assert!(t.evaluate(20_000, &[down("a")]).is_empty());
let notes = t.evaluate(31_000, &[down("a")]); assert_eq!(notes.len(), 1);
assert_eq!(notes[0].kind, "failure");
}
#[test]
fn a_flap_within_the_delay_is_swallowed() {
let mut t = tracker(30_000);
t.evaluate(0, &[up("a")]);
t.evaluate(1_000, &[down("a")]); assert!(t.evaluate(2_000, &[up("a")]).is_empty()); assert!(t.evaluate(40_000, &[up("a")]).is_empty());
}
#[test]
fn recovery_is_reported_too() {
let mut t = tracker(0); t.evaluate(0, &[up("a")]);
let down_note = t.evaluate(1_000, &[down("a")]);
assert_eq!(down_note.len(), 1);
assert_eq!(down_note[0].kind, "failure");
let up_note = t.evaluate(2_000, &[up("a")]);
assert_eq!(up_note.len(), 1);
assert_eq!(up_note[0].kind, "success");
}
#[test]
fn unhealthy_maps_to_a_warning() {
let mut t = tracker(0);
t.evaluate(0, &[up("a")]);
let notes = t.evaluate(1_000, &[unhealthy("a")]);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].kind, "warning");
}
#[test]
fn a_shifting_target_restarts_the_timer() {
let mut t = tracker(10_000);
t.evaluate(0, &[up("a")]);
t.evaluate(1_000, &[down("a")]); assert!(t.evaluate(5_000, &[unhealthy("a")]).is_empty());
assert!(t.evaluate(12_000, &[unhealthy("a")]).is_empty());
let notes = t.evaluate(15_001, &[unhealthy("a")]); assert_eq!(notes.len(), 1);
assert_eq!(notes[0].kind, "warning");
}
#[test]
fn a_vanished_container_is_forgotten_and_rebaselines() {
let mut t = tracker(0);
t.evaluate(0, &[up("a")]); assert!(t.evaluate(1_000, &[]).is_empty()); assert!(t.states.is_empty()); assert!(t.evaluate(2_000, &[down("a")]).is_empty());
}
}