Add alert rules with FilterBar-based triggers and ntfy delivery (tickets 017-019)

Adds AlertRule/AlertCondition/AlertSink models with CRUD persisted on
Settings (mirroring CssEntry's shape), per-line trigger evaluation hooked
into the engine's ingest path (Immediate/Threshold/NewValue/Ratio
conditions), and best-effort ntfy delivery spawned off the ingest hot path.

Design notes:
- Alert queries reuse FilterBar syntax but only support the
  regex()/sequence/timestamp subset (no metric[param] comparisons), since
  resolving those would require a full bucket rescan per alert per ingested
  line rather than cost proportional to new logs.
- Ratio's zero-denominator case skips evaluation for that line rather than
  treating the ratio as infinite.
- NewValue's seen-value set is in-memory only and does not survive a
  restart, matching the engine's other non-persisted runtime state.

engine::run now takes a SettingsHandle (moved from api.rs to settings.rs so
engine.rs can depend on it without a circular import) to read the current
alert rules on each ingest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 16:30:14 +01:00
co-authored by Claude Sonnet 5
parent 758feca780
commit dd3320812c
13 changed files with 1579 additions and 180 deletions
+28 -1
View File
@@ -11,11 +11,18 @@ use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::alerts::AlertRule;
/// Shared, process-wide settings handle. Cheap to clone; lock is only held
/// briefly since settings changes are rare (unlike the engine's hot path).
pub type SettingsHandle = Arc<Mutex<Settings>>;
/// User's preferred colour scheme for the webapp.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
@@ -63,7 +70,10 @@ pub struct Profiles {
}
/// The single, process-wide settings instance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
///
/// Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields
/// (`AlertCondition::Threshold`/`Ratio`), which aren't `Eq`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>,
@@ -72,6 +82,10 @@ pub struct Settings {
/// means unlimited — matches today's unbounded retention.
#[serde(default)]
pub max_logs: Option<usize>,
/// Alert rule definitions (ticket 017). `#[serde(default)]` so existing
/// settings.json files without this field still load.
#[serde(default)]
pub alert_rules: Vec<AlertRule>,
}
impl Default for Settings {
@@ -85,6 +99,7 @@ impl Default for Settings {
profiles_dir: "profiles".to_string(),
},
max_logs: None,
alert_rules: Vec::new(),
}
}
}
@@ -355,6 +370,18 @@ mod tests {
});
settings.profiles.names.push("default".to_string());
settings.profiles.active_profile = Some("default".to_string());
settings.alert_rules.push(crate::alerts::AlertRule {
id: "rule1".to_string(),
name: "error spike".to_string(),
enabled: true,
condition: crate::alerts::AlertCondition::Immediate {
query: r#"regex("error")"#.to_string(),
},
sink: crate::alerts::AlertSink::Ntfy {
topic: "alerts".to_string(),
server: None,
},
});
settings.save(&path).unwrap();