use std::time::Duration;
use reqwest::{Client, redirect::Policy};
use tokio::sync::mpsc;
use crate::ui::alerts::WebhookPayload;
pub const WEBHOOK_QUEUE_CAPACITY: usize = 64;
const WEBHOOK_TIMEOUT: Duration = Duration::from_secs(2);
pub fn spawn_webhook_worker(url: String) -> mpsc::Sender<WebhookPayload> {
let (tx, mut rx) = mpsc::channel::<WebhookPayload>(WEBHOOK_QUEUE_CAPACITY);
tokio::spawn(async move {
let client = match Client::builder()
.timeout(WEBHOOK_TIMEOUT)
.redirect(Policy::none())
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!("alert-webhook: failed to build HTTP client: {e}");
return;
}
};
let safe_url = redact_url_userinfo(&url);
while let Some(payload) = rx.recv().await {
if url.is_empty() {
continue; }
match client.post(&url).json(&payload).send().await {
Ok(resp) => {
let status = resp.status();
if status.is_redirection() {
tracing::warn!(
"alert-webhook: {} returned redirect {} (not followed)",
safe_url,
status
);
} else if !status.is_success() {
tracing::warn!("alert-webhook: {} responded {}", safe_url, status);
}
}
Err(e) => tracing::warn!("alert-webhook: POST to {safe_url} failed: {e}"),
}
}
});
tx
}
fn redact_url_userinfo(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let after_scheme = &url[scheme_end + 3..];
let authority_end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
let authority = &after_scheme[..authority_end];
if let Some(at_pos) = authority.find('@') {
let redacted_authority = format!("***@{host}", host = &authority[at_pos + 1..]);
let mut out = String::with_capacity(url.len());
out.push_str(&url[..scheme_end + 3]);
out.push_str(&redacted_authority);
out.push_str(&after_scheme[authority_end..]);
out
} else {
url.to_string()
}
}
pub fn enqueue(tx: &mpsc::Sender<WebhookPayload>, payload: WebhookPayload) -> bool {
match tx.try_send(payload) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!("alert-webhook: queue full, dropping payload");
false
}
Err(mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!("alert-webhook: worker channel closed, dropping payload");
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::alerts::WebhookPayload;
#[test]
fn payload_json_contains_expected_fields() {
let p = WebhookPayload {
timestamp: "2026-04-20T00:00:00+00:00".to_string(),
host: "n01".to_string(),
gpu_index: Some(3),
rule: "temperature".to_string(),
from: "warn".to_string(),
to: "crit".to_string(),
value: 95.5,
threshold: 90.0,
};
let j = serde_json::to_string(&p).unwrap();
assert!(j.contains("\"timestamp\":"));
assert!(j.contains("\"host\":\"n01\""));
assert!(j.contains("\"gpu_index\":3"));
assert!(j.contains("\"rule\":\"temperature\""));
assert!(j.contains("\"value\":95.5"));
assert!(j.contains("\"threshold\":90"));
}
#[tokio::test]
async fn enqueue_returns_true_on_empty_queue() {
let tx = spawn_webhook_worker(String::new());
let p = WebhookPayload {
timestamp: "2026-04-20T00:00:00+00:00".to_string(),
host: "n01".to_string(),
gpu_index: None,
rule: "temperature".to_string(),
from: "ok".to_string(),
to: "warn".to_string(),
value: 85.0,
threshold: 80.0,
};
assert!(enqueue(&tx, p));
}
#[test]
fn redact_url_userinfo_strips_basic_auth() {
assert_eq!(
redact_url_userinfo("https://user:pass@hook.example.com/alerts"),
"https://***@hook.example.com/alerts"
);
}
#[test]
fn redact_url_userinfo_strips_user_only() {
assert_eq!(
redact_url_userinfo("https://token@hook.example.com/"),
"https://***@hook.example.com/"
);
}
#[test]
fn redact_url_userinfo_preserves_plain_url() {
assert_eq!(
redact_url_userinfo("https://hook.example.com/alerts"),
"https://hook.example.com/alerts"
);
}
#[test]
fn redact_url_userinfo_ignores_at_in_path() {
assert_eq!(
redact_url_userinfo("https://hook.example.com/path/with@symbol"),
"https://hook.example.com/path/with@symbol"
);
}
#[test]
fn redact_url_userinfo_ignores_at_in_query() {
assert_eq!(
redact_url_userinfo("https://hook.example.com/alerts?email=a@b"),
"https://hook.example.com/alerts?email=a@b"
);
}
#[test]
fn redact_url_userinfo_handles_missing_scheme() {
assert_eq!(redact_url_userinfo("hook.example.com"), "hook.example.com");
}
}