From 18c2d72d221b931c1df0863f34584827769430b3 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 18:13:51 +0100 Subject: [PATCH] Move the ntfy alert sink to Settings, shared by all alert rules Alert rules no longer carry their own ntfy topic/server; Settings now holds one process-wide ntfy sink (with its own "send test notification"), and the Alerts panel is left to just define rule conditions. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/alerts.rs | 44 +++++------------- src/api.rs | 19 ++++---- src/dto.rs | 16 +++++-- src/engine.rs | 7 ++- src/settings.rs | 13 ++++-- webapp/src/components/Alerts.tsx | 74 ++---------------------------- webapp/src/components/NavMenu.tsx | 74 +++++++++++++++++++++++++++++- webapp/src/lib/api.ts | 18 +++++--- webapp/src/lib/useSettingsPanel.ts | 10 ++++ 9 files changed, 148 insertions(+), 127 deletions(-) diff --git a/src/alerts.rs b/src/alerts.rs index ea8e370..6c4d1de 100644 --- a/src/alerts.rs +++ b/src/alerts.rs @@ -25,7 +25,6 @@ pub struct AlertRule { pub name: String, pub enabled: bool, pub condition: AlertCondition, - pub sink: AlertSink, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -54,17 +53,6 @@ pub enum AlertCondition { }, } -/// Modeled as an enum (rather than bare fields on `AlertRule`) so more sink kinds can be added later without reshaping it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum AlertSink { - Ntfy { - topic: String, - /// Defaults to `https://ntfy.sh` when absent. - server: Option, - }, -} - #[derive(Debug)] pub enum AlertError { NotFound, @@ -107,9 +95,8 @@ impl Settings { name: String, enabled: bool, condition: AlertCondition, - sink: AlertSink, ) -> Result { - let rule = AlertRule { id: generate_alert_rule_id(&name), name, enabled, condition, sink }; + let rule = AlertRule { id: generate_alert_rule_id(&name), name, enabled, condition }; self.alert_rules.push(rule.clone()); self.save(settings_path)?; Ok(rule) @@ -122,7 +109,6 @@ impl Settings { name: String, enabled: bool, condition: AlertCondition, - sink: AlertSink, ) -> Result { let rule = self .alert_rules @@ -132,7 +118,6 @@ impl Settings { rule.name = name; rule.enabled = enabled; rule.condition = condition; - rule.sink = sink; let updated = rule.clone(); self.save(settings_path)?; Ok(updated) @@ -322,15 +307,16 @@ fn evaluate_condition( } } -/// Errors are only logged (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion. -pub async fn deliver_for_rule(rule: AlertRule, message: String) { - match rule.sink { - AlertSink::Ntfy { topic, server } => { - let server = server.unwrap_or_else(|| "https://ntfy.sh".to_string()); - if let Err(err) = deliver_ntfy(&server, &topic, &message).await { - eprintln!("warning: failed to deliver alert '{}' via ntfy: {err}", rule.name); - } - } +/// Delivers to the single, process-wide ntfy sink configured in Settings. Errors are only logged +/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion. +/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is. +pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option, server: Option) { + let Some(topic) = topic else { + return; + }; + let server = server.unwrap_or_else(|| "https://ntfy.sh".to_string()); + if let Err(err) = deliver_ntfy(&server, &topic, &message).await { + eprintln!("warning: failed to deliver alert '{rule_name}' via ntfy: {err}"); } } @@ -364,7 +350,6 @@ mod tests { name: "test rule".to_string(), enabled: true, condition: AlertCondition::Immediate { query: query.to_string() }, - sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None }, } } @@ -380,7 +365,6 @@ mod tests { "spike".to_string(), true, AlertCondition::Immediate { query: r#"regex("error")"#.to_string() }, - AlertSink::Ntfy { topic: "alerts".to_string(), server: None }, ) .unwrap(); assert_eq!(settings.alert_rules.len(), 1); @@ -395,7 +379,6 @@ mod tests { "renamed".to_string(), false, AlertCondition::Immediate { query: r#"regex("warn")"#.to_string() }, - AlertSink::Ntfy { topic: "other".to_string(), server: Some("https://example.com".to_string()) }, ) .unwrap(); assert_eq!(updated.id, created.id); @@ -420,7 +403,6 @@ mod tests { "x".to_string(), true, AlertCondition::Immediate { query: r#"regex("x")"#.to_string() }, - AlertSink::Ntfy { topic: "x".to_string(), server: None }, ); assert!(matches!(result, Err(AlertError::NotFound))); @@ -463,7 +445,6 @@ mod tests { threshold: 3.0, window_ms: 10_000, }, - sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None }, }; let mut states = HashMap::new(); for (seq, ms) in [(0u64, 0i64), (1, 1000)] { @@ -489,7 +470,6 @@ mod tests { threshold: 2.0, window_ms: 1_000, }, - sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None }, }; let mut states = HashMap::new(); assert!(evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "error").is_empty()); @@ -507,7 +487,6 @@ mod tests { field_regex: r"agent=(?P\S+)".to_string(), capture_group: "agent".to_string(), }, - sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None }, }; let mut states = HashMap::new(); let fired = evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "agent=curl"); @@ -531,7 +510,6 @@ mod tests { ratio: 0.5, window_ms: 10_000, }, - sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None }, }; let mut states = HashMap::new(); // Only an "error" so far; no "request" yet, so denominator is 0 -> skip. diff --git a/src/api.rs b/src/api.rs index c3e4c1f..7601118 100644 --- a/src/api.rs +++ b/src/api.rs @@ -423,7 +423,7 @@ async fn create_alert_rule( Json(body): Json, ) -> 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) { + match settings.create_alert_rule(&state.settings_path, body.name, body.enabled, body.condition) { Ok(rule) => (StatusCode::CREATED, Json(rule)).into_response(), Err(err) => alert_error_response(err), } @@ -435,7 +435,7 @@ async fn update_alert_rule( Json(body): Json, ) -> 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) { + match settings.update_alert_rule(&state.settings_path, &id, body.name, body.enabled, body.condition) { Ok(rule) => Json(rule).into_response(), Err(err) => alert_error_response(err), } @@ -481,6 +481,12 @@ async fn update_settings( if let Some(custom_theme_id) = body.custom_theme_id { settings.custom_theme_id = custom_theme_id; } + if let Some(ntfy_topic) = body.ntfy_topic { + settings.ntfy_topic = ntfy_topic; + } + if let Some(ntfy_server) = body.ntfy_server { + settings.ntfy_server = ntfy_server; + } match settings.save(&state.settings_path) { Ok(()) => (), Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), @@ -756,8 +762,7 @@ mod tests { json!({ "name": "error spike", "enabled": true, - "condition": { "kind": "immediate", "query": "regex(\"error\")" }, - "sink": { "kind": "ntfy", "topic": "alerts", "server": null } + "condition": { "kind": "immediate", "query": "regex(\"error\")" } }), ) .await; @@ -776,8 +781,7 @@ mod tests { json!({ "name": "renamed", "enabled": false, - "condition": { "kind": "immediate", "query": "regex(\"warn\")" }, - "sink": { "kind": "ntfy", "topic": "alerts", "server": null } + "condition": { "kind": "immediate", "query": "regex(\"warn\")" } }), ) .await; @@ -807,8 +811,7 @@ mod tests { json!({ "name": "x", "enabled": true, - "condition": { "kind": "immediate", "query": "regex(\"x\")" }, - "sink": { "kind": "ntfy", "topic": "x", "server": null } + "condition": { "kind": "immediate", "query": "regex(\"x\")" } }) .to_string(), )) diff --git a/src/dto.rs b/src/dto.rs index 915d227..7a89a2b 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -1,4 +1,4 @@ -use crate::alerts::{AlertCondition, AlertSink}; +use crate::alerts::AlertCondition; use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher}; use crate::metric_primitive::MetricPrimitive; use crate::profile::{ChartType, Dashboard, Panel, Profile}; @@ -171,6 +171,10 @@ pub struct SettingsDto { pub max_logs: Option, /// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette. pub custom_theme_id: Option, + /// Ntfy topic every alert rule delivers to; `None` until configured. + pub ntfy_topic: Option, + /// Ntfy server override; `None` means `https://ntfy.sh`. + pub ntfy_server: Option, } impl From<&Settings> for SettingsDto { @@ -180,11 +184,13 @@ impl From<&Settings> for SettingsDto { active_profile: settings.profiles.active_profile.clone(), max_logs: settings.max_logs, custom_theme_id: settings.custom_theme_id.clone(), + ntfy_topic: settings.ntfy_topic.clone(), + ntfy_server: settings.ntfy_server.clone(), } } } -/// `max_logs`/`custom_theme_id` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`. +/// `max_logs`/`custom_theme_id`/`ntfy_topic`/`ntfy_server` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`. #[derive(Debug, Deserialize)] pub struct UpdateSettingsRequest { pub color_scheme: Option, @@ -192,6 +198,10 @@ pub struct UpdateSettingsRequest { pub max_logs: Option>, #[serde(default, deserialize_with = "deserialize_double_option")] pub custom_theme_id: Option>, + #[serde(default, deserialize_with = "deserialize_double_option")] + pub ntfy_topic: Option>, + #[serde(default, deserialize_with = "deserialize_double_option")] + pub ntfy_server: Option>, } /// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option>` can't do alone. @@ -208,7 +218,6 @@ pub struct CreateAlertRuleRequest { pub name: String, pub enabled: bool, pub condition: AlertCondition, - pub sink: AlertSink, } #[derive(Debug, Deserialize)] @@ -216,7 +225,6 @@ pub struct UpdateAlertRuleRequest { pub name: String, pub enabled: bool, pub condition: AlertCondition, - pub sink: AlertSink, } /// `server` defaults to `https://ntfy.sh` when absent. diff --git a/src/engine.rs b/src/engine.rs index cfd61ff..97b2f2e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -130,10 +130,13 @@ pub async fn run(mut rx: mpsc::Receiver, settings: SettingsHandle evict_excess_logs(&mut index, &mut log_count, max_logs); // Evaluate alert rules against just this line, then hand any fired rule off to delivery via tokio::spawn so it stays off the hot path. - let rules = settings.lock().unwrap().alert_rules.clone(); + let (rules, ntfy_topic, ntfy_server) = { + let settings = settings.lock().unwrap(); + (settings.alert_rules.clone(), settings.ntfy_topic.clone(), settings.ntfy_server.clone()) + }; let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text); for (rule, message) in fired { - tokio::spawn(alerts::deliver_for_rule(rule, message)); + tokio::spawn(alerts::deliver_for_rule(rule.name, message, ntfy_topic.clone(), ntfy_server.clone())); } } EngineMessage::SetMaxLogs(new_max) => { diff --git a/src/settings.rs b/src/settings.rs index c157f55..b607019 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -78,6 +78,12 @@ pub struct Settings { /// Alert rule definitions. #[serde(default)] pub alert_rules: Vec, + /// Ntfy topic every alert rule delivers to; `None` until configured in Settings. + #[serde(default)] + pub ntfy_topic: Option, + /// Ntfy server override; `None` means `https://ntfy.sh`. + #[serde(default)] + pub ntfy_server: Option, } impl Default for Settings { @@ -93,6 +99,8 @@ impl Default for Settings { }, max_logs: None, alert_rules: Vec::new(), + ntfy_topic: None, + ntfy_server: None, } } } @@ -333,11 +341,8 @@ mod tests { condition: crate::alerts::AlertCondition::Immediate { query: r#"regex("error")"#.to_string(), }, - sink: crate::alerts::AlertSink::Ntfy { - topic: "alerts".to_string(), - server: None, - }, }); + settings.ntfy_topic = Some("alerts".to_string()); settings.save(&path).unwrap(); diff --git a/webapp/src/components/Alerts.tsx b/webapp/src/components/Alerts.tsx index 86ad8a6..43fefc8 100644 --- a/webapp/src/components/Alerts.tsx +++ b/webapp/src/components/Alerts.tsx @@ -4,7 +4,6 @@ import { createAlertRule, deleteAlertRule, fetchAlertRules, - sendTestAlertNotification, updateAlertRule, type AlertCondition, type AlertRule, @@ -68,8 +67,6 @@ interface FormState { queryA: string queryB: string ratio: string - topic: string - server: string } const EMPTY_FORM: FormState = { @@ -85,8 +82,6 @@ const EMPTY_FORM: FormState = { queryA: '', queryB: '', ratio: '', - topic: '', - server: '', } function formFromRule(rule: AlertRule): FormState { @@ -113,8 +108,6 @@ function formFromRule(rule: AlertRule): FormState { base.windowInput = String(rule.condition.window_ms) break } - base.topic = rule.sink.topic - base.server = rule.sink.server ?? '' return base } @@ -158,9 +151,6 @@ export function Alerts() { const [form, setForm] = useState(EMPTY_FORM) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) - const [testError, setTestError] = useState(null) - const [testSuccess, setTestSuccess] = useState(false) - const [testing, setTesting] = useState(false) async function loadRules() { try { @@ -182,16 +172,12 @@ export function Alerts() { setEditingId(null) setForm(EMPTY_FORM) setSubmitError(null) - setTestError(null) - setTestSuccess(false) } function startEdit(rule: AlertRule) { setEditingId(rule.id) setForm(formFromRule(rule)) setSubmitError(null) - setTestError(null) - setTestSuccess(false) } async function handleSubmit(e: React.FormEvent) { @@ -201,10 +187,6 @@ export function Alerts() { setSubmitError('Name is required.') return } - if (!form.topic.trim()) { - setSubmitError('Ntfy topic is required.') - return - } const { error, condition } = buildCondition(form) if (error || !condition) { setSubmitError(error ?? 'Invalid condition.') @@ -212,7 +194,7 @@ export function Alerts() { } setSubmitting(true) try { - const rule = { name: form.name, enabled: form.enabled, condition, sink: { kind: 'ntfy' as const, topic: form.topic, server: form.server.trim() || null } } + const rule = { name: form.name, enabled: form.enabled, condition } if (editingId) { await updateAlertRule(editingId, rule) } else { @@ -239,31 +221,13 @@ export function Alerts() { async function handleToggleEnabled(rule: AlertRule) { try { - await updateAlertRule(rule.id, { name: rule.name, enabled: !rule.enabled, condition: rule.condition, sink: rule.sink }) + await updateAlertRule(rule.id, { name: rule.name, enabled: !rule.enabled, condition: rule.condition }) await loadRules() } catch { // best-effort; list refresh will show the previous state on failure } } - async function handleTestNotification() { - setTestError(null) - setTestSuccess(false) - if (!form.topic.trim()) { - setTestError('Enter a topic to send a test notification.') - return - } - setTesting(true) - try { - await sendTestAlertNotification(form.topic, form.server.trim() || null) - setTestSuccess(true) - } catch (err) { - setTestError(err instanceof Error ? err.message : 'Failed to send test notification') - } finally { - setTesting(false) - } - } - return (
@@ -272,6 +236,9 @@ export function Alerts() {
+

+ Every rule delivers to the ntfy sink configured in Settings. +

@@ -424,35 +391,12 @@ export function Alerts() {
)} -
-
- Ntfy sink - -
-
- patch({ topic: e.target.value })} - placeholder="topic" - /> - patch({ server: e.target.value })} - placeholder="https://ntfy.sh" - /> -
-
- {submitError &&
{submitError}
} - {testError &&
{testError}
} - {testSuccess &&
Test notification sent.
}
- {editingId && (
+
+
+

Ntfy sink

+ +
+
+ setNtfyTopicInput(e.target.value)} + onBlur={handleNtfyTopicBlur} + className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none" + /> + setNtfyServerInput(e.target.value)} + onBlur={handleNtfyServerBlur} + className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none" + /> + {ntfyTestError &&
{ntfyTestError}
} + {ntfyTestSuccess &&
Test notification sent.
} + +

Every alert rule delivers here — see the Alerts panel to define rules.

+
+
+

Import CSS

diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index d71cd8b..63ead58 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -98,6 +98,10 @@ export interface Settings { max_logs: number | null /** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */ custom_theme_id: string | null + /** Ntfy topic every alert rule delivers to; `null` until configured. */ + ntfy_topic: string | null + /** Ntfy server override; `null` means `https://ntfy.sh`. */ + ntfy_server: string | null } export type CssEntryKind = 'imported' | 'linked' @@ -148,15 +152,11 @@ export type AlertCondition = | { kind: 'newvalue'; field_regex: string; capture_group: string } | { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number } -// Only one sink kind exists today, but this stays a tagged union to match the backend. -export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null } - export interface AlertRule { id: string name: string enabled: boolean condition: AlertCondition - sink: AlertSink } async function getJson(path: string): Promise { @@ -297,10 +297,16 @@ export function updateSettings(update: { maxLogs?: number | null /** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */ customThemeId?: string | null + /** Omit to leave unchanged; `null` clears the configured topic. */ + ntfyTopic?: string | null + /** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */ + ntfyServer?: string | null }): Promise { const body: Record = { color_scheme: update.colorScheme } if ('maxLogs' in update) body.max_logs = update.maxLogs if ('customThemeId' in update) body.custom_theme_id = update.customThemeId + if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic + if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer return sendJson('/api/settings', 'PATCH', body) } @@ -384,13 +390,13 @@ export function fetchAlertRules(): Promise { return getJson('/api/settings/alerts') } -export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }): Promise { +export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise { return postJson('/api/settings/alerts', rule) } export function updateAlertRule( id: string, - rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }, + rule: { name: string; enabled: boolean; condition: AlertCondition }, ): Promise { return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule) } diff --git a/webapp/src/lib/useSettingsPanel.ts b/webapp/src/lib/useSettingsPanel.ts index 2ba5ca5..1f40976 100644 --- a/webapp/src/lib/useSettingsPanel.ts +++ b/webapp/src/lib/useSettingsPanel.ts @@ -153,6 +153,15 @@ export function useSettingsPanel() { [guarded], ) + const setNtfySink = useCallback( + (ntfyTopic: string | null, ntfyServer: string | null) => + guarded(async () => { + const next = await updateSettings({ ntfyTopic, ntfyServer }) + setSettings(next) + })(), + [guarded], + ) + const importCssEntry = useCallback( (filename: string, content: string) => guarded(async () => { @@ -230,6 +239,7 @@ export function useSettingsPanel() { setColorScheme, setCustomTheme, setMaxLogs, + setNtfySink, importCssEntry, importThemeEntry, linkThemeEntry,