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
+148 -12
View File
@@ -1,33 +1,30 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use crate::dto::{
BucketValuesParams, CreateDashboardRequest, CreateProfileRequest, CssEntryDto, DashboardDto,
ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto,
LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, SettingsDto,
TimelineParams, UpdateSettingsRequest,
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
SettingsDto, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
};
use crate::engine::{
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
EngineResponse,
};
use crate::alerts::AlertError;
use crate::profile::{self, Profile, ProfileError, ProfileStore};
use crate::settings::{CssError, Settings};
use crate::settings::{CssError, SettingsHandle};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, post, put};
use axum::Router;
use fs_embed::Dir as EmbedDir;
use tokio::sync::oneshot;
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
/// 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>>;
#[derive(Clone)]
struct ApiState {
engine: EngineHandle,
@@ -108,6 +105,11 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat
.route("/api/settings/css/link", post(link_css))
.route("/api/settings/css/{id}", delete(remove_css))
.route("/api/settings/css/{id}/content", get(get_css_content))
.route("/api/settings/alerts", get(list_alert_rules).post(create_alert_rule))
.route(
"/api/settings/alerts/{id}",
put(update_alert_rule).delete(delete_alert_rule),
)
.route("/api/settings/profiles", get(list_profiles).post(create_profile))
.route("/api/settings/profiles/{name}", delete(delete_profile))
.route("/api/settings/profiles/{name}/activate", post(activate_profile))
@@ -464,6 +466,63 @@ async fn get_css_content(
}
}
/// Maps an `AlertError` to a response status/message: `NotFound` -> 404,
/// `Io` -> 500. Mirrors `css_error_response` above.
fn alert_error_response(err: AlertError) -> axum::response::Response {
match err {
AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
AlertError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
/// `GET /api/settings/alerts` — lists all configured alert rules as
/// `Vec<AlertRule>`.
async fn list_alert_rules(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
Json(settings.alert_rules.clone()).into_response()
}
/// `POST /api/settings/alerts` — creates an alert rule. Body:
/// `{ "name": "...", "enabled": true, "condition": { "kind": "immediate" | \
/// "threshold" | "newvalue" | "ratio", ... }, "sink": { "kind": "ntfy", \
/// "topic": "...", "server": "..." | null } }`. Returns `201 Created` with
/// the created rule (including its generated `id`).
async fn create_alert_rule(
State(state): State<ApiState>,
Json(body): Json<CreateAlertRuleRequest>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
match settings.create_alert_rule(&state.settings_path, body.name, body.enabled, body.condition, body.sink) {
Ok(rule) => (StatusCode::CREATED, Json(rule)).into_response(),
Err(err) => alert_error_response(err),
}
}
/// `PUT /api/settings/alerts/:id` — replaces an alert rule's fields (id in
/// the URL is authoritative). Returns the updated rule, or `404 Not Found`
/// if no rule with `id` exists.
async fn update_alert_rule(
State(state): State<ApiState>,
AxumPath(id): AxumPath<String>,
Json(body): Json<UpdateAlertRuleRequest>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
match settings.update_alert_rule(&state.settings_path, &id, body.name, body.enabled, body.condition, body.sink) {
Ok(rule) => Json(rule).into_response(),
Err(err) => alert_error_response(err),
}
}
/// `DELETE /api/settings/alerts/:id` — removes an alert rule. Returns
/// `204 No Content`, or `404 Not Found` if no rule with `id` exists.
async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath<String>) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
match settings.remove_alert_rule(&state.settings_path, &id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => alert_error_response(err),
}
}
/// `GET /api/settings` — returns the subset of `Settings` the NavMenu UI
/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries
/// have their own dedicated list endpoint (`GET /api/settings/css`).
@@ -744,11 +803,13 @@ async fn delete_dashboard(
mod tests {
use super::*;
use crate::engine::{self, IngestedLine};
use crate::settings::Settings;
use crate::timestamp::Timestamp;
use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use std::sync::Mutex;
use tokio::sync::mpsc;
use tower::ServiceExt;
@@ -758,12 +819,13 @@ mod tests {
/// `TempDir` guard (kept alive for the test's duration).
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
let (engine_tx, engine_rx) = mpsc::channel(128);
tokio::spawn(engine::run(engine_rx));
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
tokio::spawn(engine::run(engine_rx, settings.clone()));
let app = router(engine_tx.clone(), settings.clone(), settings_path);
(app, engine_tx, settings, dir)
}
@@ -799,6 +861,80 @@ mod tests {
(status, value)
}
#[tokio::test]
async fn alert_rule_crud_round_trips_through_http() {
let (app, _engine, _settings, _dir) = test_app();
let (status, body) = send_json(
&app,
"POST",
"/api/settings/alerts",
json!({
"name": "error spike",
"enabled": true,
"condition": { "kind": "immediate", "query": "regex(\"error\")" },
"sink": { "kind": "ntfy", "topic": "alerts", "server": null }
}),
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(body["name"], "error spike");
let id = body["id"].as_str().unwrap().to_string();
let (status, body) = get_json(&app, "/api/settings/alerts").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.as_array().unwrap().len(), 1);
let (status, body) = send_json(
&app,
"PUT",
&format!("/api/settings/alerts/{id}"),
json!({
"name": "renamed",
"enabled": false,
"condition": { "kind": "immediate", "query": "regex(\"warn\")" },
"sink": { "kind": "ntfy", "topic": "alerts", "server": null }
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["name"], "renamed");
assert_eq!(body["enabled"], false);
let status = delete_request(&app, &format!("/api/settings/alerts/{id}")).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (_, body) = get_json(&app, "/api/settings/alerts").await;
assert!(body.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn alert_rule_routes_404_for_unknown_id() {
let (app, _engine, _settings, _dir) = test_app();
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
assert_eq!(status, StatusCode::NOT_FOUND);
// `update_alert_rule`'s `AlertError::NotFound` maps to a plain-text
// 404 body (mirrors `css_error_response`), not JSON, so this checks
// the status directly rather than via `send_json`'s JSON parsing.
let request = Request::builder()
.method("PUT")
.uri("/api/settings/alerts/does-not-exist")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "x",
"enabled": true,
"condition": { "kind": "immediate", "query": "regex(\"x\")" },
"sink": { "kind": "ntfy", "topic": "x", "server": null }
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn declares_param_and_returns_201() {
let (app, _engine, _settings, _dir) = test_app();