mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
004a63e82d
- Upgrade openssl to 0.10.78 (CVE-2026-41676), jsonwebtoken to 9.4 - Suppress unmaintained-only/no-CVE advisories in .cargo/audit.toml with per-entry rationale - Fix all `cargo clippy --all-targets -- -D warnings` errors across 35 crates: derivable_impls, needless_range_loop, map_or→is_some_and/ is_none_or, await_holding_lock (drop MutexGuard before .await), ptr_arg (&mut Vec→&mut [T]), useless_conversion, approximate_constant (2.718→E, 3.14→PI), field_reassign_with_default, manual_inspect, useless_vec, lines_filter_map_ok, print_literal, dead_code - Apply `cargo fmt --all` - Pre-existing test failure in wifi-densepose-signal (test_estimate_occupancy_noise_only) is not introduced by this PR
393 lines
11 KiB
Rust
393 lines
11 KiB
Rust
//! Alert dispatching and delivery.
|
|
|
|
use super::AlertGenerator;
|
|
use crate::domain::{Alert, AlertId, Priority, Survivor};
|
|
use crate::MatError;
|
|
use std::collections::HashMap;
|
|
|
|
/// Configuration for alert dispatch
|
|
#[derive(Debug, Clone)]
|
|
pub struct AlertConfig {
|
|
/// Enable audio alerts
|
|
pub audio_enabled: bool,
|
|
/// Enable visual alerts
|
|
pub visual_enabled: bool,
|
|
/// Escalation timeout in seconds
|
|
pub escalation_timeout_secs: u64,
|
|
/// Maximum pending alerts before forced escalation
|
|
pub max_pending_alerts: usize,
|
|
/// Auto-acknowledge after seconds (0 = disabled)
|
|
pub auto_ack_secs: u64,
|
|
}
|
|
|
|
impl Default for AlertConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
audio_enabled: true,
|
|
visual_enabled: true,
|
|
escalation_timeout_secs: 300, // 5 minutes
|
|
max_pending_alerts: 50,
|
|
auto_ack_secs: 0, // Disabled
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dispatcher for sending alerts to rescue teams
|
|
pub struct AlertDispatcher {
|
|
config: AlertConfig,
|
|
generator: AlertGenerator,
|
|
pending_alerts: parking_lot::RwLock<HashMap<AlertId, Alert>>,
|
|
handlers: Vec<Box<dyn AlertHandler>>,
|
|
}
|
|
|
|
impl AlertDispatcher {
|
|
/// Create a new alert dispatcher
|
|
pub fn new(config: AlertConfig) -> Self {
|
|
Self {
|
|
config,
|
|
generator: AlertGenerator::new(),
|
|
pending_alerts: parking_lot::RwLock::new(HashMap::new()),
|
|
handlers: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add an alert handler
|
|
pub fn add_handler(&mut self, handler: Box<dyn AlertHandler>) {
|
|
self.handlers.push(handler);
|
|
}
|
|
|
|
/// Generate an alert for a survivor
|
|
pub fn generate_alert(&self, survivor: &Survivor) -> Result<Alert, MatError> {
|
|
self.generator.generate(survivor)
|
|
}
|
|
|
|
/// Dispatch an alert
|
|
pub async fn dispatch(&self, alert: Alert) -> Result<(), MatError> {
|
|
let alert_id = alert.id().clone();
|
|
let priority = alert.priority();
|
|
|
|
// Store in pending alerts
|
|
self.pending_alerts
|
|
.write()
|
|
.insert(alert_id.clone(), alert.clone());
|
|
|
|
// Log the alert
|
|
tracing::info!(
|
|
alert_id = %alert_id,
|
|
priority = ?priority,
|
|
title = %alert.payload().title,
|
|
"Dispatching alert"
|
|
);
|
|
|
|
// Send to all handlers
|
|
for handler in &self.handlers {
|
|
if let Err(e) = handler.handle(&alert).await {
|
|
tracing::warn!(
|
|
alert_id = %alert_id,
|
|
handler = %handler.name(),
|
|
error = %e,
|
|
"Handler failed to process alert"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if we're at capacity
|
|
let pending_count = self.pending_alerts.read().len();
|
|
if pending_count >= self.config.max_pending_alerts {
|
|
tracing::warn!(
|
|
pending_count,
|
|
max = self.config.max_pending_alerts,
|
|
"Alert capacity reached - escalating oldest alerts"
|
|
);
|
|
self.escalate_oldest().await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Acknowledge an alert
|
|
pub fn acknowledge(&self, alert_id: &AlertId, by: &str) -> Result<(), MatError> {
|
|
let mut alerts = self.pending_alerts.write();
|
|
|
|
if let Some(alert) = alerts.get_mut(alert_id) {
|
|
alert.acknowledge(by);
|
|
tracing::info!(
|
|
alert_id = %alert_id,
|
|
acknowledged_by = by,
|
|
"Alert acknowledged"
|
|
);
|
|
Ok(())
|
|
} else {
|
|
Err(MatError::Alerting(format!("Alert {} not found", alert_id)))
|
|
}
|
|
}
|
|
|
|
/// Resolve an alert
|
|
pub fn resolve(
|
|
&self,
|
|
alert_id: &AlertId,
|
|
resolution: crate::domain::AlertResolution,
|
|
) -> Result<(), MatError> {
|
|
let mut alerts = self.pending_alerts.write();
|
|
|
|
if let Some(alert) = alerts.remove(alert_id) {
|
|
let mut resolved_alert = alert;
|
|
resolved_alert.resolve(resolution);
|
|
tracing::info!(
|
|
alert_id = %alert_id,
|
|
"Alert resolved"
|
|
);
|
|
Ok(())
|
|
} else {
|
|
Err(MatError::Alerting(format!("Alert {} not found", alert_id)))
|
|
}
|
|
}
|
|
|
|
/// Get all pending alerts
|
|
pub fn pending(&self) -> Vec<Alert> {
|
|
self.pending_alerts.read().values().cloned().collect()
|
|
}
|
|
|
|
/// Get pending alerts by priority
|
|
pub fn pending_by_priority(&self, priority: Priority) -> Vec<Alert> {
|
|
self.pending_alerts
|
|
.read()
|
|
.values()
|
|
.filter(|a| a.priority() == priority)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Get count of pending alerts
|
|
pub fn pending_count(&self) -> usize {
|
|
self.pending_alerts.read().len()
|
|
}
|
|
|
|
/// Check and escalate timed-out alerts
|
|
pub async fn check_escalations(&self) -> Result<u32, MatError> {
|
|
let timeout_secs = self.config.escalation_timeout_secs as i64;
|
|
let mut escalated = 0;
|
|
|
|
let mut to_escalate = Vec::new();
|
|
{
|
|
let alerts = self.pending_alerts.read();
|
|
for (id, alert) in alerts.iter() {
|
|
if alert.needs_escalation(timeout_secs) {
|
|
to_escalate.push(id.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
for id in to_escalate {
|
|
let mut alerts = self.pending_alerts.write();
|
|
if let Some(alert) = alerts.get_mut(&id) {
|
|
alert.escalate();
|
|
escalated += 1;
|
|
|
|
tracing::warn!(
|
|
alert_id = %id,
|
|
new_priority = ?alert.priority(),
|
|
"Alert escalated due to timeout"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(escalated)
|
|
}
|
|
|
|
/// Escalate oldest pending alerts
|
|
async fn escalate_oldest(&self) -> Result<(), MatError> {
|
|
let mut alerts: Vec<_> = self
|
|
.pending_alerts
|
|
.read()
|
|
.iter()
|
|
.map(|(id, alert)| (id.clone(), *alert.created_at()))
|
|
.collect();
|
|
|
|
// Sort by creation time (oldest first)
|
|
alerts.sort_by_key(|(_, created)| *created);
|
|
|
|
// Escalate oldest 10%
|
|
let to_escalate = (alerts.len() / 10).max(1);
|
|
|
|
let mut pending = self.pending_alerts.write();
|
|
for (id, _) in alerts.into_iter().take(to_escalate) {
|
|
if let Some(alert) = pending.get_mut(&id) {
|
|
alert.escalate();
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get configuration
|
|
pub fn config(&self) -> &AlertConfig {
|
|
&self.config
|
|
}
|
|
}
|
|
|
|
/// Handler for processing alerts
|
|
#[async_trait::async_trait]
|
|
pub trait AlertHandler: Send + Sync {
|
|
/// Handler name
|
|
fn name(&self) -> &str;
|
|
|
|
/// Handle an alert
|
|
async fn handle(&self, alert: &Alert) -> Result<(), MatError>;
|
|
}
|
|
|
|
/// Console/logging alert handler
|
|
#[allow(dead_code)]
|
|
pub struct ConsoleAlertHandler;
|
|
|
|
#[async_trait::async_trait]
|
|
impl AlertHandler for ConsoleAlertHandler {
|
|
fn name(&self) -> &str {
|
|
"console"
|
|
}
|
|
|
|
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
|
|
let priority_indicator = match alert.priority() {
|
|
Priority::Critical => "🔴",
|
|
Priority::High => "🟠",
|
|
Priority::Medium => "🟡",
|
|
Priority::Low => "🔵",
|
|
};
|
|
|
|
println!("\n{} ALERT {}", priority_indicator, "=".repeat(50));
|
|
println!("ID: {}", alert.id());
|
|
println!("Priority: {:?}", alert.priority());
|
|
println!("Title: {}", alert.payload().title);
|
|
println!("{}", "=".repeat(60));
|
|
println!("{}", alert.payload().message);
|
|
println!("{}", "=".repeat(60));
|
|
println!("Recommended Action: {}", alert.payload().recommended_action);
|
|
println!("{}\n", "=".repeat(60));
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Audio alert handler.
|
|
///
|
|
/// Requires platform audio support. On systems without audio hardware
|
|
/// (headless servers, embedded), this logs the alert pattern. On systems
|
|
/// with audio, integrate with the platform's audio API.
|
|
#[allow(dead_code)]
|
|
pub struct AudioAlertHandler {
|
|
/// Whether audio hardware is available
|
|
audio_available: bool,
|
|
}
|
|
|
|
impl AudioAlertHandler {
|
|
/// Create a new audio handler, auto-detecting audio support.
|
|
#[allow(dead_code)]
|
|
pub fn new() -> Self {
|
|
let audio_available =
|
|
std::env::var("DISPLAY").is_ok() || std::env::var("PULSE_SERVER").is_ok();
|
|
Self { audio_available }
|
|
}
|
|
|
|
/// Create with explicit audio availability flag.
|
|
#[allow(dead_code)]
|
|
pub fn with_availability(available: bool) -> Self {
|
|
Self {
|
|
audio_available: available,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AudioAlertHandler {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl AlertHandler for AudioAlertHandler {
|
|
fn name(&self) -> &str {
|
|
"audio"
|
|
}
|
|
|
|
async fn handle(&self, alert: &Alert) -> Result<(), MatError> {
|
|
let pattern = alert.priority().audio_pattern();
|
|
|
|
if self.audio_available {
|
|
// Platform audio integration point.
|
|
// Pattern encodes urgency: Critical=continuous, High=3-burst, etc.
|
|
tracing::info!(
|
|
alert_id = %alert.id(),
|
|
pattern,
|
|
"Playing audio alert pattern"
|
|
);
|
|
} else {
|
|
tracing::debug!(
|
|
alert_id = %alert.id(),
|
|
pattern,
|
|
"Audio hardware not available - alert pattern logged only"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::domain::{AlertPayload, SurvivorId, TriageStatus};
|
|
|
|
fn create_test_alert() -> Alert {
|
|
Alert::new(
|
|
SurvivorId::new(),
|
|
Priority::High,
|
|
AlertPayload::new("Test Alert", "Test message", TriageStatus::Delayed),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_alert() {
|
|
let dispatcher = AlertDispatcher::new(AlertConfig::default());
|
|
let alert = create_test_alert();
|
|
|
|
let result = dispatcher.dispatch(alert).await;
|
|
assert!(result.is_ok());
|
|
assert_eq!(dispatcher.pending_count(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_acknowledge_alert() {
|
|
let dispatcher = AlertDispatcher::new(AlertConfig::default());
|
|
let alert = create_test_alert();
|
|
let alert_id = alert.id().clone();
|
|
|
|
dispatcher.dispatch(alert).await.unwrap();
|
|
|
|
let result = dispatcher.acknowledge(&alert_id, "Team Alpha");
|
|
assert!(result.is_ok());
|
|
|
|
let pending = dispatcher.pending();
|
|
assert!(pending
|
|
.iter()
|
|
.any(|a| a.id() == &alert_id && a.acknowledged_by() == Some("Team Alpha")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resolve_alert() {
|
|
let dispatcher = AlertDispatcher::new(AlertConfig::default());
|
|
let alert = create_test_alert();
|
|
let alert_id = alert.id().clone();
|
|
|
|
dispatcher.dispatch(alert).await.unwrap();
|
|
|
|
let resolution = crate::domain::AlertResolution {
|
|
resolution_type: crate::domain::ResolutionType::Rescued,
|
|
notes: "Survivor extracted successfully".to_string(),
|
|
resolved_by: Some("Team Alpha".to_string()),
|
|
resolved_at: chrono::Utc::now(),
|
|
};
|
|
|
|
dispatcher.resolve(&alert_id, resolution).unwrap();
|
|
assert_eq!(dispatcher.pending_count(), 0);
|
|
}
|
|
}
|