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:
co-authored by
Claude Sonnet 5
parent
758feca780
commit
dd3320812c
+148
-12
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user