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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ae4e550c1f
commit
18c2d72d22
+11
-33
@@ -25,7 +25,6 @@ pub struct AlertRule {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub condition: AlertCondition,
|
pub condition: AlertCondition,
|
||||||
pub sink: AlertSink,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[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<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AlertError {
|
pub enum AlertError {
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -107,9 +95,8 @@ impl Settings {
|
|||||||
name: String,
|
name: String,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
condition: AlertCondition,
|
condition: AlertCondition,
|
||||||
sink: AlertSink,
|
|
||||||
) -> Result<AlertRule, AlertError> {
|
) -> Result<AlertRule, AlertError> {
|
||||||
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.alert_rules.push(rule.clone());
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(rule)
|
Ok(rule)
|
||||||
@@ -122,7 +109,6 @@ impl Settings {
|
|||||||
name: String,
|
name: String,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
condition: AlertCondition,
|
condition: AlertCondition,
|
||||||
sink: AlertSink,
|
|
||||||
) -> Result<AlertRule, AlertError> {
|
) -> Result<AlertRule, AlertError> {
|
||||||
let rule = self
|
let rule = self
|
||||||
.alert_rules
|
.alert_rules
|
||||||
@@ -132,7 +118,6 @@ impl Settings {
|
|||||||
rule.name = name;
|
rule.name = name;
|
||||||
rule.enabled = enabled;
|
rule.enabled = enabled;
|
||||||
rule.condition = condition;
|
rule.condition = condition;
|
||||||
rule.sink = sink;
|
|
||||||
let updated = rule.clone();
|
let updated = rule.clone();
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(updated)
|
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.
|
/// Delivers to the single, process-wide ntfy sink configured in Settings. Errors are only logged
|
||||||
pub async fn deliver_for_rule(rule: AlertRule, message: String) {
|
/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
|
||||||
match rule.sink {
|
/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is.
|
||||||
AlertSink::Ntfy { topic, server } => {
|
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
|
||||||
let server = server.unwrap_or_else(|| "https://ntfy.sh".to_string());
|
let Some(topic) = topic else {
|
||||||
if let Err(err) = deliver_ntfy(&server, &topic, &message).await {
|
return;
|
||||||
eprintln!("warning: failed to deliver alert '{}' via ntfy: {err}", rule.name);
|
};
|
||||||
}
|
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(),
|
name: "test rule".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
condition: AlertCondition::Immediate { query: query.to_string() },
|
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(),
|
"spike".to_string(),
|
||||||
true,
|
true,
|
||||||
AlertCondition::Immediate { query: r#"regex("error")"#.to_string() },
|
AlertCondition::Immediate { query: r#"regex("error")"#.to_string() },
|
||||||
AlertSink::Ntfy { topic: "alerts".to_string(), server: None },
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(settings.alert_rules.len(), 1);
|
assert_eq!(settings.alert_rules.len(), 1);
|
||||||
@@ -395,7 +379,6 @@ mod tests {
|
|||||||
"renamed".to_string(),
|
"renamed".to_string(),
|
||||||
false,
|
false,
|
||||||
AlertCondition::Immediate { query: r#"regex("warn")"#.to_string() },
|
AlertCondition::Immediate { query: r#"regex("warn")"#.to_string() },
|
||||||
AlertSink::Ntfy { topic: "other".to_string(), server: Some("https://example.com".to_string()) },
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(updated.id, created.id);
|
assert_eq!(updated.id, created.id);
|
||||||
@@ -420,7 +403,6 @@ mod tests {
|
|||||||
"x".to_string(),
|
"x".to_string(),
|
||||||
true,
|
true,
|
||||||
AlertCondition::Immediate { query: r#"regex("x")"#.to_string() },
|
AlertCondition::Immediate { query: r#"regex("x")"#.to_string() },
|
||||||
AlertSink::Ntfy { topic: "x".to_string(), server: None },
|
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(AlertError::NotFound)));
|
assert!(matches!(result, Err(AlertError::NotFound)));
|
||||||
|
|
||||||
@@ -463,7 +445,6 @@ mod tests {
|
|||||||
threshold: 3.0,
|
threshold: 3.0,
|
||||||
window_ms: 10_000,
|
window_ms: 10_000,
|
||||||
},
|
},
|
||||||
sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None },
|
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
|
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
|
||||||
@@ -489,7 +470,6 @@ mod tests {
|
|||||||
threshold: 2.0,
|
threshold: 2.0,
|
||||||
window_ms: 1_000,
|
window_ms: 1_000,
|
||||||
},
|
},
|
||||||
sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None },
|
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "error").is_empty());
|
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<agent>\S+)".to_string(),
|
field_regex: r"agent=(?P<agent>\S+)".to_string(),
|
||||||
capture_group: "agent".to_string(),
|
capture_group: "agent".to_string(),
|
||||||
},
|
},
|
||||||
sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None },
|
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "agent=curl");
|
let fired = evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "agent=curl");
|
||||||
@@ -531,7 +510,6 @@ mod tests {
|
|||||||
ratio: 0.5,
|
ratio: 0.5,
|
||||||
window_ms: 10_000,
|
window_ms: 10_000,
|
||||||
},
|
},
|
||||||
sink: AlertSink::Ntfy { topic: "alerts".to_string(), server: None },
|
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
||||||
|
|||||||
+11
-8
@@ -423,7 +423,7 @@ async fn create_alert_rule(
|
|||||||
Json(body): Json<CreateAlertRuleRequest>,
|
Json(body): Json<CreateAlertRuleRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
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(),
|
Ok(rule) => (StatusCode::CREATED, Json(rule)).into_response(),
|
||||||
Err(err) => alert_error_response(err),
|
Err(err) => alert_error_response(err),
|
||||||
}
|
}
|
||||||
@@ -435,7 +435,7 @@ async fn update_alert_rule(
|
|||||||
Json(body): Json<UpdateAlertRuleRequest>,
|
Json(body): Json<UpdateAlertRuleRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
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(),
|
Ok(rule) => Json(rule).into_response(),
|
||||||
Err(err) => alert_error_response(err),
|
Err(err) => alert_error_response(err),
|
||||||
}
|
}
|
||||||
@@ -481,6 +481,12 @@ async fn update_settings(
|
|||||||
if let Some(custom_theme_id) = body.custom_theme_id {
|
if let Some(custom_theme_id) = body.custom_theme_id {
|
||||||
settings.custom_theme_id = 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) {
|
match settings.save(&state.settings_path) {
|
||||||
Ok(()) => (),
|
Ok(()) => (),
|
||||||
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||||
@@ -756,8 +762,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "error spike",
|
"name": "error spike",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"condition": { "kind": "immediate", "query": "regex(\"error\")" },
|
"condition": { "kind": "immediate", "query": "regex(\"error\")" }
|
||||||
"sink": { "kind": "ntfy", "topic": "alerts", "server": null }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -776,8 +781,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "renamed",
|
"name": "renamed",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"condition": { "kind": "immediate", "query": "regex(\"warn\")" },
|
"condition": { "kind": "immediate", "query": "regex(\"warn\")" }
|
||||||
"sink": { "kind": "ntfy", "topic": "alerts", "server": null }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -807,8 +811,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "x",
|
"name": "x",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"condition": { "kind": "immediate", "query": "regex(\"x\")" },
|
"condition": { "kind": "immediate", "query": "regex(\"x\")" }
|
||||||
"sink": { "kind": "ntfy", "topic": "x", "server": null }
|
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
))
|
))
|
||||||
|
|||||||
+12
-4
@@ -1,4 +1,4 @@
|
|||||||
use crate::alerts::{AlertCondition, AlertSink};
|
use crate::alerts::AlertCondition;
|
||||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||||
use crate::metric_primitive::MetricPrimitive;
|
use crate::metric_primitive::MetricPrimitive;
|
||||||
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
||||||
@@ -171,6 +171,10 @@ pub struct SettingsDto {
|
|||||||
pub max_logs: Option<usize>,
|
pub max_logs: Option<usize>,
|
||||||
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
|
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
|
||||||
pub custom_theme_id: Option<String>,
|
pub custom_theme_id: Option<String>,
|
||||||
|
/// Ntfy topic every alert rule delivers to; `None` until configured.
|
||||||
|
pub ntfy_topic: Option<String>,
|
||||||
|
/// Ntfy server override; `None` means `https://ntfy.sh`.
|
||||||
|
pub ntfy_server: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Settings> for SettingsDto {
|
impl From<&Settings> for SettingsDto {
|
||||||
@@ -180,11 +184,13 @@ impl From<&Settings> for SettingsDto {
|
|||||||
active_profile: settings.profiles.active_profile.clone(),
|
active_profile: settings.profiles.active_profile.clone(),
|
||||||
max_logs: settings.max_logs,
|
max_logs: settings.max_logs,
|
||||||
custom_theme_id: settings.custom_theme_id.clone(),
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateSettingsRequest {
|
pub struct UpdateSettingsRequest {
|
||||||
pub color_scheme: Option<ColorScheme>,
|
pub color_scheme: Option<ColorScheme>,
|
||||||
@@ -192,6 +198,10 @@ pub struct UpdateSettingsRequest {
|
|||||||
pub max_logs: Option<Option<usize>>,
|
pub max_logs: Option<Option<usize>>,
|
||||||
#[serde(default, deserialize_with = "deserialize_double_option")]
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
||||||
pub custom_theme_id: Option<Option<String>>,
|
pub custom_theme_id: Option<Option<String>>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
||||||
|
pub ntfy_topic: Option<Option<String>>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
||||||
|
pub ntfy_server: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
|
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
|
||||||
@@ -208,7 +218,6 @@ pub struct CreateAlertRuleRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub condition: AlertCondition,
|
pub condition: AlertCondition,
|
||||||
pub sink: AlertSink,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -216,7 +225,6 @@ pub struct UpdateAlertRuleRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub condition: AlertCondition,
|
pub condition: AlertCondition,
|
||||||
pub sink: AlertSink,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `server` defaults to `https://ntfy.sh` when absent.
|
/// `server` defaults to `https://ntfy.sh` when absent.
|
||||||
|
|||||||
+5
-2
@@ -130,10 +130,13 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
|
|||||||
evict_excess_logs(&mut index, &mut log_count, max_logs);
|
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.
|
// 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);
|
let fired = alerts::evaluate_alerts(&rules, &mut alert_states, sequence, timestamp, &text);
|
||||||
for (rule, message) in fired {
|
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) => {
|
EngineMessage::SetMaxLogs(new_max) => {
|
||||||
|
|||||||
+9
-4
@@ -78,6 +78,12 @@ pub struct Settings {
|
|||||||
/// Alert rule definitions.
|
/// Alert rule definitions.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub alert_rules: Vec<AlertRule>,
|
pub alert_rules: Vec<AlertRule>,
|
||||||
|
/// Ntfy topic every alert rule delivers to; `None` until configured in Settings.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ntfy_topic: Option<String>,
|
||||||
|
/// Ntfy server override; `None` means `https://ntfy.sh`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ntfy_server: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -93,6 +99,8 @@ impl Default for Settings {
|
|||||||
},
|
},
|
||||||
max_logs: None,
|
max_logs: None,
|
||||||
alert_rules: Vec::new(),
|
alert_rules: Vec::new(),
|
||||||
|
ntfy_topic: None,
|
||||||
|
ntfy_server: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,11 +341,8 @@ mod tests {
|
|||||||
condition: crate::alerts::AlertCondition::Immediate {
|
condition: crate::alerts::AlertCondition::Immediate {
|
||||||
query: r#"regex("error")"#.to_string(),
|
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();
|
settings.save(&path).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
createAlertRule,
|
createAlertRule,
|
||||||
deleteAlertRule,
|
deleteAlertRule,
|
||||||
fetchAlertRules,
|
fetchAlertRules,
|
||||||
sendTestAlertNotification,
|
|
||||||
updateAlertRule,
|
updateAlertRule,
|
||||||
type AlertCondition,
|
type AlertCondition,
|
||||||
type AlertRule,
|
type AlertRule,
|
||||||
@@ -68,8 +67,6 @@ interface FormState {
|
|||||||
queryA: string
|
queryA: string
|
||||||
queryB: string
|
queryB: string
|
||||||
ratio: string
|
ratio: string
|
||||||
topic: string
|
|
||||||
server: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: FormState = {
|
const EMPTY_FORM: FormState = {
|
||||||
@@ -85,8 +82,6 @@ const EMPTY_FORM: FormState = {
|
|||||||
queryA: '',
|
queryA: '',
|
||||||
queryB: '',
|
queryB: '',
|
||||||
ratio: '',
|
ratio: '',
|
||||||
topic: '',
|
|
||||||
server: '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromRule(rule: AlertRule): FormState {
|
function formFromRule(rule: AlertRule): FormState {
|
||||||
@@ -113,8 +108,6 @@ function formFromRule(rule: AlertRule): FormState {
|
|||||||
base.windowInput = String(rule.condition.window_ms)
|
base.windowInput = String(rule.condition.window_ms)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
base.topic = rule.sink.topic
|
|
||||||
base.server = rule.sink.server ?? ''
|
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +151,6 @@ export function Alerts() {
|
|||||||
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [testError, setTestError] = useState<string | null>(null)
|
|
||||||
const [testSuccess, setTestSuccess] = useState(false)
|
|
||||||
const [testing, setTesting] = useState(false)
|
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
try {
|
try {
|
||||||
@@ -182,16 +172,12 @@ export function Alerts() {
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setForm(EMPTY_FORM)
|
setForm(EMPTY_FORM)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setTestError(null)
|
|
||||||
setTestSuccess(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(rule: AlertRule) {
|
function startEdit(rule: AlertRule) {
|
||||||
setEditingId(rule.id)
|
setEditingId(rule.id)
|
||||||
setForm(formFromRule(rule))
|
setForm(formFromRule(rule))
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setTestError(null)
|
|
||||||
setTestSuccess(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -201,10 +187,6 @@ export function Alerts() {
|
|||||||
setSubmitError('Name is required.')
|
setSubmitError('Name is required.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!form.topic.trim()) {
|
|
||||||
setSubmitError('Ntfy topic is required.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { error, condition } = buildCondition(form)
|
const { error, condition } = buildCondition(form)
|
||||||
if (error || !condition) {
|
if (error || !condition) {
|
||||||
setSubmitError(error ?? 'Invalid condition.')
|
setSubmitError(error ?? 'Invalid condition.')
|
||||||
@@ -212,7 +194,7 @@ export function Alerts() {
|
|||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
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) {
|
if (editingId) {
|
||||||
await updateAlertRule(editingId, rule)
|
await updateAlertRule(editingId, rule)
|
||||||
} else {
|
} else {
|
||||||
@@ -239,31 +221,13 @@ export function Alerts() {
|
|||||||
|
|
||||||
async function handleToggleEnabled(rule: AlertRule) {
|
async function handleToggleEnabled(rule: AlertRule) {
|
||||||
try {
|
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()
|
await loadRules()
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort; list refresh will show the previous state on failure
|
// 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 (
|
return (
|
||||||
<div className="border-b border-border text-sm">
|
<div className="border-b border-border text-sm">
|
||||||
<div className={sectionHeaderClassName}>
|
<div className={sectionHeaderClassName}>
|
||||||
@@ -272,6 +236,9 @@ export function Alerts() {
|
|||||||
<HelpIcon sectionId="alerts" label="Help: Alerts" />
|
<HelpIcon sectionId="alerts" label="Help: Alerts" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="px-3 pt-3 text-xs text-text-faint">
|
||||||
|
Every rule delivers to the ntfy sink configured in Settings.
|
||||||
|
</p>
|
||||||
<div className="flex gap-4 p-3">
|
<div className="flex gap-4 p-3">
|
||||||
<form onSubmit={handleSubmit} className="w-96 shrink-0 space-y-2">
|
<form onSubmit={handleSubmit} className="w-96 shrink-0 space-y-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -424,35 +391,12 @@ export function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs text-text-subtle">
|
|
||||||
<span>Ntfy sink</span>
|
|
||||||
<HelpIcon sectionId="alerts" label="Help: Alert sink" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<input
|
|
||||||
className="w-1/2 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
value={form.topic}
|
|
||||||
onChange={(e) => patch({ topic: e.target.value })}
|
|
||||||
placeholder="topic"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-1/2 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
value={form.server}
|
|
||||||
onChange={(e) => patch({ server: e.target.value })}
|
|
||||||
placeholder="https://ntfy.sh"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||||
<input type="checkbox" checked={form.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />
|
<input type="checkbox" checked={form.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />
|
||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||||
{testError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{testError}</div>}
|
|
||||||
{testSuccess && <div className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary">Test notification sent.</div>}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -462,14 +406,6 @@ export function Alerts() {
|
|||||||
>
|
>
|
||||||
{submitting ? 'Saving…' : editingId ? 'Save rule' : 'Create rule'}
|
{submitting ? 'Saving…' : editingId ? 'Save rule' : 'Create rule'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleTestNotification}
|
|
||||||
disabled={testing}
|
|
||||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{testing ? 'Sending…' : 'Send test notification'}
|
|
||||||
</button>
|
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<button type="button" onClick={startCreate} className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2">
|
<button type="button" onClick={startCreate} className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2">
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Stats } from '../lib/api'
|
import type { Stats } from '../lib/api'
|
||||||
import { cssContentUrl, type ColorScheme, type CssEntry } from '../lib/api'
|
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api'
|
||||||
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
||||||
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
||||||
import { DocsPanel } from './Docs'
|
import { DocsPanel } from './Docs'
|
||||||
@@ -147,11 +147,47 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
const [themeFilename, setThemeFilename] = useState('')
|
const [themeFilename, setThemeFilename] = useState('')
|
||||||
const [themeContent, setThemeContent] = useState('')
|
const [themeContent, setThemeContent] = useState('')
|
||||||
const [themeLinkUrl, setThemeLinkUrl] = useState('')
|
const [themeLinkUrl, setThemeLinkUrl] = useState('')
|
||||||
|
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
|
||||||
|
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
||||||
|
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
||||||
|
const [ntfyTestSuccess, setNtfyTestSuccess] = useState(false)
|
||||||
|
const [ntfyTesting, setNtfyTesting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
|
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
|
||||||
}, [panel.settings?.max_logs])
|
}, [panel.settings?.max_logs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNtfyTopicInput(panel.settings?.ntfy_topic ?? '')
|
||||||
|
setNtfyServerInput(panel.settings?.ntfy_server ?? '')
|
||||||
|
}, [panel.settings?.ntfy_topic, panel.settings?.ntfy_server])
|
||||||
|
|
||||||
|
function handleNtfyTopicBlur() {
|
||||||
|
panel.setNtfySink(ntfyTopicInput.trim() || null, panel.settings?.ntfy_server ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNtfyServerBlur() {
|
||||||
|
panel.setNtfySink(panel.settings?.ntfy_topic ?? null, ntfyServerInput.trim() || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNtfyTest() {
|
||||||
|
setNtfyTestError(null)
|
||||||
|
setNtfyTestSuccess(false)
|
||||||
|
if (!ntfyTopicInput.trim()) {
|
||||||
|
setNtfyTestError('Enter a topic to send a test notification.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setNtfyTesting(true)
|
||||||
|
try {
|
||||||
|
await sendTestAlertNotification(ntfyTopicInput.trim(), ntfyServerInput.trim() || null)
|
||||||
|
setNtfyTestSuccess(true)
|
||||||
|
} catch (err) {
|
||||||
|
setNtfyTestError(err instanceof Error ? err.message : 'Failed to send test notification')
|
||||||
|
} finally {
|
||||||
|
setNtfyTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleMaxLogsBlur() {
|
function handleMaxLogsBlur() {
|
||||||
const trimmed = maxLogsInput.trim()
|
const trimmed = maxLogsInput.trim()
|
||||||
if (trimmed === '') {
|
if (trimmed === '') {
|
||||||
@@ -369,6 +405,42 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Ntfy sink</h3>
|
||||||
|
<HelpIcon sectionId="alerts" label="Help: Ntfy sink" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="topic"
|
||||||
|
value={ntfyTopicInput}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
value={ntfyServerInput}
|
||||||
|
onChange={(e) => 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 && <div className="text-xs text-danger-text-strong">{ntfyTestError}</div>}
|
||||||
|
{ntfyTestSuccess && <div className="text-xs text-text-secondary">Test notification sent.</div>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNtfyTest}
|
||||||
|
disabled={ntfyTesting}
|
||||||
|
className="self-start rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{ntfyTesting ? 'Sending…' : 'Send test notification'}
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-text-faint">Every alert rule delivers here — see the Alerts panel to define rules.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3>
|
||||||
<form onSubmit={handleImport} className="flex flex-col gap-2">
|
<form onSubmit={handleImport} className="flex flex-col gap-2">
|
||||||
|
|||||||
+12
-6
@@ -98,6 +98,10 @@ export interface Settings {
|
|||||||
max_logs: number | null
|
max_logs: number | null
|
||||||
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
||||||
custom_theme_id: string | null
|
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'
|
export type CssEntryKind = 'imported' | 'linked'
|
||||||
@@ -148,15 +152,11 @@ export type AlertCondition =
|
|||||||
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
||||||
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
| { 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 {
|
export interface AlertRule {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
condition: AlertCondition
|
condition: AlertCondition
|
||||||
sink: AlertSink
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
@@ -297,10 +297,16 @@ export function updateSettings(update: {
|
|||||||
maxLogs?: number | null
|
maxLogs?: number | null
|
||||||
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
||||||
customThemeId?: string | null
|
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<Settings> {
|
}): Promise<Settings> {
|
||||||
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
||||||
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
||||||
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
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)
|
return sendJson('/api/settings', 'PATCH', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,13 +390,13 @@ export function fetchAlertRules(): Promise<AlertRule[]> {
|
|||||||
return getJson('/api/settings/alerts')
|
return getJson('/api/settings/alerts')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }): Promise<AlertRule> {
|
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
|
||||||
return postJson('/api/settings/alerts', rule)
|
return postJson('/api/settings/alerts', rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAlertRule(
|
export function updateAlertRule(
|
||||||
id: string,
|
id: string,
|
||||||
rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink },
|
rule: { name: string; enabled: boolean; condition: AlertCondition },
|
||||||
): Promise<AlertRule> {
|
): Promise<AlertRule> {
|
||||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,15 @@ export function useSettingsPanel() {
|
|||||||
[guarded],
|
[guarded],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const setNtfySink = useCallback(
|
||||||
|
(ntfyTopic: string | null, ntfyServer: string | null) =>
|
||||||
|
guarded(async () => {
|
||||||
|
const next = await updateSettings({ ntfyTopic, ntfyServer })
|
||||||
|
setSettings(next)
|
||||||
|
})(),
|
||||||
|
[guarded],
|
||||||
|
)
|
||||||
|
|
||||||
const importCssEntry = useCallback(
|
const importCssEntry = useCallback(
|
||||||
(filename: string, content: string) =>
|
(filename: string, content: string) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
@@ -230,6 +239,7 @@ export function useSettingsPanel() {
|
|||||||
setColorScheme,
|
setColorScheme,
|
||||||
setCustomTheme,
|
setCustomTheme,
|
||||||
setMaxLogs,
|
setMaxLogs,
|
||||||
|
setNtfySink,
|
||||||
importCssEntry,
|
importCssEntry,
|
||||||
importThemeEntry,
|
importThemeEntry,
|
||||||
linkThemeEntry,
|
linkThemeEntry,
|
||||||
|
|||||||
Reference in New Issue
Block a user