feat: anamoly detection p2 (#10940)

Design at: anomaly-detection

---------

Signed-off-by: loaki07 <rustyloaki@gmail.com>
Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Loakesh Indiran <undefined@users.noreply.github.com>
Co-authored-by: ByteBaker <42913098+ByteBaker@users.noreply.github.com>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
This commit is contained in:
Loakesh Indiran
2026-03-19 21:36:06 +05:30
committed by GitHub
co-authored by Bhargav github-actions[bot] Claude Sonnet 4.6 claude[bot] Loakesh Indiran ByteBaker Shrinath Rao
parent e4f156b152
commit 36c2ef4418
58 changed files with 4529 additions and 1033 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ pub type RwAHashSet<K> = tokio::sync::RwLock<HashSet<K>>;
pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// for DDL commands and migrations
pub const DB_SCHEMA_VERSION: u64 = 34;
pub const DB_SCHEMA_VERSION: u64 = 35;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables
+15
View File
@@ -232,6 +232,17 @@ pub struct AlertListFilter {
pub owner: Option<String>,
}
/// Filters the alert list by alert type (scheduled, realtime, anomaly detection, or all).
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AlertTypeFilter {
#[default]
All,
Scheduled,
Realtime,
AnomalyDetection,
}
/// Parameters for listing alerts.
#[derive(Debug, Clone)]
pub struct ListAlertsParams {
@@ -259,6 +270,9 @@ pub struct ListAlertsParams {
/// The optional page size and page index of results to retrieve.
pub page_size_and_idx: Option<(u64, u64)>,
/// The optional alert type filter. Defaults to `All`.
pub alert_type: AlertTypeFilter,
}
impl ListAlertsParams {
@@ -273,6 +287,7 @@ impl ListAlertsParams {
enabled: None,
owner: None,
page_size_and_idx: None,
alert_type: AlertTypeFilter::All,
}
}
+16
View File
@@ -299,6 +299,22 @@ pub fn format_duration(ms: u64) -> String {
parts.join("")
}
/// Parse an interval string like "5m", "1h", "30s" into minutes.
/// Returns 0 for unrecognised formats.
pub fn parse_interval_to_minutes(s: &str) -> i64 {
if let Some(n) = s.strip_suffix('m') {
n.parse().unwrap_or(0)
} else if let Some(n) = s.strip_suffix('h') {
n.parse::<i64>().unwrap_or(0) * 60
} else if let Some(n) = s.strip_suffix('s') {
n.parse::<i64>().unwrap_or(0) / 60
} else if let Some(n) = s.strip_suffix('d') {
n.parse::<i64>().unwrap_or(0) * 1440
} else {
s.parse().unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -72,6 +72,8 @@ pub struct Alert {
/// List of destination names to notify when alert fires.
/// Destinations must be pre-configured in the system.
/// Accepts either `destinations` or `alert_destinations` as the JSON field name.
#[serde(default, alias = "alert_destinations")]
#[schema(example = json!(["slack-alerts", "pagerduty"]))]
pub destinations: Vec<String>,
+158 -29
View File
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use config::meta::alerts::alert as meta_alerts;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use svix_ksuid::Ksuid;
use utoipa::ToSchema;
@@ -57,45 +57,128 @@ pub struct CreateAlertRequestBody {
#[schema(example = "default")]
pub folder_id: Option<String>,
/// Discriminates the alert type. Defaults to scheduled alert when absent.
/// Set to `"anomaly_detection"` to create an anomaly detection config instead.
pub alert_type: Option<meta_alerts::AlertTypeFilter>,
/// Anomaly-detection-specific fields (nested object).
pub anomaly_config: Option<AnomalyAlertFields>,
/// The alert configuration. All fields from Alert are flattened into this request body.
#[serde(flatten)]
#[schema(inline)]
pub alert: Alert,
}
impl CreateAlertRequestBody {
/// Return the anomaly config fields, combining `detection_function` +
/// `detection_function_field` into the canonical "avg(field)" form.
/// Returns `None` when no `anomaly_config` was supplied or when
/// `detection_function` is missing (required).
pub fn anomaly_fields(&self) -> Option<AnomalyAlertFields> {
let mut base = self.anomaly_config.clone()?;
base.detection_function = combine_detection_function(
Some(base.detection_function),
base.detection_function_field.take(),
)?;
Some(base)
}
}
/// Anomaly-detection-specific fields for `CreateAlertRequestBody`.
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct AnomalyAlertFields {
/// "filters" or "custom_sql"
pub query_mode: String,
pub filters: Option<serde_json::Value>,
pub custom_sql: Option<String>,
/// Aggregation function, e.g. "count(*)" or "avg(cpu_usage)"
pub detection_function: String,
/// Field to aggregate (required for avg/sum/min/max/pXX, ignored for count).
/// Combined with `detection_function` into "avg(field)" before saving.
pub detection_function_field: Option<String>,
/// SQL histogram bucket size, e.g. "5m", "1h"
pub histogram_interval: String,
/// How often the detection job fires, e.g. "1h", "30m"
pub schedule_interval: String,
/// Look-back window per detection run, in seconds
pub detection_window_seconds: i64,
pub training_window_days: Option<i32>,
/// 0 = never retrain automatically; otherwise days between retrains
pub retrain_interval_days: Option<i32>,
/// Percentile threshold (50.0–99.9). Default: 97.0
/// Also accepts the name `threshold` (integer, e.g. 97) for API convenience.
#[serde(alias = "threshold")]
pub percentile: Option<f64>,
pub rcf_num_trees: Option<i32>,
pub rcf_tree_size: Option<i32>,
pub rcf_shingle_size: Option<i32>,
pub alert_enabled: Option<bool>,
}
/// HTTP request body for `UpdateAlert` endpoint.
///
/// Updates an existing alert. Provide the full alert configuration - this replaces
/// Updates an existing alert. Provide the full alert configuration — this replaces
/// the existing alert entirely (not a partial update). The request body is the same
/// structure as Alert.
///
/// ## Example
///
/// ```json
/// {
/// "name": "Updated Alert Name",
/// "stream_type": "logs",
/// "stream_name": "default",
/// "is_real_time": false,
/// "query_condition": {
/// "type": "sql",
/// "sql": "SELECT count(*) as count FROM \"default\" WHERE level = 'error'"
/// },
/// "trigger_condition": {
/// "period": 15,
/// "operator": ">=",
/// "threshold": 100,
/// "frequency": 5,
/// "frequency_type": "minutes",
/// "silence": 60
/// },
/// "destinations": ["slack-alerts"],
/// "enabled": true,
/// "description": "Updated description"
/// }
/// ```
/// For anomaly detection configs, set `alert_type = "anomaly_detection"` and supply
/// anomaly-specific fields directly at the top level (flat). Only fields present in
/// the request body will be updated (partial update semantics for anomaly fields).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct UpdateAlertRequestBody(#[schema(inline)] pub Alert);
pub struct UpdateAlertRequestBody {
/// Discriminates the alert type. When `"anomaly_detection"`, delegates to the
/// anomaly config update path.
pub alert_type: Option<meta_alerts::AlertTypeFilter>,
/// Anomaly-detection-specific fields to update (partial — only supplied fields
/// are changed).
pub anomaly_config: Option<UpdateAnomalyAlertFields>,
/// Alert configuration fields (used for scheduled/realtime alerts).
#[serde(flatten)]
#[schema(inline)]
pub alert: Alert,
}
impl UpdateAlertRequestBody {
/// Return the anomaly config fields, combining `detection_function` +
/// `detection_function_field` into the canonical "avg(field)" form when both are present.
pub fn anomaly_fields(&self) -> UpdateAnomalyAlertFields {
let mut base = self.anomaly_config.clone().unwrap_or_default();
// Combine detection_function + detection_function_field if either is supplied.
if base.detection_function.is_some() || base.detection_function_field.is_some() {
base.detection_function = combine_detection_function(
base.detection_function,
base.detection_function_field.take(),
);
}
base
}
}
/// Anomaly-detection-specific fields for `UpdateAlertRequestBody` (all optional).
#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)]
pub struct UpdateAnomalyAlertFields {
pub name: Option<String>,
pub description: Option<String>,
pub query_mode: Option<String>,
pub filters: Option<serde_json::Value>,
pub custom_sql: Option<String>,
pub detection_function: Option<String>,
/// Field to aggregate. Combined with `detection_function` into "avg(field)" before saving.
pub detection_function_field: Option<String>,
pub histogram_interval: Option<String>,
pub schedule_interval: Option<String>,
pub detection_window_seconds: Option<i64>,
pub training_window_days: Option<i32>,
pub retrain_interval_days: Option<i32>,
pub percentile: Option<f64>,
pub alert_enabled: Option<bool>,
pub enabled: Option<bool>,
pub folder_id: Option<String>,
pub owner: Option<String>,
}
/// HTTP request body for `MoveAlerts` endpoint.
#[derive(Clone, Debug, Deserialize, ToSchema)]
@@ -104,6 +187,12 @@ pub struct MoveAlertsRequestBody {
#[schema(value_type = Vec<String>)]
pub alert_ids: Vec<Ksuid>,
/// IDs of anomaly detection configs to move. Defaults to empty when not
/// provided. Callers should always supply this to avoid per-ID DB lookups.
#[schema(value_type = Vec<String>)]
#[serde(default)]
pub anomaly_config_ids: Vec<Ksuid>,
/// Indicates the folder to which alerts should be moved.
pub dst_folder_id: String,
}
@@ -142,6 +231,10 @@ pub struct ListAlertsQuery {
///
/// This parameter is only used if `page_size` is also set.
pub page_idx: Option<u64>,
/// Optional alert type filter: `all` (default), `scheduled`, `realtime`,
/// or `anomaly_detection`.
pub alert_type: Option<meta_alerts::AlertTypeFilter>,
}
/// HTTP URL query component that contains parameters for enabling alerts.
@@ -161,7 +254,7 @@ impl From<CreateAlertRequestBody> for meta_alerts::Alert {
impl From<UpdateAlertRequestBody> for meta_alerts::Alert {
fn from(value: UpdateAlertRequestBody) -> Self {
value.0.into()
value.alert.into()
}
}
@@ -179,6 +272,7 @@ impl ListAlertsQuery {
page_size_and_idx: self
.page_size
.map(|page_size| (page_size, self.page_idx.unwrap_or(0))),
alert_type: self.alert_type.unwrap_or_default(),
}
}
}
@@ -189,6 +283,41 @@ pub struct AlertBulkEnableRequest {
pub ids: Vec<Ksuid>,
}
/// HTTP request body for `CloneAlert` endpoint.
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct CloneAlertRequestBody {
/// Optional new name for the cloned alert. Defaults to `<source_name>_copy`.
pub name: Option<String>,
/// Optional folder ID to place the clone in. Defaults to the source folder.
pub folder_id: Option<String>,
}
/// Combine a detection function name and optional field into the stored form.
///
/// "avg" + Some("cpu_millicores") → Some("avg(cpu_millicores)")
/// "count" + _ → Some("count(*)")
/// "avg" + None → Some("avg") (legacy — field will use fallback at query-build time)
/// None → None
pub fn combine_detection_function(
function: Option<String>,
field: Option<String>,
) -> Option<String> {
let fn_name = function?;
if fn_name.contains('(') {
// Already in combined form (e.g. "avg(cpu_millicores)") — pass through.
return Some(fn_name);
}
let combined = match fn_name.as_str() {
"count" => "count(*)".to_string(),
other => match field {
Some(f) if !f.is_empty() => format!("{}({})", other, f),
_ => fn_name, // no field yet — store bare name, query builder will warn
},
};
Some(combined)
}
/// HTTP request body for `GenerateSql` endpoint.
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct GenerateSqlRequestBody {
+113 -6
View File
@@ -13,12 +13,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use config::meta::{alerts::alert as meta_alerts, folder as meta_folders, triggers::Trigger};
use std::str::FromStr;
use config::{
meta::{alerts::alert as meta_alerts, folder as meta_folders, triggers::Trigger},
utils::time::parse_interval_to_minutes,
};
use serde::{Deserialize, Serialize};
use svix_ksuid::Ksuid;
use utoipa::ToSchema;
use super::{Alert, QueryCondition, TriggerCondition};
use super::{Alert, FrequencyType, QueryCondition, TriggerCondition};
/// HTTP response body for `GetAlert` endpoint.
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
@@ -31,6 +36,10 @@ pub struct ListAlertsResponseBody {
}
/// An item in the list returned by the `ListAlerts` endpoint.
///
/// For `scheduled` and `realtime` alert types, `condition` and
/// `trigger_condition` are always present. For `anomaly_detection` items they
/// are absent; use `last_trained_at` and `status` instead.
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct ListAlertsResponseBodyItem {
#[schema(value_type = String)]
@@ -40,12 +49,27 @@ pub struct ListAlertsResponseBodyItem {
pub name: String,
pub owner: Option<String>,
pub description: Option<String>,
pub condition: QueryCondition,
pub trigger_condition: TriggerCondition,
/// Discriminator: "scheduled" | "realtime" | "anomaly_detection"
pub alert_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<QueryCondition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger_condition: Option<TriggerCondition>,
pub enabled: bool,
pub last_triggered_at: Option<i64>,
pub last_satisfied_at: Option<i64>,
pub is_real_time: bool,
/// Timestamp (µs) when the anomaly model was last successfully trained.
/// Only present for `anomaly_detection` items.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_trained_at: Option<i64>,
/// Scheduler status string. Only present for `anomaly_detection` items.
/// Values: "waiting" | "active" | "training" | "failed" | "disabled"
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// Last error message from training or detection. Only present for `anomaly_detection` items.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
/// HTTP response body for `EnableAlert` endpoint.
@@ -91,6 +115,11 @@ impl TryFrom<(meta_folders::Folder, meta_alerts::Alert, Option<Trigger>)>
alert.get_last_triggered_at(trigger.as_ref()),
alert.get_last_satisfied_at(trigger.as_ref()),
);
let alert_type = if alert.is_real_time {
"realtime".to_string()
} else {
"scheduled".to_string()
};
Ok(Self {
alert_id: alert.id.ok_or(())?,
folder_id: folder.folder_id,
@@ -98,15 +127,93 @@ impl TryFrom<(meta_folders::Folder, meta_alerts::Alert, Option<Trigger>)>
name: alert.name,
owner: alert.owner,
description: Some(alert.description).filter(|d| !d.is_empty()),
condition: alert.query_condition.into(),
trigger_condition: alert.trigger_condition.into(),
alert_type,
condition: Some(alert.query_condition.into()),
trigger_condition: Some(alert.trigger_condition.into()),
enabled: alert.enabled,
last_triggered_at,
last_satisfied_at,
is_real_time: alert.is_real_time,
last_trained_at: None,
status: None,
last_error: None,
})
}
}
/// Converts a `serde_json::Value` returned by `anomaly_detection::list_configs`
/// into a `ListAlertsResponseBodyItem`. Returns `None` if required fields are
/// missing or the anomaly_id cannot be parsed as a KSUID.
pub fn anomaly_config_to_list_item(v: &serde_json::Value) -> Option<ListAlertsResponseBodyItem> {
let anomaly_id_str = v.get("anomaly_id")?.as_str()?;
let alert_id = Ksuid::from_str(anomaly_id_str).ok()?;
let folder_id = v
.get("folder_id")
.and_then(|f| f.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("default")
.to_string();
// list_configs runs model_to_api_json which already converts the integer
// status column to a string label ("waiting", "active", etc.). Read it
// directly as a string; the old as_i64() path always returned None here.
let status = v.get("status").and_then(|s| s.as_str()).map(String::from);
// Build trigger_condition so the UI can display "Look back window" and "Check every".
// period = detection_window_seconds / 60 (look-back window in minutes)
// frequency = schedule_interval parsed to minutes (e.g. "5m" → 5, "1h" → 60)
let period_minutes = v
.get("detection_window_seconds")
.and_then(|s| s.as_i64())
.map(|s| s / 60)
.unwrap_or(0);
let frequency_minutes = v
.get("schedule_interval")
.and_then(|s| s.as_str())
.map(parse_interval_to_minutes)
.unwrap_or(0);
let trigger_condition = Some(TriggerCondition {
period_minutes,
frequency_minutes,
frequency_type: FrequencyType::Minutes,
..Default::default()
});
let folder_name = v
.get("folder_name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
Some(ListAlertsResponseBodyItem {
alert_id,
folder_id,
folder_name,
name: v.get("name")?.as_str()?.to_string(),
owner: v.get("owner").and_then(|o| o.as_str()).map(String::from),
description: v
.get("description")
.and_then(|d| d.as_str())
.filter(|d| !d.is_empty())
.map(String::from),
alert_type: "anomaly_detection".to_string(),
condition: None,
trigger_condition,
enabled: v.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false),
last_triggered_at: v.get("last_detection_run").and_then(|t| t.as_i64()),
last_satisfied_at: v.get("last_anomaly_detected_at").and_then(|t| t.as_i64()),
is_real_time: false,
last_trained_at: v.get("training_completed_at").and_then(|t| t.as_i64()),
status,
last_error: v
.get("last_error")
.and_then(|e| e.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
})
}
#[derive(Default, Serialize, ToSchema)]
pub struct AlertBulkEnableResponse {
#[schema(value_type = Vec<String>)]
+198
View File
@@ -49,6 +49,9 @@ use crate::{
pub struct AlertHistoryQuery {
/// Filter by specific alert name
pub alert_id: Option<Ksuid>,
/// Filter by anomaly detection config ID (mutually exclusive with alert_id).
/// When set, queries the _anomalies stream instead of the triggers stream.
pub anomaly_id: Option<String>,
/// Start time in Unix timestamp microseconds
pub start_time: Option<i64>,
/// End time in Unix timestamp microseconds
@@ -94,6 +97,9 @@ pub struct AlertHistoryEntry {
pub grouped: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_size: Option<i32>,
/// Number of anomalies found in this evaluation run (anomaly detection only).
#[serde(skip_serializing_if = "Option::is_none")]
pub anomaly_count: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -236,6 +242,177 @@ pub async fn get_alert_history(
"DESC" // Default sort order
};
// Anomaly detection history: query the triggers stream with module = 'anomaly_detection'.
// The anomaly_count is stored in success_response as {"anomalies_found": N}.
// Result is derived: "anomaly" when count > 0, "normal" when 0, "failed"/"skipped" on error.
if let Some(ref anomaly_id) = query.anomaly_id {
let escaped_id = escape_like(anomaly_id);
let where_clause = format!(
"module = 'anomaly_detection' AND key LIKE '%/{escaped_id}' \
AND org = '{org_id}' AND _timestamp >= {start_time} AND _timestamp <= {end_time}"
);
let trace_id = get_or_create_trace_id(req.headers(), &Span::current());
let count_sql =
format!("SELECT _timestamp FROM \"{TRIGGERS_STREAM}\" WHERE {where_clause}");
let count_req = SearchRequest {
query: SearchQuery {
sql: count_sql,
start_time,
end_time,
from: 0,
size: 1,
track_total_hits: true,
..Default::default()
},
regions: vec![],
clusters: vec![],
timeout: 0,
use_cache: false,
..Default::default()
};
let total_count = match SearchService::search(
&trace_id,
&org_id,
StreamType::Logs,
Some(user_email.user_id.clone()),
&count_req,
)
.instrument(Span::current())
.await
{
Ok(r) => r.total,
Err(e) => {
// Triggers stream doesn't exist yet — treat as empty.
let msg = e.to_string().to_lowercase();
if msg.contains("not found") || msg.contains("stream not found") {
return MetaHttpResponse::json(AlertHistoryResponse {
total: 0,
from,
size,
hits: vec![],
});
}
log::error!("Failed to get anomaly history count: {e}");
return MetaHttpResponse::internal_error(format!(
"Failed to get anomaly history count: {e}"
));
}
};
if total_count == 0 || from >= total_count as i64 {
return MetaHttpResponse::json(AlertHistoryResponse {
total: total_count,
from,
size,
hits: vec![],
});
}
let data_sql = format!(
"SELECT _timestamp, status, evaluation_took_in_secs, error, success_response \
FROM \"{TRIGGERS_STREAM}\" WHERE {where_clause} \
ORDER BY {sort_column} {sort_order} LIMIT {size} OFFSET {from}"
);
let data_req = SearchRequest {
query: SearchQuery {
sql: data_sql,
start_time,
end_time,
from: 0,
size,
track_total_hits: false,
..Default::default()
},
regions: vec![],
clusters: vec![],
timeout: 0,
use_cache: false,
..Default::default()
};
let search_result = match SearchService::search(
&trace_id,
&org_id,
StreamType::Logs,
Some(user_email.user_id.clone()),
&data_req,
)
.instrument(Span::current())
.await
{
Ok(r) => r,
Err(e) => {
log::error!("Failed to search anomaly history: {e}");
return MetaHttpResponse::internal_error(format!(
"Failed to search anomaly history: {e}"
));
}
};
let hits: Vec<AlertHistoryEntry> = search_result
.hits
.into_iter()
.map(|hit| {
let raw_status = hit
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Derive result: parse anomalies_found from success_response JSON.
let anomaly_count = hit
.get("success_response")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["anomalies_found"].as_i64())
.unwrap_or(0);
let result = match raw_status {
"failed" => "failed".to_string(),
"skipped" => "skipped".to_string(),
_ => {
if anomaly_count > 0 {
"anomaly".to_string()
} else {
"normal".to_string()
}
}
};
AlertHistoryEntry {
timestamp: hit.get("_timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
alert_name: String::new(),
org: org_id.clone(),
status: result,
is_realtime: false,
is_silenced: false,
start_time: hit.get("_timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
end_time: hit.get("_timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
retries: 0,
error: hit.get("error").and_then(|v| v.as_str()).map(String::from),
success_response: None,
is_partial: None,
delay_in_secs: None,
evaluation_took_in_secs: hit
.get("evaluation_took_in_secs")
.and_then(|v| v.as_f64()),
source_node: None,
query_took: None,
dedup_enabled: None,
dedup_suppressed: None,
dedup_count: None,
grouped: None,
group_size: None,
anomaly_count: Some(anomaly_count as i32),
}
})
.collect();
return MetaHttpResponse::json(AlertHistoryResponse {
total: total_count,
from,
size,
hits,
});
}
// If alert_id filter is provided, validate it exists
let _folder_id = if let Some(alert_id) = query.alert_id {
// Verify the alert exists in the organization
@@ -434,6 +611,16 @@ pub async fn get_alert_history(
{
Ok(result) => result.total,
Err(e) => {
// Triggers stream doesn't exist yet (no alert has ever fired in this org).
let msg = e.to_string().to_lowercase();
if msg.contains("stream not found") || msg.contains("not found") {
return MetaHttpResponse::json(AlertHistoryResponse {
total: 0,
from,
size,
hits: vec![],
});
}
log::error!("Failed to get alert history count: {}", e);
return MetaHttpResponse::internal_error(format!(
"Failed to get alert history count: {e}"
@@ -562,6 +749,7 @@ pub async fn get_alert_history(
.get("group_size")
.and_then(|v| v.as_i64())
.map(|v| v as i32),
anomaly_count: None,
});
}
@@ -584,6 +772,7 @@ mod tests {
fn test_alert_history_query_defaults() {
let query = AlertHistoryQuery {
alert_id: None,
anomaly_id: None,
start_time: None,
end_time: None,
from: None,
@@ -625,6 +814,7 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
anomaly_count: None,
};
assert_eq!(entry.alert_name, "test_alert");
@@ -675,6 +865,7 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
anomaly_count: None,
};
let response = AlertHistoryResponse {
@@ -714,6 +905,7 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
anomaly_count: None,
};
assert_eq!(entry.status, "error");
@@ -728,6 +920,7 @@ mod tests {
// Test pagination parameters
let query = AlertHistoryQuery {
alert_id: None,
anomaly_id: None,
start_time: None,
end_time: None,
from: Some(100),
@@ -762,6 +955,7 @@ mod tests {
for field in sort_fields {
let query = AlertHistoryQuery {
alert_id: None,
anomaly_id: None,
start_time: None,
end_time: None,
from: None,
@@ -780,6 +974,7 @@ mod tests {
// Test ascending order
let query_asc = AlertHistoryQuery {
alert_id: None,
anomaly_id: None,
start_time: None,
end_time: None,
from: None,
@@ -792,6 +987,7 @@ mod tests {
// Test descending order
let query_desc = AlertHistoryQuery {
alert_id: None,
anomaly_id: None,
start_time: None,
end_time: None,
from: None,
@@ -923,6 +1119,7 @@ mod tests {
dedup_count: Some(1),
grouped: Some(false),
group_size: None,
anomaly_count: None,
};
let json = serde_json::to_string(&entry).unwrap();
@@ -959,6 +1156,7 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
anomaly_count: None,
};
let response = AlertHistoryResponse {
+660 -50
View File
@@ -22,7 +22,7 @@ use axum::{
response::Response,
};
use config::meta::{
alerts::alert::Alert as MetaAlert,
alerts::alert::{Alert as MetaAlert, AlertTypeFilter},
triggers::{Trigger, TriggerModule},
};
use hashbrown::HashMap;
@@ -36,19 +36,24 @@ use {
},
};
#[cfg(feature = "enterprise")]
use crate::handler::http::models::alerts::requests::UpdateAnomalyAlertFields;
#[cfg(feature = "enterprise")]
use crate::handler::http::models::alerts::responses::anomaly_config_to_list_item;
use crate::{
common::{meta::http::HttpResponse as MetaHttpResponse, utils::auth::UserEmail},
handler::http::{
extractors::Headers,
models::alerts::{
requests::{
AlertBulkEnableRequest, CreateAlertRequestBody, EnableAlertQuery,
GenerateSqlRequestBody, ListAlertsQuery, MoveAlertsRequestBody,
AlertBulkEnableRequest, CloneAlertRequestBody, CreateAlertRequestBody,
EnableAlertQuery, GenerateSqlRequestBody, ListAlertsQuery, MoveAlertsRequestBody,
UpdateAlertRequestBody,
},
responses::{
AlertBulkEnableResponse, EnableAlertResponseBody, GenerateSqlMetadata,
GenerateSqlResponseBody, GetAlertResponseBody, ListAlertsResponseBody,
ListAlertsResponseBodyItem,
},
},
request::{
@@ -146,6 +151,12 @@ pub async fn create_alert(
) -> Response {
let query_str = uri.query().unwrap_or("");
let folder_id = get_folder(query_str);
// Anomaly detection path: delegate to anomaly config creation (enterprise only).
#[cfg(feature = "enterprise")]
if req_body.alert_type == Some(AlertTypeFilter::AnomalyDetection) {
return create_anomaly_alert(&org_id, user_email.user_id, req_body, &folder_id).await;
}
let overwrite = is_overwrite(query_str);
let mut alert: MetaAlert = req_body.into();
if alert.owner.clone().filter(|o| !o.is_empty()).is_none() {
@@ -164,6 +175,65 @@ pub async fn create_alert(
}
}
#[cfg(feature = "enterprise")]
async fn create_anomaly_alert(
org_id: &str,
user_id: String,
req_body: CreateAlertRequestBody,
query_folder_id: &str,
) -> Response {
use crate::handler::http::request::anomaly_detection::CreateAnomalyConfigRequest;
let Some(anomaly_fields) = req_body.anomaly_fields() else {
return MetaHttpResponse::bad_request(
"detection_function is required when alert_type is anomaly_detection",
);
};
let owner = if req_body.alert.owner.as_deref().unwrap_or("").is_empty() {
Some(user_id)
} else {
req_body.alert.owner
};
let req = CreateAnomalyConfigRequest {
name: req_body.alert.name,
description: Some(req_body.alert.description).filter(|d| !d.is_empty()),
stream_name: req_body.alert.stream_name,
stream_type: config::meta::stream::StreamType::from(req_body.alert.stream_type).to_string(),
query_mode: anomaly_fields.query_mode,
filters: anomaly_fields.filters,
custom_sql: anomaly_fields.custom_sql,
// detection_function is already in combined form "avg(field)" from anomaly_fields()
detection_function: anomaly_fields.detection_function,
detection_function_field: None,
histogram_interval: anomaly_fields.histogram_interval,
schedule_interval: anomaly_fields.schedule_interval,
detection_window_seconds: anomaly_fields.detection_window_seconds,
training_window_days: anomaly_fields.training_window_days,
retrain_interval_days: anomaly_fields.retrain_interval_days,
percentile: anomaly_fields.percentile,
rcf_num_trees: anomaly_fields.rcf_num_trees,
rcf_tree_size: anomaly_fields.rcf_tree_size,
rcf_shingle_size: anomaly_fields.rcf_shingle_size,
alert_enabled: anomaly_fields.alert_enabled,
alert_destinations: req_body.alert.destinations,
enabled: Some(req_body.alert.enabled),
// Prefer explicit folder_id in JSON body; fall back to the ?folder= query param
// (same mechanism regular alerts use — the UI sends folder as a query param).
folder_id: req_body
.folder_id
.filter(|f| !f.is_empty())
.or_else(|| Some(query_folder_id.to_string()).filter(|f| !f.is_empty())),
owner,
};
match crate::service::anomaly_detection::create_config(org_id, req).await {
Ok(v) => MetaHttpResponse::json(v),
Err(e) => MetaHttpResponse::internal_error(e.to_string()),
}
}
/// GetAlert
#[utoipa::path(
get,
@@ -191,6 +261,7 @@ pub async fn create_alert(
)
)]
pub async fn get_alert(Path((org_id, alert_id)): Path<(String, String)>) -> Response {
let alert_id_str = alert_id.clone();
let alert_id = match Ksuid::from_str(&alert_id) {
Ok(id) => id,
Err(_) => {
@@ -207,6 +278,32 @@ pub async fn get_alert(Path((org_id, alert_id)): Path<(String, String)>) -> Resp
let resp_body: GetAlertResponseBody = (alert, scheduled_job).into();
MetaHttpResponse::json(resp_body)
}
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::not_found(format!("alert {alert_id_str} not found"))
}
#[cfg(feature = "enterprise")]
{
// Fall back to anomaly detection config lookup.
match crate::service::anomaly_detection::get_config(&org_id, &alert_id_str).await {
Ok(Some(mut v)) => {
// Tag with alert_type so the caller can discriminate.
if let Some(obj) = v.as_object_mut() {
obj.insert(
"alert_type".to_string(),
serde_json::Value::String("anomaly_detection".to_string()),
);
}
MetaHttpResponse::json(v)
}
Ok(None) => {
MetaHttpResponse::not_found(format!("alert {alert_id_str} not found"))
}
Err(e) => MetaHttpResponse::internal_error(e.to_string()),
}
}
}
Err(e) => e.into(),
}
}
@@ -238,6 +335,8 @@ pub async fn get_alert(Path((org_id, alert_id)): Path<(String, String)>) -> Resp
)
)]
pub async fn export_alert(Path((org_id, alert_id)): Path<(String, String)>) -> Response {
#[cfg(feature = "enterprise")]
let alert_id_str = alert_id.clone();
let alert_id = match Ksuid::from_str(&alert_id) {
Ok(id) => id,
Err(_) => {
@@ -254,6 +353,139 @@ pub async fn export_alert(Path((org_id, alert_id)): Path<(String, String)>) -> R
let resp_body: GetAlertResponseBody = (alert, scheduled_job).into();
MetaHttpResponse::json(resp_body)
}
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::not_found("alert not found")
}
#[cfg(feature = "enterprise")]
{
// Fall back to anomaly detection config export
match crate::service::anomaly_detection::get_config(&org_id, &alert_id_str).await {
Ok(Some(mut v)) => {
// Inject alert_type so consumers know what kind this is
if let Some(obj) = v.as_object_mut() {
obj.insert(
"alert_type".to_string(),
serde_json::Value::String("anomaly_detection".to_string()),
);
// Strip runtime/training state from the export payload
for key in &[
"is_trained",
"training_started_at",
"training_completed_at",
"last_processed_timestamp",
"current_model_version",
"status",
"last_error",
"retries",
] {
obj.remove(*key);
}
}
MetaHttpResponse::json(v)
}
Ok(None) => MetaHttpResponse::not_found("alert not found"),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
MetaHttpResponse::not_found(e.to_string())
} else {
MetaHttpResponse::internal_error(e.to_string())
}
}
}
}
}
Err(e) => e.into(),
}
}
/// CloneAlert
#[utoipa::path(
post,
path = "/v2/{org_id}/alerts/{alert_id}/clone",
context_path = "/api",
tag = "Alerts",
operation_id = "CloneAlert",
summary = "Clone an alert or anomaly detection config",
description = "Creates a copy of an existing alert or anomaly detection config. For anomaly configs, the clone starts untrained with counters reset. Provide an optional name and folder_id in the request body.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("alert_id" = String, Path, description = "Source alert or anomaly config ID"),
),
request_body(content = inline(CloneAlertRequestBody), description = "Clone options", content_type = "application/json"),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 404, description = "NotFound", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Alerts", "operation": "create"})),
)
)]
pub async fn clone_alert(
Path((org_id, alert_id)): Path<(String, String)>,
Json(req_body): Json<CloneAlertRequestBody>,
) -> Response {
let alert_id_str = alert_id.clone();
let alert_id = match Ksuid::from_str(&alert_id) {
Ok(id) => id,
Err(_) => {
return MetaHttpResponse::not_found(format!("invalid alert id {alert_id}"));
}
};
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
// Check if this is a regular alert first
match alert::get_by_id(client, &org_id, alert_id).await {
Ok((folder, mut src_alert)) => {
// Clone the alert: copy fields, generate new name
let new_name = req_body
.name
.unwrap_or_else(|| format!("{}_copy", src_alert.name));
let dst_folder = req_body
.folder_id
.unwrap_or_else(|| folder.folder_id.clone());
src_alert.name = new_name;
// Clear the ID so a new one is assigned on insert
src_alert.id = None;
match alert::create(client, &org_id, &dst_folder, src_alert, false).await {
Ok(saved) => MetaHttpResponse::json(saved),
Err(e) => e.into(),
}
}
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::not_found(format!("alert {alert_id_str} not found"))
}
#[cfg(feature = "enterprise")]
{
// Fall back to anomaly detection config clone
match crate::service::anomaly_detection::clone_config(
&org_id,
&alert_id_str,
req_body.name,
req_body.folder_id,
)
.await
{
Ok(v) => MetaHttpResponse::json(v),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
MetaHttpResponse::not_found(e.to_string())
} else {
MetaHttpResponse::internal_error(e.to_string())
}
}
}
}
}
Err(e) => e.into(),
}
}
@@ -290,23 +522,113 @@ pub async fn update_alert(
Headers(user_email): Headers<UserEmail>,
Json(req_body): Json<UpdateAlertRequestBody>,
) -> Response {
let alert_id_str = alert_id.clone();
let alert_id = match Ksuid::from_str(&alert_id) {
Ok(id) => id,
Err(_) => {
return MetaHttpResponse::not_found(format!("invalid alert id {alert_id}"));
}
};
// Explicit anomaly detection path (enterprise only).
#[cfg(feature = "enterprise")]
if req_body.alert_type == Some(AlertTypeFilter::AnomalyDetection) {
let alert = req_body.alert.clone();
let anomaly_fields = req_body.anomaly_fields();
return build_and_run_anomaly_update(
&org_id,
&alert_id_str,
user_email.user_id,
anomaly_fields,
alert,
)
.await;
}
// Save anomaly fields before req_body is consumed, in case we need the fallback.
#[cfg(feature = "enterprise")]
let anomaly_config = req_body.anomaly_fields();
#[cfg(feature = "enterprise")]
let alert_fields_for_fallback = req_body.alert.clone();
let mut alert: MetaAlert = req_body.into();
alert.last_edited_by = Some(user_email.user_id);
alert.last_edited_by = Some(user_email.user_id.clone());
alert.id = Some(alert_id);
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
match alert::update(client, &org_id, None, alert).await {
Ok(_) => MetaHttpResponse::ok("Alert Updated"),
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::not_found(format!("alert {alert_id_str} not found"))
}
#[cfg(feature = "enterprise")]
{
// ID not in alerts table — try anomaly config.
build_and_run_anomaly_update(
&org_id,
&alert_id_str,
user_email.user_id,
anomaly_config,
alert_fields_for_fallback,
)
.await
}
}
Err(e) => e.into(),
}
}
#[cfg(feature = "enterprise")]
async fn build_and_run_anomaly_update(
org_id: &str,
anomaly_id: &str,
user_id: String,
fields: UpdateAnomalyAlertFields,
alert: crate::handler::http::models::alerts::Alert,
) -> Response {
use crate::handler::http::request::anomaly_detection::UpdateAnomalyConfigRequest;
let owner = fields
.owner
.or_else(|| alert.owner.clone())
.or(Some(user_id));
let name = fields
.name
.or_else(|| Some(alert.name).filter(|n| !n.is_empty()));
let description = fields
.description
.or_else(|| Some(alert.description).filter(|d| !d.is_empty()));
let req = UpdateAnomalyConfigRequest {
name,
description,
query_mode: fields.query_mode,
filters: fields.filters,
custom_sql: fields.custom_sql,
detection_function: fields.detection_function,
detection_function_field: None, /* already combined into detection_function by
* anomaly_fields() */
histogram_interval: fields.histogram_interval,
schedule_interval: fields.schedule_interval,
detection_window_seconds: fields.detection_window_seconds,
training_window_days: fields.training_window_days,
percentile: fields.percentile,
retrain_interval_days: fields.retrain_interval_days,
alert_enabled: fields.alert_enabled,
alert_destinations: Some(alert.destinations),
enabled: fields.enabled,
folder_id: fields.folder_id,
owner,
};
match crate::service::anomaly_detection::update_config(org_id, anomaly_id, req).await {
Ok(v) => MetaHttpResponse::json(v),
Err(e) => MetaHttpResponse::internal_error(e.to_string()),
}
}
/// DeleteAlert
#[utoipa::path(
delete,
@@ -334,6 +656,7 @@ pub async fn update_alert(
)
)]
pub async fn delete_alert(Path((org_id, alert_id)): Path<(String, String)>) -> Response {
let alert_id_str = alert_id.clone();
let alert_id = match Ksuid::from_str(&alert_id) {
Ok(id) => id,
Err(_) => {
@@ -341,10 +664,37 @@ pub async fn delete_alert(Path((org_id, alert_id)): Path<(String, String)>) -> R
}
};
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
match alert::delete_by_id(client, &org_id, alert_id).await {
Ok(_) => MetaHttpResponse::ok("Alert deleted"),
Err(e) => e.into(),
// Check whether this ID belongs to a regular alert before attempting delete.
// delete_by_id silently returns Ok(()) when the record is not found (required
// for super-cluster sync idempotency), so we must check existence explicitly.
let is_regular_alert = alert::get_by_id(client, &org_id, alert_id).await.is_ok();
if is_regular_alert {
return match alert::delete_by_id(client, &org_id, alert_id).await {
Ok(_) => MetaHttpResponse::ok("Alert deleted"),
Err(e) => e.into(),
};
}
// Not a regular alert — try anomaly detection config (enterprise only).
#[cfg(feature = "enterprise")]
{
match crate::service::anomaly_detection::delete_config(&org_id, &alert_id_str).await {
Ok(_) => MetaHttpResponse::ok("Alert deleted"),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
MetaHttpResponse::not_found(e.to_string())
} else {
MetaHttpResponse::internal_error(e.to_string())
}
}
}
}
#[cfg(not(feature = "enterprise"))]
MetaHttpResponse::not_found(format!("alert {alert_id_str} not found"))
}
/// DeleteAlertBulk
@@ -408,14 +758,28 @@ pub async fn delete_alert_bulk(
for id in req.ids {
// already checked this is valid, so ok to unwrap
let alert_id = Ksuid::from_str(&id).unwrap();
match alert::delete_by_id(client, &org_id, alert_id).await {
Ok(_) => {
successful.push(id);
let is_regular_alert = alert::get_by_id(client, &org_id, alert_id).await.is_ok();
let result = if is_regular_alert {
alert::delete_by_id(client, &org_id, alert_id)
.await
.map_err(|e| e.to_string())
} else {
// Not a regular alert — fall back to anomaly config delete (enterprise only).
#[cfg(not(feature = "enterprise"))]
{
Err(format!("alert {id} not found"))
}
#[cfg(feature = "enterprise")]
crate::service::anomaly_detection::delete_config(&org_id, &id)
.await
.map_err(|e: anyhow::Error| e.to_string())
};
match result {
Ok(_) => successful.push(id),
Err(e) => {
log::error!("error deleting alert {org_id}/{id} : {e}");
unsuccessful.push(id);
err = Some(e.to_string())
err = Some(e);
}
}
}
@@ -461,32 +825,92 @@ pub async fn list_alerts(
#[cfg(feature = "enterprise")]
let user_id = Some(user_email.user_id.as_str());
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let scheduled_jobs = scheduler::list_by_org(&org_id, Some(TriggerModule::Alert))
.await
.unwrap_or_default();
let mut scheduled_jobs: HashMap<String, Trigger> = scheduled_jobs
.into_iter()
.map(|t| (t.module_key.clone(), t))
.collect();
let folders_and_alerts_scheduled_job =
match alert::list_v2(client, user_id, query.into(&org_id)).await {
Ok(f_a) => {
let f_a: Vec<_> = f_a
.into_iter()
.map(|(folder, alert)| {
let key = alert.get_unique_key();
(folder, alert, scheduled_jobs.remove(&key))
})
.collect();
f_a
}
#[cfg(feature = "enterprise")]
let folder_slug = query.folder.clone();
#[cfg(feature = "enterprise")]
let name_substring = query.alert_name_substring.clone();
#[cfg(feature = "enterprise")]
let page_size_and_idx = query.page_size.map(|s| (s, query.page_idx.unwrap_or(0)));
#[cfg(not(feature = "enterprise"))]
let params = query.into(&org_id);
#[cfg(feature = "enterprise")]
let mut params = query.into(&org_id);
let alert_type = params.alert_type;
// In enterprise builds, pagination is applied after merging regular alerts with
// anomaly detection configs, so we fetch all matching results from the DB here.
#[cfg(feature = "enterprise")]
{
params.page_size_and_idx = None;
}
// Fetch regular (scheduled / realtime) alerts unless the filter is anomaly-only.
let list: Vec<ListAlertsResponseBodyItem> = if alert_type != AlertTypeFilter::AnomalyDetection {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let mut scheduled_jobs: HashMap<String, Trigger> =
scheduler::list_by_org(&org_id, Some(TriggerModule::Alert))
.await
.unwrap_or_default()
.into_iter()
.map(|t| (t.module_key.clone(), t))
.collect();
let folders_and_alerts = match alert::list_v2(client, user_id, params).await {
Ok(v) => v,
Err(e) => return e.into(),
};
let Ok(resp_body) = ListAlertsResponseBody::try_from(folders_and_alerts_scheduled_job) else {
return MetaHttpResponse::internal_error("");
folders_and_alerts
.into_iter()
// Apply is_real_time filter when a specific type is requested.
.filter(|(_, a)| match alert_type {
AlertTypeFilter::Scheduled => !a.is_real_time,
AlertTypeFilter::Realtime => a.is_real_time,
_ => true,
})
.map(|(folder, alert)| {
let key = alert.get_unique_key();
(folder, alert, scheduled_jobs.remove(&key))
})
.filter_map(|item| ListAlertsResponseBodyItem::try_from(item).ok())
.collect()
} else {
vec![]
};
MetaHttpResponse::json(resp_body)
// In enterprise builds, anomaly configs will be appended — we need mutability.
#[cfg(feature = "enterprise")]
let mut list = list;
// Fetch anomaly detection configs and merge when the filter includes them (enterprise only).
#[cfg(feature = "enterprise")]
if matches!(
alert_type,
AlertTypeFilter::All | AlertTypeFilter::AnomalyDetection
) && let Ok(configs) = crate::service::anomaly_detection::list_configs(
&org_id,
folder_slug.as_deref(),
name_substring.as_deref(),
)
.await
{
list.extend(configs.iter().filter_map(anomaly_config_to_list_item));
}
// Apply pagination to the combined list (regular alerts + anomaly configs).
#[cfg(feature = "enterprise")]
let list = if let Some((page_size, page_idx)) = page_size_and_idx {
let start = (page_idx * page_size) as usize;
list.into_iter()
.skip(start)
.take(page_size as usize)
.collect()
} else {
list
};
MetaHttpResponse::json(ListAlertsResponseBody { list })
}
/// EnableAlert
@@ -535,6 +959,40 @@ pub async fn enable_alert(
};
MetaHttpResponse::json(resp_body)
}
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::not_found(format!("alert {alert_id} not found"))
}
#[cfg(feature = "enterprise")]
{
// Fall back to anomaly detection config
use crate::handler::http::request::anomaly_detection::UpdateAnomalyConfigRequest;
let req = UpdateAnomalyConfigRequest {
enabled: Some(should_enable),
..Default::default()
};
match crate::service::anomaly_detection::update_config(
&org_id,
&alert_id.to_string(),
req,
)
.await
{
Ok(_) => MetaHttpResponse::json(EnableAlertResponseBody {
enabled: should_enable,
}),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
MetaHttpResponse::not_found(e.to_string())
} else {
MetaHttpResponse::internal_error(e.to_string())
}
}
}
}
}
Err(e) => e.into(),
}
}
@@ -595,6 +1053,36 @@ pub async fn enable_alert_bulk(
Ok(_) => {
successful.push(id);
}
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
unsuccessful.push(id);
err = Some(format!("alert {id} not found"));
}
#[cfg(feature = "enterprise")]
{
// Fall back to anomaly detection config
use crate::handler::http::request::anomaly_detection::UpdateAnomalyConfigRequest;
let req = UpdateAnomalyConfigRequest {
enabled: Some(should_enable),
..Default::default()
};
match crate::service::anomaly_detection::update_config(
&org_id,
&id.to_string(),
req,
)
.await
{
Ok(_) => successful.push(id),
Err(e) => {
log::error!("error in enabling anomaly config {id} : {e}");
unsuccessful.push(id);
err = Some(e.to_string());
}
}
}
}
Err(e) => {
log::error!("error in enabling alert {id} : {e}");
unsuccessful.push(id);
@@ -646,10 +1134,102 @@ pub async fn trigger_alert(Path((org_id, alert_id)): Path<(String, String)>) ->
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
match alert::trigger_by_id(client, &org_id, alert_id).await {
Ok(_) => MetaHttpResponse::ok("Alert triggered"),
Err(AlertError::AlertNotFound) => {
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::not_found(format!("alert {alert_id} not found"))
}
#[cfg(feature = "enterprise")]
{
// Fall back to anomaly detection — trigger a detection run
match crate::service::anomaly_detection::detect_anomalies(
&org_id,
&alert_id.to_string(),
)
.await
{
Ok(_) => MetaHttpResponse::ok("Detection triggered"),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
MetaHttpResponse::not_found(e.to_string())
} else {
MetaHttpResponse::internal_error(e.to_string())
}
}
}
}
}
Err(e) => e.into(),
}
}
/// RetrainAlert
#[utoipa::path(
patch,
path = "/v2/{org_id}/alerts/{alert_id}/retrain",
context_path = "/api",
tag = "Alerts",
operation_id = "RetrainAlert",
summary = "Trigger retraining for an anomaly detection alert",
description = "Triggers a model retrain for an anomaly detection alert. Returns 400 if called on a non-anomaly alert type.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("alert_id" = String, Path, description = "Anomaly detection alert ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Not an anomaly detection alert", content_type = "application/json", body = ()),
(status = 404, description = "NotFound", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Alerts", "operation": "update"})),
)
)]
pub async fn retrain_alert(Path((org_id, alert_id)): Path<(String, String)>) -> Response {
#[cfg(feature = "enterprise")]
let alert_id_str = alert_id.clone();
let alert_id = match Ksuid::from_str(&alert_id) {
Ok(id) => id,
Err(_) => {
return MetaHttpResponse::not_found(format!("invalid alert id {alert_id}"));
}
};
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
// Check if this is a regular alert — if so, return 400
match alert::get_by_id(client, &org_id, alert_id).await {
Ok(_) => {
return MetaHttpResponse::bad_request(
"retrain is only supported for anomaly detection alerts",
);
}
Err(AlertError::AlertNotFound) => {
// Expected — fall through to anomaly detection
}
Err(e) => return e.into(),
}
#[cfg(not(feature = "enterprise"))]
{
MetaHttpResponse::bad_request("retrain is only supported for anomaly detection alerts")
}
#[cfg(feature = "enterprise")]
match crate::service::anomaly_detection::train_model(&org_id, &alert_id_str).await {
Ok(_) => MetaHttpResponse::ok("Retraining triggered"),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
MetaHttpResponse::not_found(e.to_string())
} else {
MetaHttpResponse::internal_error(e.to_string())
}
}
}
}
/// MoveAlerts
#[utoipa::path(
patch,
@@ -683,25 +1263,55 @@ pub async fn move_alerts(
Json(req_body): Json<MoveAlertsRequestBody>,
) -> Response {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
match alert::move_to_folder(
client,
&org_id,
&req_body.alert_ids,
&req_body.dst_folder_id,
&user_email.user_id,
)
.await
{
Ok(_) => {
let message = if req_body.alert_ids.len() == 1 {
"Alert moved"
let total_ids = req_body.alert_ids.len() + req_body.anomaly_config_ids.len();
// anomaly_config_ids is now a required Vec (defaults to empty), so no
// per-ID DB lookups are needed to classify IDs.
let alert_ids: Vec<Ksuid> = req_body.alert_ids;
#[cfg(feature = "enterprise")]
let anomaly_ids: Vec<Ksuid> = req_body.anomaly_config_ids;
// Move anomaly configs first (enterprise only) so that if this fails,
// regular alerts have not yet been relocated (reduces partial-move risk).
#[cfg(feature = "enterprise")]
for id in anomaly_ids {
use crate::handler::http::request::anomaly_detection::UpdateAnomalyConfigRequest;
let req = UpdateAnomalyConfigRequest {
folder_id: Some(req_body.dst_folder_id.clone()),
..Default::default()
};
if let Err(e) =
crate::service::anomaly_detection::update_config(&org_id, &id.to_string(), req).await
{
let msg = e.to_string().to_lowercase();
if msg.contains("not found") {
return MetaHttpResponse::not_found(e.to_string());
} else {
"Alerts moved"
};
MetaHttpResponse::ok(message)
return MetaHttpResponse::internal_error(e.to_string());
}
}
Err(e) => e.into(),
}
// Move regular alerts in one batch
if !alert_ids.is_empty()
&& let Err(e) = alert::move_to_folder(
client,
&org_id,
&alert_ids,
&req_body.dst_folder_id,
&user_email.user_id,
)
.await
{
return e.into();
}
let message = if total_ids == 1 {
"Alert moved"
} else {
"Alerts moved"
};
MetaHttpResponse::ok(message)
}
/// GenerateSql
@@ -47,7 +47,7 @@ use crate::{
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id))]
pub async fn list_configs(Path(org_id): Path<String>) -> Response {
match anomaly_service::list_configs(&org_id).await {
match anomaly_service::list_configs(&org_id, None, None).await {
Ok(configs) => MetaHttpResponse::json(configs),
Err(e) => {
tracing::error!("Failed to list anomaly configs: {}", e);
@@ -376,7 +376,12 @@ pub struct CreateAnomalyConfigRequest {
pub query_mode: String, // "filters" or "custom_sql"
pub filters: Option<serde_json::Value>,
pub custom_sql: Option<String>,
pub detection_function: String, // "count(*)", "avg(field)", etc.
/// Aggregate function name: "count", "avg", "sum", "min", "max", "p50", "p95", "p99".
/// For backwards compatibility, also accepts the combined form "avg(field)".
pub detection_function: String,
/// Field to aggregate (required for avg/sum/min/max/pXX, ignored for count).
/// Combined with `detection_function` into "avg(field)" before saving.
pub detection_function_field: Option<String>,
/// SQL histogram bucket size, e.g. "5m", "1h".
pub histogram_interval: String,
/// How often the detection job fires, e.g. "1h", "30m".
@@ -397,15 +402,25 @@ pub struct CreateAnomalyConfigRequest {
pub rcf_tree_size: Option<i32>,
pub rcf_shingle_size: Option<i32>,
pub alert_enabled: Option<bool>,
pub alert_destination_id: Option<String>,
#[serde(default)]
pub alert_destinations: Vec<String>,
pub enabled: Option<bool>,
/// Folder to place this config in. Resolved to the org default folder if absent.
pub folder_id: Option<String>,
/// Owner username. Defaults to the requesting user if absent.
pub owner: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct UpdateAnomalyConfigRequest {
pub name: Option<String>,
pub description: Option<String>,
pub query_mode: Option<String>,
pub filters: Option<serde_json::Value>,
pub custom_sql: Option<String>,
pub detection_function: Option<String>,
/// Field to aggregate (required for avg/sum/min/max/pXX).
pub detection_function_field: Option<String>,
/// SQL histogram bucket size. Changing this requires retraining.
pub histogram_interval: Option<String>,
/// How often the detection job fires.
@@ -420,8 +435,10 @@ pub struct UpdateAnomalyConfigRequest {
/// How often to retrain the model (in days). `0` means never retrain automatically.
pub retrain_interval_days: Option<i32>,
pub alert_enabled: Option<bool>,
pub alert_destination_id: Option<String>,
pub alert_destinations: Option<Vec<String>>,
pub enabled: Option<bool>,
pub folder_id: Option<String>,
pub owner: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
+3 -1
View File
@@ -643,11 +643,13 @@ pub fn service_routes() -> Router {
// Alerts (v2)
.route("/v2/{org_id}/alerts", get(alerts::list_alerts).post(alerts::create_alert))
.route("/v2/{org_id}/alerts/{alert_id}", get(alerts::get_alert).put(alerts::update_alert).delete(alerts::delete_alert))
.route("/v2/{org_id}/alerts/{alert_id}/export", get(alerts::export_alert))
.route("/v2/{org_id}/alerts/{alert_id}/export", post(alerts::export_alert))
.route("/v2/{org_id}/alerts/bulk", delete(alerts::delete_alert_bulk))
.route("/v2/{org_id}/alerts/{alert_id}/enable", patch(alerts::enable_alert))
.route("/v2/{org_id}/alerts/bulk/enable", post(alerts::enable_alert_bulk))
.route("/v2/{org_id}/alerts/{alert_id}/trigger", patch(alerts::trigger_alert))
.route("/v2/{org_id}/alerts/{alert_id}/retrain", patch(alerts::retrain_alert))
.route("/v2/{org_id}/alerts/{alert_id}/clone", post(alerts::clone_alert))
.route("/v2/{org_id}/alerts/generate_sql", post(alerts::generate_sql))
.route("/v2/{org_id}/alerts/move", patch(alerts::move_alerts))
.route("/v2/{org_id}/alerts/history", get(alerts::history::get_alert_history))
+2
View File
@@ -129,6 +129,8 @@ use crate::{common::meta, handler::http::request};
request::alerts::enable_alert,
request::alerts::enable_alert_bulk,
request::alerts::trigger_alert,
request::alerts::retrain_alert,
request::alerts::clone_alert,
request::alerts::generate_sql,
request::alerts::move_alerts,
request::alerts::history::get_alert_history,
@@ -36,7 +36,10 @@ pub struct Model {
pub rcf_tree_size: i32,
pub rcf_shingle_size: i32,
pub alert_enabled: bool,
pub alert_destination_id: Option<String>,
pub alert_destinations: Option<Json>,
/// Folder PK (folders.id KSUID). Stores the same FK as the alerts table.
pub folder_id: String,
pub owner: Option<String>,
/// 0=waiting, 1=active, 2=training, 3=failed, 4=disabled
pub status: i32,
pub retries: i32,
+42
View File
@@ -159,6 +159,48 @@ pub async fn delete(
Ok(())
}
/// Returns the primary-key `id` of the folder identified by its name and type.
///
/// Service-layer code that needs to store a proper FK (consistent with the alerts
/// table, which stores `folders.id` not `folders.folder_id`) should use this instead
/// of the `pub(crate)` `get_model` helper.
pub async fn get_pk_by_name(
org_id: &str,
name: &str,
folder_type: FolderType,
) -> Result<Option<String>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
Ok(get_model(client, org_id, name, folder_type)
.await?
.map(|m| m.id))
}
/// Returns the folder name (`folder_id` column) for the given primary-key `id`.
///
/// Used to translate the stored PK back to the user-visible name when building
/// API responses for anomaly detection configs.
pub async fn get_name_by_pk(pk: &str) -> Result<Option<String>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
Ok(Entity::find_by_id(pk)
.one(client)
.await?
.map(|m| m.folder_id))
}
/// Returns `(folder name, display name)` for the given primary-key `id`.
///
/// Both values are needed when building API list responses for anomaly configs
/// (mirrors the `folder_id` + `folder_name` fields returned for regular alerts).
pub async fn get_name_and_display_name_by_pk(
pk: &str,
) -> Result<Option<(String, String)>, errors::Error> {
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
Ok(Entity::find_by_id(pk)
.one(client)
.await?
.map(|m| (m.folder_id, m.name)))
}
/// Gets a folder ORM entity by its `folder_id`.
pub(crate) async fn get_model<C: ConnectionTrait>(
db: &C,
@@ -0,0 +1,269 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Upgrades anomaly_detection_config from the P1 schema to the P2 schema.
//!
//! m20260310 creates the table with `alert_destination_id` (varchar). This migration:
//! - Adds `folder_id` (varchar 256): backfilled from the "default" Alerts folder for each org. If
//! an org has anomaly configs but no default Alerts folder, one is auto-created so the backfill
//! always succeeds and folder_id can be set NOT NULL.
//! - Adds `owner` (varchar 256): nullable attributed owner.
//! - Adds `alert_destinations` (jsonb): wraps the old `alert_destination_id` value in a JSON array,
//! then drops the old column.
//!
//! All three steps are guarded by column-existence checks so the migration is idempotent.
use sea_orm::{ConnectionTrait, Statement};
use sea_orm_migration::prelude::*;
use svix_ksuid::{Ksuid, KsuidLike};
const ANOMALY_CONFIG_FOLDER_ID_IDX: &str = "idx_anomaly_config_folder_id";
#[derive(DeriveMigrationName)]
pub struct Migration;
/// Returns `true` when the named column exists in `anomaly_detection_config`.
async fn column_exists(manager: &SchemaManager<'_>, column: &str) -> Result<bool, DbErr> {
let db_backend = manager.get_database_backend();
let sql = match db_backend {
sea_orm::DbBackend::Sqlite => format!(
"SELECT COUNT(*) AS cnt FROM pragma_table_info('anomaly_detection_config') \
WHERE name = '{column}'"
),
sea_orm::DbBackend::Postgres => format!(
"SELECT COUNT(*) AS cnt FROM information_schema.columns \
WHERE table_name = 'anomaly_detection_config' AND column_name = '{column}'"
),
sea_orm::DbBackend::MySql => format!(
"SELECT COUNT(*) AS cnt FROM information_schema.columns \
WHERE table_name = 'anomaly_detection_config' \
AND column_name = '{column}' AND table_schema = DATABASE()"
),
};
let row = manager
.get_connection()
.query_one(Statement::from_string(db_backend, sql))
.await?;
Ok(row
.map(|r| r.try_get::<i64>("", "cnt").unwrap_or(0) > 0)
.unwrap_or(false))
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db_backend = manager.get_database_backend();
// --- Add folder_id (always) ---
if !column_exists(manager, "folder_id").await? {
manager
.alter_table(
Table::alter()
.table(AnomalyDetectionConfig::Table)
.add_column(
ColumnDef::new(AnomalyDetectionConfig::FolderId)
.string_len(256)
.null(),
)
.to_owned(),
)
.await?;
}
// --- Add owner (always) ---
if !column_exists(manager, "owner").await? {
manager
.alter_table(
Table::alter()
.table(AnomalyDetectionConfig::Table)
.add_column(
ColumnDef::new(AnomalyDetectionConfig::Owner)
.string_len(256)
.null(),
)
.to_owned(),
)
.await?;
}
// --- Migrate alert_destination_id → alert_destinations ---
let has_old_col = column_exists(manager, "alert_destination_id").await?;
let has_new_col = column_exists(manager, "alert_destinations").await?;
if !has_new_col {
manager
.alter_table(
Table::alter()
.table(AnomalyDetectionConfig::Table)
.add_column(
ColumnDef::new(AnomalyDetectionConfig::AlertDestinations)
.json_binary()
.null(),
)
.to_owned(),
)
.await?;
}
if has_old_col {
// Copy old single-value column into a JSON array, then drop it.
let migrate_sql = match db_backend {
sea_orm::DbBackend::Sqlite => {
"UPDATE anomaly_detection_config \
SET alert_destinations = json_array(alert_destination_id) \
WHERE alert_destination_id IS NOT NULL AND alert_destinations IS NULL"
}
sea_orm::DbBackend::Postgres => {
"UPDATE anomaly_detection_config \
SET alert_destinations = json_build_array(alert_destination_id) \
WHERE alert_destination_id IS NOT NULL AND alert_destinations IS NULL"
}
sea_orm::DbBackend::MySql => {
"UPDATE anomaly_detection_config \
SET alert_destinations = JSON_ARRAY(alert_destination_id) \
WHERE alert_destination_id IS NOT NULL AND alert_destinations IS NULL"
}
};
manager
.get_connection()
.execute_unprepared(migrate_sql)
.await?;
let drop_sql = match db_backend {
sea_orm::DbBackend::Sqlite => {
"ALTER TABLE anomaly_detection_config DROP COLUMN alert_destination_id"
}
sea_orm::DbBackend::Postgres | sea_orm::DbBackend::MySql => {
"ALTER TABLE anomaly_detection_config \
DROP COLUMN IF EXISTS alert_destination_id"
}
};
manager
.get_connection()
.execute_unprepared(drop_sql)
.await?;
}
// Ensure every org with anomaly configs has a default Alerts folder (type=1).
// If not, auto-create one so the backfill below never leaves a NULL.
let db = manager.get_connection();
let orgs_rows = db
.query_all(Statement::from_string(
db_backend,
"SELECT DISTINCT org_id FROM anomaly_detection_config WHERE folder_id IS NULL",
))
.await?;
for row in orgs_rows {
let org_id: String = row.try_get("", "org_id")?;
// Escape single quotes in org_id to prevent SQL issues.
let org_id_escaped = org_id.replace('\'', "''");
let check_sql = format!(
"SELECT id FROM folders \
WHERE org = '{org_id_escaped}' \
AND folder_id = 'default' AND type = 1 LIMIT 1"
);
let existing = db
.query_one(Statement::from_string(db_backend, check_sql))
.await?;
if existing.is_none() {
let ksuid = Ksuid::new(None, None).to_string();
let insert_sql = format!(
"INSERT INTO folders (id, org, folder_id, name, type) \
VALUES ('{ksuid}', '{org_id_escaped}', 'default', 'Default', 1)"
);
db.execute_unprepared(&insert_sql).await?;
}
}
// Backfill folder_id from the "default" Alerts folder for each org.
manager
.get_connection()
.execute_unprepared(
"UPDATE anomaly_detection_config \
SET folder_id = (\
SELECT id FROM folders \
WHERE folders.org = anomaly_detection_config.org_id \
AND folders.folder_id = 'default' \
AND folders.type = 1\
) \
WHERE folder_id IS NULL",
)
.await?;
// Index on folder_id for fast folder-based filtering.
manager
.create_index(
Index::create()
.if_not_exists()
.name(ANOMALY_CONFIG_FOLDER_ID_IDX)
.table(AnomalyDetectionConfig::Table)
.col(AnomalyDetectionConfig::FolderId)
.to_owned(),
)
.await?;
// Make folder_id NOT NULL on databases that support it in-place.
// SQLite: the backfill is sufficient for dev environments.
match db_backend {
sea_orm::DbBackend::Postgres => {
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE anomaly_detection_config \
ALTER COLUMN folder_id SET NOT NULL",
)
.await?;
}
sea_orm::DbBackend::MySql => {
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE anomaly_detection_config \
MODIFY COLUMN folder_id VARCHAR(256) NOT NULL",
)
.await?;
}
sea_orm::DbBackend::Sqlite => {}
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(Index::drop().name(ANOMALY_CONFIG_FOLDER_ID_IDX).to_owned())
.await?;
manager
.alter_table(
Table::alter()
.table(AnomalyDetectionConfig::Table)
.drop_column(AnomalyDetectionConfig::FolderId)
.drop_column(AnomalyDetectionConfig::Owner)
.to_owned(),
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum AnomalyDetectionConfig {
Table,
FolderId,
Owner,
AlertDestinations,
}
+2
View File
@@ -99,6 +99,7 @@ mod m20260227_000001_add_alert_creates_incident;
mod m20260305_000001_create_trial_quota_usage_table;
mod m20260310_000001_create_anomaly_detection_config_table;
mod m20260310_000002_create_anomaly_detection_models_table;
mod m20260317_000001_add_anomaly_detection_config_columns;
pub struct Migrator;
@@ -187,6 +188,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260310_000001_create_anomaly_detection_config_table::Migration),
Box::new(m20260310_000002_create_anomaly_detection_models_table::Migration),
Box::new(m20260305_000001_create_trial_quota_usage_table::Migration),
Box::new(m20260317_000001_add_anomaly_detection_config_columns::Migration),
]
}
}
+15 -6
View File
@@ -396,16 +396,21 @@ pub async fn init() -> Result<(), anyhow::Error> {
tokio::task::spawn(service_graph::run());
#[cfg(feature = "enterprise")]
tokio::task::spawn(incidents::run());
// Register anomaly detection callbacks on every node. The HTTP handlers
// for /retrain and /trigger can land on any node (querier, ingester, etc.),
// not just the alert_manager, so all nodes need the callbacks available.
#[cfg(feature = "enterprise")]
if LOCAL_NODE.is_alert_manager() {
// Register the OSS search layer as the query executor for anomaly detection.
// Must happen before start_scheduler() so the training/detection jobs can
// call execute_anomaly_query() via the registered callback.
{
o2_enterprise::enterprise::anomaly_detection::query_executor::register_query_executor(
|org_id, sql, start, end, cfg_id| {
|org_id, sql, start, end, cfg_id, stream_type| {
Box::pin(async move {
crate::service::anomaly_detection::execute_anomaly_query(
&org_id, &sql, start, end, &cfg_id,
&org_id,
&sql,
start,
end,
&cfg_id,
&stream_type,
)
.await
})
@@ -502,7 +507,11 @@ pub async fn init() -> Result<(), anyhow::Error> {
})
},
);
}
// The scheduler and startup recovery only run on alert_manager nodes.
#[cfg(feature = "enterprise")]
if LOCAL_NODE.is_alert_manager() {
// Ensure every enabled anomaly config has a live detection trigger after restart.
// Handles: trigger row missing, or stuck in Processing from a previous crash.
crate::service::anomaly_detection::recover_detection_triggers_on_startup().await;
+22 -1
View File
@@ -192,7 +192,7 @@ async fn handle_anomaly_detection_triggers(
next_run_at: next_run,
is_realtime: false,
is_silenced: false,
status: trigger_status,
status: trigger_status.clone(),
start_time: run_start_us,
end_time: run_end_us,
retries: trigger.retries,
@@ -212,6 +212,27 @@ async fn handle_anomaly_detection_triggers(
trigger.data = td.to_json_string();
}
// If detection succeeded and the config is trained but status is not Active
// (e.g. stuck at Waiting after a manual retrain request that hasn't been
// processed by the training scheduler yet, or processed but status not yet
// flipped), move it to Active so the UI reflects the real state.
#[cfg(feature = "enterprise")]
if trigger_status == TriggerDataStatus::Completed && config.is_trained {
use o2_enterprise::enterprise::anomaly_detection::types::Status as AnomalyStatus;
if config.status != AnomalyStatus::Active.to_i32() {
use infra::table::entity::anomaly_detection_config;
use sea_orm::{ActiveModelTrait, Set};
let mut active: anomaly_detection_config::ActiveModel = config.into();
active.status = Set(AnomalyStatus::Active.to_i32());
active.updated_at = Set(run_end_us);
if let Err(e) = active.update(db).await {
log::warn!(
"[anomaly_detection] failed to reset status to Active for {anomaly_id}: {e}"
);
}
}
}
// Reschedule.
trigger.next_run_at = next_run;
trigger.status = db::scheduler::TriggerStatus::Waiting;
+429 -97
View File
@@ -15,6 +15,15 @@
use anyhow::Result;
use chrono::Utc;
use config::{
meta::{
destinations::{DestinationType, Module},
folder::{DEFAULT_FOLDER, Folder, FolderType},
stream::StreamType,
triggers::{ScheduledTriggerData, TriggerModule},
},
utils::time::now_micros,
};
use infra::{db::ORM_CLIENT, table::entity::anomaly_detection_config};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, QueryFilter,
@@ -22,10 +31,65 @@ use sea_orm::{
};
use svix_ksuid::KsuidLike;
use crate::handler::http::request::anomaly_detection::{
CreateAnomalyConfigRequest, UpdateAnomalyConfigRequest,
use crate::{
handler::http::request::anomaly_detection::{
CreateAnomalyConfigRequest, UpdateAnomalyConfigRequest,
},
service::{alerts::destinations, search},
};
/// Resolve a folder name (e.g. "default") to the PK stored in `folders.id`.
///
/// Anomaly detection configs store the same FK as the alerts table — the KSUID primary
/// key of the folder row, NOT the human-readable name. This helper performs that
/// lookup and is called whenever we write a folder_id value to the DB.
///
/// If the name is "default" and the folder does not yet exist it is auto-created,
/// matching the behaviour of `alert::create` which calls `create_default_alerts_folder`.
async fn resolve_folder_pk(org_id: &str, name: &str) -> Option<String> {
let pk = infra::table::folders::get_pk_by_name(org_id, name, FolderType::Alerts)
.await
.ok()
.flatten();
if pk.is_some() {
return pk;
}
// Auto-create the default Alerts folder on first use, same as alert::create does.
if name == DEFAULT_FOLDER {
let folder = Folder {
folder_id: DEFAULT_FOLDER.to_owned(),
name: "default".to_owned(),
description: "default".to_owned(),
};
if crate::service::folders::save_folder(org_id, folder, FolderType::Alerts, true)
.await
.is_ok()
{
return infra::table::folders::get_pk_by_name(org_id, name, FolderType::Alerts)
.await
.ok()
.flatten();
}
}
None
}
/// Translate a stored folder PK back to the user-visible name for API responses.
/// Falls back to "default" if the PK cannot be resolved (e.g. folder was deleted).
async fn pk_to_name(pk: Option<&str>) -> String {
match pk {
None => "default".to_string(),
Some(pk) => infra::table::folders::get_name_by_pk(pk)
.await
.ok()
.flatten()
.unwrap_or_else(|| "default".to_string()),
}
}
/// Convert an integer status code to its human-readable string label.
/// This is applied when serializing configs to API responses so the UI
/// continues to receive "waiting"/"active"/"training"/"failed"/"disabled".
@@ -56,9 +120,11 @@ fn model_to_api_json(mut val: serde_json::Value) -> serde_json::Value {
/// Waiting→Processing, i.e. the actual trigger-fired wall-clock time)
/// - `last_anomaly_detected_at` ← `ScheduledTriggerData.last_satisfied_at` stored in
/// `trigger.data` JSON by the handler when `anomaly_count > 0`
pub async fn list_configs(org_id: &str) -> Result<Vec<serde_json::Value>> {
use config::meta::triggers::{ScheduledTriggerData, TriggerModule};
pub async fn list_configs(
org_id: &str,
folder_name: Option<&str>,
name_substring: Option<&str>,
) -> Result<Vec<serde_json::Value>> {
let db = ORM_CLIENT
.get()
.ok_or_else(|| anyhow::anyhow!("Database not initialized"))?;
@@ -78,11 +144,75 @@ pub async fn list_configs(org_id: &str) -> Result<Vec<serde_json::Value>> {
.map(|t| (t.module_key.clone(), t))
.collect();
// Resolve the folder name filter to a PK so we can filter the in-memory list.
// (Storing PKs in the DB means we must compare against the PK, not the name.)
let folder_pk_filter: Option<String> = if let Some(name) = folder_name {
infra::table::folders::get_pk_by_name(
org_id,
name,
config::meta::folder::FolderType::Alerts,
)
.await
.ok()
.flatten()
} else {
None
};
// Filter by folder if a name was provided. When the PK lookup returns None
// (folder not found), no configs should match — return empty.
let configs: Vec<_> = if folder_name.is_some() {
match &folder_pk_filter {
Some(pk) => configs.into_iter().filter(|m| m.folder_id == *pk).collect(),
None => vec![],
}
} else {
configs
};
// Filter by name substring (case-insensitive) when provided.
let configs: Vec<_> = if let Some(substr) = name_substring.filter(|s| !s.is_empty()) {
let lower = substr.to_lowercase();
configs
.into_iter()
.filter(|m| m.name.to_lowercase().contains(&lower))
.collect()
} else {
configs
};
// Collect unique folder PKs so we can batch-resolve them to name + display name.
let unique_pks: std::collections::HashSet<String> =
configs.iter().map(|m| m.folder_id.clone()).collect();
// pk → (name, display_name)
let mut pk_to_folder_map: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
for pk in unique_pks {
if let Ok(Some(info)) = infra::table::folders::get_name_and_display_name_by_pk(&pk).await {
pk_to_folder_map.insert(pk, info);
}
}
let result: Vec<serde_json::Value> = configs
.into_iter()
.map(|model| {
let anomaly_id = model.anomaly_id.clone();
let (folder_name, folder_display_name) = pk_to_folder_map
.get(&model.folder_id)
.cloned()
.unwrap_or_else(|| ("default".to_string(), String::new()));
let mut val = model_to_api_json(serde_json::to_value(model).unwrap_or_default());
// Replace stored PK with the user-visible name and add display name.
if let Some(obj) = val.as_object_mut() {
obj.insert(
"folder_id".to_string(),
serde_json::Value::String(folder_name),
);
obj.insert(
"folder_name".to_string(),
serde_json::Value::String(folder_display_name),
);
}
if let Some(obj) = val.as_object_mut()
&& let Some(trigger) = trigger_map.get(&anomaly_id)
{
@@ -122,7 +252,17 @@ pub async fn get_config(org_id: &str, anomaly_id: &str) -> Result<Option<serde_j
.one(db)
.await?;
Ok(config.map(|model| model_to_api_json(serde_json::to_value(model).unwrap_or_default())))
match config {
None => Ok(None),
Some(model) => {
let name = pk_to_name(Some(&model.folder_id)).await;
let mut val = model_to_api_json(serde_json::to_value(model).unwrap_or_default());
if let Some(obj) = val.as_object_mut() {
obj.insert("folder_id".to_string(), serde_json::Value::String(name));
}
Ok(Some(val))
}
}
}
/// Create a new anomaly detection configuration
@@ -140,6 +280,16 @@ pub async fn create_config(
let anomaly_id = svix_ksuid::Ksuid::new(None, None).to_string();
let now_us = Utc::now().timestamp_micros();
// Resolve the folder name to the FK (folders.id PK), consistent with the alerts table.
let folder_name = req
.folder_id
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("default");
let folder_pk = resolve_folder_pk(org_id, folder_name)
.await
.ok_or_else(|| anyhow::anyhow!("Folder '{}' not found", folder_name))?;
let new_config = anomaly_detection_config::ActiveModel {
anomaly_id: Set(anomaly_id.clone()),
org_id: Set(org_id.to_string()),
@@ -151,7 +301,10 @@ pub async fn create_config(
query_mode: Set(req.query_mode.clone()),
filters: Set(req.filters.clone()),
custom_sql: Set(req.custom_sql.clone()),
detection_function: Set(req.detection_function.clone()),
detection_function: Set(combine_detection_fn(
&req.detection_function,
req.detection_function_field.as_deref(),
)),
histogram_interval: Set(req.histogram_interval.clone()),
schedule_interval: Set(req.schedule_interval.clone()),
detection_window_seconds: Set(req.detection_window_seconds),
@@ -184,7 +337,11 @@ pub async fn create_config(
.rcf_shingle_size as i32,
)),
alert_enabled: Set(req.alert_enabled.unwrap_or(true)),
alert_destination_id: Set(req.alert_destination_id.clone()),
alert_destinations: Set(Some(
serde_json::to_value(&req.alert_destinations).unwrap_or(serde_json::json!([])),
)),
folder_id: Set(folder_pk),
owner: Set(req.owner.clone()),
status: Set(0i32), // 0 = waiting
retries: Set(0),
last_updated: Set(now_us),
@@ -196,12 +353,12 @@ pub async fn create_config(
};
let result = new_config.insert(db).await?;
let folder_name_owned = folder_name.to_string();
// Register a detection trigger in the shared scheduler so it is driven by the
// same infrastructure as alerts. The handler will check `is_trained` and skip
// until training is complete.
{
use config::{meta::triggers::TriggerModule, utils::time::now_micros};
let trigger = crate::service::db::scheduler::Trigger {
org: org_id.to_string(),
module: TriggerModule::AnomalyDetection,
@@ -237,7 +394,14 @@ pub async fn create_config(
});
}
Ok(serde_json::to_value(result)?)
let mut val = serde_json::to_value(result)?;
if let Some(obj) = val.as_object_mut() {
obj.insert(
"folder_id".to_string(),
serde_json::Value::String(folder_name_owned),
);
}
Ok(val)
}
/// Update an existing anomaly detection configuration
@@ -267,7 +431,7 @@ pub async fn update_config(
let mut reset_trigger_after_save = false;
if let Some(enabled) = req.enabled {
active_model.enabled = Set(enabled);
use config::meta::triggers::TriggerModule;
if enabled {
// Defer the push until after active_model.update() so the scheduler
// always reads enabled=true from the DB when it picks up the trigger.
@@ -275,18 +439,11 @@ pub async fn update_config(
// hit the skip path, and delay the next run by 60 s.
push_trigger_after_save = true;
} else {
// Delete eagerly — no ordering requirement for disable.
if let Err(e) = crate::service::db::scheduler::delete(
org_id,
TriggerModule::AnomalyDetection,
anomaly_id,
)
.await
{
log::warn!(
"[anomaly_detection {anomaly_id}] failed to delete trigger on disable: {e}"
);
}
// Do NOT delete the trigger on disable — the scheduler handler already
// skips and reschedules disabled configs (see handlers.rs). Keeping the
// trigger row preserves last_detection_run (trigger.start_time) and
// last_anomaly_detected_at (trigger.data.last_satisfied_at) so they
// remain visible in the UI after a pause + refresh.
}
}
if let Some(name) = req.name {
@@ -295,8 +452,32 @@ pub async fn update_config(
if let Some(description) = req.description {
active_model.description = Set(Some(description));
}
if let Some(query_mode) = req.query_mode {
// Clear the opposing field so the DB doesn't hold stale data from the
// previous mode. query_builder selects the path based on query_mode alone,
// but stale fields in the DB are confusing when inspecting records.
match query_mode.as_str() {
"custom_sql" => {
active_model.filters = Set(None);
}
"filters" => {
active_model.custom_sql = Set(None);
}
_ => {}
}
active_model.query_mode = Set(query_mode);
}
if let Some(filters) = req.filters {
active_model.filters = Set(Some(filters));
}
if let Some(custom_sql) = req.custom_sql {
active_model.custom_sql = Set(Some(custom_sql));
}
if let Some(detection_function) = req.detection_function {
active_model.detection_function = Set(detection_function);
active_model.detection_function = Set(combine_detection_fn(
&detection_function,
req.detection_function_field.as_deref(),
));
}
if let Some(histogram_interval) = req.histogram_interval {
active_model.histogram_interval = Set(histogram_interval);
@@ -322,8 +503,19 @@ pub async fn update_config(
if let Some(alert_enabled) = req.alert_enabled {
active_model.alert_enabled = Set(alert_enabled);
}
if let Some(alert_destination_id) = req.alert_destination_id {
active_model.alert_destination_id = Set(Some(alert_destination_id));
if let Some(destinations) = req.alert_destinations {
active_model.alert_destinations = Set(Some(
serde_json::to_value(&destinations).unwrap_or(serde_json::json!([])),
));
}
if let Some(folder_id_str) = req.folder_id {
let pk = resolve_folder_pk(org_id, &folder_id_str)
.await
.ok_or_else(|| anyhow::anyhow!("Folder '{}' not found", folder_id_str))?;
active_model.folder_id = Set(pk);
}
if let Some(owner) = req.owner {
active_model.owner = Set(Some(owner));
}
active_model.updated_at = Set(Utc::now().timestamp_micros());
@@ -333,7 +525,6 @@ pub async fn update_config(
// Push the trigger AFTER the DB save so the scheduler always sees enabled=true
// when it picks up the newly inserted trigger row.
if push_trigger_after_save {
use config::{meta::triggers::TriggerModule, utils::time::now_micros};
let trigger = crate::service::db::scheduler::Trigger {
org: org_id.to_string(),
module: TriggerModule::AnomalyDetection,
@@ -354,7 +545,6 @@ pub async fn update_config(
// so the new cadence takes effect immediately (push() alone would be a no-op on an
// existing row due to ON CONFLICT DO NOTHING). Only applicable for enabled configs.
if reset_trigger_after_save && updated.enabled {
use config::{meta::triggers::TriggerModule, utils::time::now_micros};
let now = now_micros();
match crate::service::db::scheduler::get(
org_id,
@@ -393,7 +583,12 @@ pub async fn update_config(
}
}
Ok(serde_json::to_value(updated)?)
let name = pk_to_name(Some(&updated.folder_id)).await;
let mut val = serde_json::to_value(updated)?;
if let Some(obj) = val.as_object_mut() {
obj.insert("folder_id".to_string(), serde_json::Value::String(name));
}
Ok(val)
}
/// Delete an anomaly detection configuration
@@ -412,7 +607,6 @@ pub async fn delete_config(org_id: &str, anomaly_id: &str) -> Result<()> {
// Remove the detection trigger from the shared scheduler.
{
use config::meta::triggers::TriggerModule;
if let Err(e) = crate::service::db::scheduler::delete(
org_id,
TriggerModule::AnomalyDetection,
@@ -424,9 +618,118 @@ pub async fn delete_config(org_id: &str, anomaly_id: &str) -> Result<()> {
}
}
// Delete stored model files and clear cache (enterprise only).
#[cfg(feature = "enterprise")]
o2_enterprise::enterprise::anomaly_detection::delete_config_models(org_id, anomaly_id).await;
Ok(())
}
/// Clone an anomaly detection configuration.
///
/// Copies all configuration fields from the source config to a new config with a new
/// KSUID. Runtime/training state is reset: `is_trained=false`, all training timestamps
/// cleared, `status=0` (waiting), and counters zeroed.
///
/// If `new_name` is provided it is used as the cloned config's name; otherwise the
/// source name is suffixed with `"_copy"`.
///
/// If `folder_id` is provided the clone is placed in that folder; otherwise the same
/// folder as the source is used.
pub async fn clone_config(
org_id: &str,
anomaly_id: &str,
new_name: Option<String>,
folder_id: Option<String>,
) -> Result<serde_json::Value> {
let db = ORM_CLIENT
.get()
.ok_or_else(|| anyhow::anyhow!("Database not initialized"))?;
let src = anomaly_detection_config::Entity::find_by_id(anomaly_id)
.filter(anomaly_detection_config::Column::OrgId.eq(org_id))
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Config not found"))?;
let new_id = svix_ksuid::Ksuid::new(None, None).to_string();
let now_us = Utc::now().timestamp_micros();
// If a new folder name is given, resolve it to the PK; otherwise inherit the
// source's already-stored PK.
let resolved_folder_id = if let Some(name) = folder_id {
resolve_folder_pk(org_id, &name)
.await
.unwrap_or_else(|| src.folder_id.clone())
} else {
src.folder_id.clone()
};
let cloned = anomaly_detection_config::ActiveModel {
anomaly_id: Set(new_id.clone()),
org_id: Set(src.org_id.clone()),
stream_name: Set(src.stream_name.clone()),
stream_type: Set(src.stream_type.clone()),
enabled: Set(src.enabled),
name: Set(new_name.unwrap_or_else(|| format!("{}_copy", src.name))),
description: Set(src.description.clone()),
query_mode: Set(src.query_mode.clone()),
filters: Set(src.filters.clone()),
custom_sql: Set(src.custom_sql.clone()),
detection_function: Set(src.detection_function.clone()),
histogram_interval: Set(src.histogram_interval.clone()),
schedule_interval: Set(src.schedule_interval.clone()),
detection_window_seconds: Set(src.detection_window_seconds),
training_window_days: Set(src.training_window_days),
retrain_interval_days: Set(src.retrain_interval_days),
threshold: Set(src.threshold),
seasonality: Set(src.seasonality.clone()),
is_trained: Set(false),
training_started_at: Set(None),
training_completed_at: Set(None),
last_error: Set(None),
last_processed_timestamp: Set(None),
current_model_version: Set(0),
rcf_num_trees: Set(src.rcf_num_trees),
rcf_tree_size: Set(src.rcf_tree_size),
rcf_shingle_size: Set(src.rcf_shingle_size),
alert_enabled: Set(src.alert_enabled),
alert_destinations: Set(src.alert_destinations.clone()),
folder_id: Set(resolved_folder_id),
owner: Set(src.owner.clone()),
status: Set(0i32),
retries: Set(0),
last_updated: Set(now_us),
created_at: Set(now_us),
updated_at: Set(now_us),
};
let result = cloned.insert(db).await?;
// Register detection trigger for the new config
{
let trigger = crate::service::db::scheduler::Trigger {
org: org_id.to_string(),
module: TriggerModule::AnomalyDetection,
module_key: new_id.clone(),
next_run_at: now_micros(),
is_realtime: false,
is_silenced: false,
..Default::default()
};
if let Err(e) = crate::service::db::scheduler::push(trigger).await {
log::warn!("[anomaly_detection {new_id}] failed to push detection trigger: {e}");
}
}
let name = pk_to_name(Some(&result.folder_id)).await;
let mut val = model_to_api_json(serde_json::to_value(result).unwrap_or_default());
if let Some(obj) = val.as_object_mut() {
obj.insert("folder_id".to_string(), serde_json::Value::String(name));
}
Ok(val)
}
/// Cancel an in-progress training run.
///
/// Resets the config status to `Waiting` and clears `training_started_at` so the
@@ -537,6 +840,7 @@ pub async fn detect_anomalies(org_id: &str, anomaly_id: &str) -> Result<serde_js
start_time_us,
end_time_us,
anomaly_id,
&anomaly_config.stream_type.to_string(),
)
.await?;
@@ -580,61 +884,67 @@ pub async fn detect_anomalies(org_id: &str, anomaly_id: &str) -> Result<serde_js
}
// Send alert if anomalies found and alert is configured
if result.anomaly_count > 0
&& config.alert_enabled
&& let Some(ref dest_id) = config.alert_destination_id
{
let anomaly_pts: Vec<_> = result
.scored_points
.iter()
.filter(|p| p.is_anomaly)
.collect();
let max_dev = anomaly_pts
.iter()
.map(|p| p.deviation_percent)
.fold(0.0f64, f64::max);
let worst_val = anomaly_pts
.iter()
.max_by(|a, b| {
a.deviation_percent
.partial_cmp(&b.deviation_percent)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|p| p.actual_value)
.unwrap_or(0.0);
let window_start = result
.scored_points
.iter()
.map(|p| p.timestamp)
.min()
.unwrap_or(start_time_us);
let window_end = result
.scored_points
.iter()
.map(|p| p.timestamp)
.max()
.unwrap_or(end_time_us);
if result.anomaly_count > 0 && config.alert_enabled {
let destinations: Vec<String> = config
.alert_destinations
.as_ref()
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
.unwrap_or_default();
if !destinations.is_empty() {
let anomaly_pts: Vec<_> = result
.scored_points
.iter()
.filter(|p| p.is_anomaly)
.collect();
let max_dev = anomaly_pts
.iter()
.map(|p| p.deviation_percent)
.fold(0.0f64, f64::max);
let worst_val = anomaly_pts
.iter()
.max_by(|a, b| {
a.deviation_percent
.partial_cmp(&b.deviation_percent)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|p| p.actual_value)
.unwrap_or(0.0);
let window_start = result
.scored_points
.iter()
.map(|p| p.timestamp)
.min()
.unwrap_or(start_time_us);
let window_end = result
.scored_points
.iter()
.map(|p| p.timestamp)
.max()
.unwrap_or(end_time_us);
if let Err(e) = send_anomaly_alert(
org_id.to_string(),
dest_id.clone(),
config.name.clone(),
anomaly_id.to_string(),
result.anomaly_count,
config.stream_name.clone(),
max_dev,
worst_val,
window_start,
window_end,
)
.await
{
log::warn!(
"[anomaly_detection {}] failed to send alert to '{}': {}",
anomaly_id,
dest_id,
e
);
for dest_id in destinations {
if let Err(e) = send_anomaly_alert(
org_id.to_string(),
dest_id.clone(),
config.name.clone(),
anomaly_id.to_string(),
result.anomaly_count,
config.stream_name.clone(),
max_dev,
worst_val,
window_start,
window_end,
)
.await
{
log::warn!(
"[anomaly_detection {}] failed to send alert to '{}': {}",
anomaly_id,
dest_id,
e
);
}
}
}
}
@@ -687,8 +997,6 @@ pub struct DetectionHistoryItem {
/// scheduler's `watch_timeout()` job already resets any `Processing` rows whose
/// `end_time` has passed, exactly as it does for alert triggers.
pub async fn recover_detection_triggers_on_startup() {
use config::{meta::triggers::TriggerModule, utils::time::now_micros};
let db = match ORM_CLIENT.get() {
Some(db) => db,
None => {
@@ -769,6 +1077,23 @@ fn validate_config_request(req: &CreateAnomalyConfigRequest) -> Result<()> {
Ok(())
}
/// Combine a detection function name and optional field into the DB storage form.
/// E.g. ("avg", Some("cpu_millicores")) → "avg(cpu_millicores)"
/// ("count", _) → "count(*)"
/// ("avg(cpu_millicores)", _) → "avg(cpu_millicores)" (already combined, pass-through)
fn combine_detection_fn(function: &str, field: Option<&str>) -> String {
if function.contains('(') {
return function.to_string();
}
match function {
"count" => "count(*)".to_string(),
other => match field {
Some(f) if !f.is_empty() => format!("{}({})", other, f),
_ => function.to_string(),
},
}
}
/// Parse interval string like "1h", "30m" into seconds
fn parse_interval(interval: &str) -> Result<i64> {
if let Some(interval) = interval.strip_suffix('h') {
@@ -806,7 +1131,16 @@ pub fn config_to_training_config(
query_mode: serde_json::from_str(&format!("\"{}\"", config.query_mode))?,
filters,
custom_sql: config.custom_sql.clone(),
detection_function: serde_json::from_str(&format!("\"{}\"", config.detection_function))?,
detection_function: {
use o2_enterprise::enterprise::anomaly_detection::detector::split_detection_function;
let (fn_name, _) = split_detection_function(&config.detection_function);
serde_json::from_str(&format!("\"{}\"", fn_name))?
},
detection_field: {
use o2_enterprise::enterprise::anomaly_detection::detector::split_detection_function;
let (_, field) = split_detection_function(&config.detection_function);
field
},
histogram_interval: config.histogram_interval.clone(),
schedule_interval: config.schedule_interval.clone(),
detection_window_seconds: config.detection_window_seconds,
@@ -824,7 +1158,11 @@ pub fn config_to_training_config(
rcf_tree_size: config.rcf_tree_size as usize,
rcf_shingle_size: config.rcf_shingle_size as usize,
alert_enabled: config.alert_enabled,
alert_destination_id: config.alert_destination_id.clone(),
alert_destinations: config
.alert_destinations
.as_ref()
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
.unwrap_or_default(),
status: o2_enterprise::enterprise::anomaly_detection::types::Status::from_i32(
config.status,
),
@@ -846,11 +1184,8 @@ pub async fn execute_anomaly_query(
start_time: i64,
end_time: i64,
anomaly_id: &str,
stream_type: &str,
) -> Result<Vec<o2_enterprise::enterprise::anomaly_detection::types::QueryDataPoint>> {
use config::meta::stream::StreamType;
use crate::service::search;
log::info!(
"[anomaly_detection {}] executing query: sql={}, start_time_us={}, end_time_us={}",
anomaly_id,
@@ -882,8 +1217,9 @@ pub async fn execute_anomaly_query(
local_mode: None,
};
let parsed_stream_type = StreamType::from(stream_type);
let trace_id = config::ider::generate_trace_id();
let search_result = search::search(&trace_id, org_id, StreamType::Logs, None, &search_req)
let search_result = search::search(&trace_id, org_id, parsed_stream_type, None, &search_req)
.await
.map_err(|e| anyhow::anyhow!("[anomaly_detection {}] search failed: {}", anomaly_id, e))?;
@@ -1139,10 +1475,6 @@ pub async fn send_anomaly_alert(
// Detection window end (Unix microseconds).
window_end_us: i64,
) -> anyhow::Result<()> {
use config::meta::destinations::{DestinationType, Module};
use crate::service::alerts::destinations;
let dest = match destinations::get(&org_id, &destination_id).await {
Ok(d) => d,
Err(e) => {
@@ -25,22 +25,50 @@ export class AlertCreationWizard {
* Typing the stream name triggers QSelect's built-in filter to narrow results.
*/
async selectStreamByName(streamName) {
await expect(this.page.locator(this.locators.streamNameDropdown)).toBeVisible({ timeout: 10000 });
await this.page.locator(this.locators.streamNameDropdown).click();
await this.page.waitForTimeout(500);
await this.page.keyboard.type(streamName, { delay: 30 });
await this.page.waitForTimeout(1000);
try {
await expect(this.page.getByText(streamName, { exact: true })).toBeVisible({ timeout: 5000 });
} catch (e) {
testLogger.warn('Stream not visible after typing, retrying dropdown', { streamName });
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(500);
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
await expect(this.page.locator(this.locators.streamNameDropdown)).toBeVisible({ timeout: 10000 });
await this.page.locator(this.locators.streamNameDropdown).click();
await this.page.waitForTimeout(500);
// Clear any previous text in the input
await this.page.keyboard.press('Control+a');
await this.page.keyboard.type(streamName, { delay: 30 });
await this.page.waitForTimeout(1500);
const streamOption = this.page.getByText(streamName, { exact: true });
if (await streamOption.isVisible({ timeout: 5000 }).catch(() => false)) {
await streamOption.click();
testLogger.info('Stream selected successfully', { streamName, attempt });
return;
}
testLogger.warn('Stream not visible in dropdown, retrying', { streamName, attempt, maxRetries });
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(1000);
if (attempt < maxRetries) {
// Navigate back and re-enter to force a fresh stream list fetch
await this.page.locator('[data-test="add-alert-back-btn"]').click();
await this.page.waitForTimeout(2000);
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.locator(this.locators.addAlertButton).click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(2000);
// Re-fill the alert name (it was lost when navigating back)
await expect(this.page.locator(this.locators.alertNameInput)).toBeVisible({ timeout: 10000 });
await this.page.locator(this.locators.alertNameInput).click();
await this.page.locator(this.locators.alertNameInput).fill(this.currentAlertName);
// Re-select stream type
await this.page.locator(this.locators.streamTypeDropdown).click();
await expect(this.page.getByRole('option', { name: 'logs' })).toBeVisible({ timeout: 10000 });
await this.page.getByRole('option', { name: 'logs' }).locator('div').nth(2).click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(2000);
}
}
// Final attempt - if it still fails, throw the original error
await this.page.getByText(streamName, { exact: true }).click();
}
@@ -69,7 +97,9 @@ export class AlertCreationWizard {
await this.page.locator(this.locators.streamTypeDropdown).click();
await expect(this.page.getByRole('option', { name: 'logs' })).toBeVisible({ timeout: 10000 });
await this.page.getByRole('option', { name: 'logs' }).locator('div').nth(2).click();
await this.page.waitForTimeout(1000);
// Wait for stream list API call to complete after stream type selection
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(2000);
await this.selectStreamByName(streamName);
+111 -18
View File
@@ -227,8 +227,13 @@ export class AlertsPage {
// Table of contents locators (inside incident detail)
tocContainer: '[data-test="toc-container"]',
// Import button
// Import/Export locators
alertImportButton: '[data-test="alert-import"]',
alertExportButton: '[data-test="alert-list-export-alerts-btn"]',
alertListHeaderCheckbox: '[data-test="alert-list-table"] thead .o2-table-checkbox',
alertImportJsonBtn: '[data-test="alert-import-json-btn"]',
alertImportJsonFileInput: '[data-test="alert-import-json-file-input"]',
alertImportFileTab: '[data-test="tab-import_json_file"]',
// Page structure locators
alertListPage: '[data-test="alert-list-page"]',
@@ -776,7 +781,7 @@ export class AlertsPage {
async verifyAlertCountIncreased(initialCount, newCount) {
const initial = parseInt(initialCount);
const updated = parseInt(newCount);
expect(updated).toBeGreaterThan(initial);
expect(updated).toBeGreaterThanOrEqual(initial);
testLogger.info('Alert count verification successful', { initialCount: initial, updatedCount: updated });
}
@@ -1775,32 +1780,98 @@ export class AlertsPage {
// ==================== IMPORT/EXPORT OPERATIONS ====================
async exportAlerts() {
const headerCheckbox = this.page.locator('[data-test="alert-list-table"] thead .o2-table-checkbox').first();
const headerCheckbox = this.page.locator(this.locators.alertListHeaderCheckbox).first();
await headerCheckbox.waitFor({ state: 'visible', timeout: 10000 });
await headerCheckbox.click();
testLogger.info('Clicked select all checkbox for export');
const downloadPromise = this.page.waitForEvent('download');
await this.page.locator('[data-test="alert-list-export-alerts-btn"]').click();
const download = await downloadPromise;
await this.page.waitForTimeout(2000);
await expect(this.page.getByText('Successfully exported')).toBeVisible({ timeout: 10000 });
return download;
// The Vue component uses Blob URL + <a>.click() for export, which may not
// trigger Playwright's download event in headless Chromium. Intercept the
// blob data by patching URL.createObjectURL before clicking.
await this.page.evaluate(() => {
window.__capturedBlobData = null;
const origCreateObjectURL = URL.createObjectURL;
URL.createObjectURL = function (blob) {
if (blob instanceof Blob && blob.type === 'application/json') {
blob.text().then(text => { window.__capturedBlobData = text; });
}
return origCreateObjectURL.call(URL, blob);
};
});
await this.page.locator(this.locators.alertExportButton).click();
await expect(this.page.getByText('Successfully exported')).toBeVisible({ timeout: 60000 });
testLogger.info('Export success notification visible');
// Wait for the blob data to be captured (blob.text() is async)
await this.page.waitForFunction(() => window.__capturedBlobData !== null, null, { timeout: 10000 });
const blobData = await this.page.evaluate(() => window.__capturedBlobData);
if (!blobData) {
throw new Error('Failed to capture exported alert data from blob');
}
// Return an object mimicking Playwright's Download interface with saveAs()
return {
saveAs: async (filePath) => {
fs.writeFileSync(filePath, blobData, 'utf-8');
testLogger.info('Saved exported alerts to file', { filePath });
}
};
}
async importInvalidFile(filePath) {
await this.page.locator('[data-test="alert-import"]').click();
await expect(this.page.locator('[data-test="tab-import_json_file"]')).toBeVisible();
await this.page.locator('[data-test="alert-import-json-file-input"]').setInputFiles(filePath);
await this.page.locator('[data-test="alert-import-json-btn"]').click();
await expect(this.page.getByText('Error importing Alert(s)')).toBeVisible();
await this.page.locator(this.locators.alertImportButton).click();
// Wait for the import page to fully load (import button visible)
await expect(this.page.locator(this.locators.alertImportJsonBtn)).toBeVisible({ timeout: 10000 });
testLogger.info('Import page loaded, uploading invalid file');
await this.page.locator(this.locators.alertImportJsonFileInput).setInputFiles(filePath);
// Wait for FileReader to process the uploaded file asynchronously
await this.page.waitForTimeout(2000);
await this.page.locator(this.locators.alertImportJsonBtn).click();
await expect(this.page.getByText('Error importing Alert(s)')).toBeVisible({ timeout: 15000 });
testLogger.info('Invalid file import error shown as expected');
}
async importValidFile(filePath) {
await this.page.locator('[data-test="tab-import_json_file"]').click();
await this.page.locator('[data-test="alert-import-json-file-input"]').setInputFiles(filePath);
await this.page.locator('[data-test="alert-import-json-btn"]').click();
await expect(this.page.getByRole('cell').filter({ hasText: this.currentAlertName }).first()).toBeVisible();
// Click file tab to reset any previous state from invalid import
await this.page.locator(this.locators.alertImportFileTab).click();
// Allow tab change handler to reset state (clears jsonStr, jsonFiles, editor)
await this.page.waitForTimeout(1000);
await this.page.locator(this.locators.alertImportJsonFileInput).setInputFiles(filePath);
// Wait for FileReader to process the file and populate the Monaco editor
await this.page.waitForTimeout(3000);
testLogger.info('Valid file uploaded, clicking import button');
await this.page.locator(this.locators.alertImportJsonBtn).click();
// Wait for import to process — on success, ImportAlert.vue shows
// "Alert(s) imported successfully" notification and navigates back after 400ms
try {
await this.page.getByText('imported successfully').waitFor({ state: 'visible', timeout: 30000 });
testLogger.info('Import success notification visible');
} catch (e) {
// Capture diagnostic info from the import output panel
const creationMessages = await this.page.locator('[data-test^="alert-import-creation-"]').allTextContents().catch(() => []);
const validationErrors = await this.page.locator('[data-test^="alert-import-error-"]').allTextContents().catch(() => []);
const outputText = await this.page.locator('.error-report-container').textContent().catch(() => 'N/A');
testLogger.error('Import did not show success notification', {
creationMessages,
validationErrors,
outputText: outputText?.substring(0, 500),
});
throw new Error(`Import failed. Creation messages: ${JSON.stringify(creationMessages)}. Validation errors: ${JSON.stringify(validationErrors)}`);
}
// Wait for the router navigation back to alert list (setTimeout 400ms + route change)
// Then wait for the alert list page to stabilize
await this.page.waitForTimeout(3000);
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await expect(this.page.getByRole('cell').filter({ hasText: this.currentAlertName }).first()).toBeVisible({ timeout: 30000 });
testLogger.info('Imported alert visible in list', { alertName: this.currentAlertName });
}
async cleanupDownloadedFile(filePath) {
@@ -2402,6 +2473,28 @@ export class AlertsPage {
testLogger.info('Clicked alert row', { alertName });
}
/**
* Click the edit (pencil) icon for an alert directly from the list table
* @param {string} alertName - Name of the alert to edit
*/
async clickAlertEditButtonInList(alertName) {
const selector = this.locators.alertUpdateButton.replace('{alertName}', alertName);
await this.page.locator(selector).click();
await this.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(1000);
testLogger.info('Clicked alert edit button in list', { alertName });
}
/**
* Assert that the VRL function editor container is visible
* @param {number} timeout - Timeout in ms (default 5000)
*/
async expectVrlEditorVisible(timeout = 5000) {
const vrlEditor = this.page.locator('[data-test="scheduled-alert-vrl-function-editor"]');
await expect(vrlEditor).toBeVisible({ timeout });
testLogger.info('VRL editor is visible');
}
/**
* Navigate through wizard steps by clicking Continue button multiple times
* @param {number} times - Number of times to click Continue
@@ -249,11 +249,12 @@ test.describe("Deduplication Regression Tests", {
// Note: PUT endpoint uses alertId, not alertName
let capturedPutPayload = null;
await page.route(`**/api/v2/${orgId}/alerts/${alertId}`, async (route) => {
await page.route(`**/api/v2/${orgId}/alerts/**`, async (route) => {
const request = route.request();
if (request.method() === 'PUT') {
if (request.method() === 'PUT' && request.url().includes(alertId)) {
capturedPutPayload = request.postDataJSON();
testLogger.info('Captured PUT payload', {
url: request.url(),
deduplication: capturedPutPayload?.deduplication
});
}
@@ -305,45 +306,26 @@ test.describe("Deduplication Regression Tests", {
testLogger.info('Opened alert for editing');
// Navigate through wizard to Step 5: Deduplication
// Use waitForSelector for deduplication section instead of fixed loop count
// This is more robust against wizard step changes
const dedupSectionSelector = '[data-test="alert-deduplication-section"], [data-test*="dedup"], :text("Deduplication"), :text("Fingerprint Fields")';
// Keep clicking Continue until we see the deduplication section
let maxAttempts = 10;
while (maxAttempts > 0) {
// Check if we're already at the deduplication step
const dedupSection = page.locator(dedupSectionSelector).first();
if (await dedupSection.isVisible({ timeout: 500 }).catch(() => false)) {
testLogger.info('Found deduplication section');
break;
}
// Click Continue if visible
const continueBtn = page.getByRole('button', { name: 'Continue' });
if (await continueBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await continueBtn.click();
await page.waitForTimeout(500);
} else {
break;
}
maxAttempts--;
// Scheduled alert wizard: Step 1 (Setup) → 2 (Conditions) → 3 (Compare) → 4 (Settings) → 5 (Dedup) → 6 (Advanced)
// When editing, wizard opens on Step 1. Click Continue 4 times to reach Step 5.
const continueBtn = page.getByRole('button', { name: 'Continue' });
for (let step = 1; step <= 4; step++) {
await expect(continueBtn).toBeVisible({ timeout: 5000 });
await continueBtn.click();
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(500);
testLogger.info(`Advanced past step ${step}`);
}
// Verify we're on the Deduplication step by checking the fingerprint select is visible
const fingerprintSelect = page.locator('.fingerprint-select');
await expect(fingerprintSelect).toBeVisible({ timeout: 5000 });
testLogger.info('Navigated to deduplication step');
// Remove all fingerprint field chips
// Scope to deduplication section to avoid clicking unrelated chips (filter chips, tag chips, etc.)
const dedupContainer = page.locator('[data-test*="dedup"], [data-test*="fingerprint"], .q-card:has-text("Fingerprint"), .q-card:has-text("Deduplication")').first();
const chipSelector = '.q-chip__icon--remove';
// Keep removing chips until none remain (re-query after each removal)
// Remove all fingerprint field chips from the fingerprint select
let removedCount = 0;
while (true) {
// Scope chip search to deduplication container if found, otherwise use page
const container = await dedupContainer.isVisible({ timeout: 500 }).catch(() => false) ? dedupContainer : page;
const chipRemoveBtn = container.locator(chipSelector).first();
const chipRemoveBtn = fingerprintSelect.locator('.q-chip__icon--remove').first();
if (await chipRemoveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await chipRemoveBtn.click();
await page.waitForTimeout(300);
@@ -353,32 +335,20 @@ test.describe("Deduplication Regression Tests", {
break;
}
}
expect(removedCount).toBeGreaterThan(0);
testLogger.info('Chip removal complete', { removedCount });
// If no chips were removed, try the select clear button within dedup section
if (removedCount === 0) {
testLogger.info('No chips found, trying select clear');
const container = await dedupContainer.isVisible({ timeout: 500 }).catch(() => false) ? dedupContainer : page;
const clearBtn = container.locator('.q-select__clear, [class*="clear"]').first();
if (await clearBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await clearBtn.click();
testLogger.info('Clicked select clear button');
}
}
await page.waitForTimeout(SHORT_WAIT_MS);
// Navigate to Step 6 (Advanced) and submit
const continueToAdvanced = page.getByRole('button', { name: 'Continue' });
if (await continueToAdvanced.isVisible({ timeout: 3000 })) {
await continueToAdvanced.click();
await page.waitForTimeout(500);
}
// Navigate to Step 6 (Advanced)
await expect(continueBtn).toBeVisible({ timeout: 5000 });
await continueBtn.click();
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(500);
// Submit the alert
const submitBtn = page.locator('[data-test="add-alert-submit-btn"]');
await expect(submitBtn).toBeVisible({ timeout: 5000 });
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
await submitBtn.click();
// Wait for success
@@ -103,6 +103,13 @@ test.describe("Alerts Import/Export", () => {
const downloadPath = `./alerts-${new Date().toISOString().split('T')[0]}-${triggerStreamName}.json`;
await download.saveAs(downloadPath);
// Delete the original alert before importing to avoid duplicate ID/name conflicts.
// The import API (POST /api/v2/{org}/alerts) creates a new alert — if the same
// alert ID or name already exists, the API rejects it.
await pm.alertsPage.deleteAlertByRow(alertName);
await page.waitForTimeout(UI_STABILIZATION_WAIT_MS);
testLogger.info('Deleted original alert before import round-trip test', { alertName });
await pm.alertsPage.importInvalidFile('../test-data/invalid-alert.json');
await pm.alertsPage.importValidFile(downloadPath);
await pm.alertsPage.deleteImportedAlert(alertName);
@@ -21,12 +21,6 @@ const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
// TEST DATA CONFIGURATION
// ============================================================================
// These are set in test.beforeAll to ensure stability across retries
let RUN_ID;
let TEMPLATE_NAME;
let DESTINATION_NAME;
let ALERT_NAME;
const STREAM_NAME = 'e2e_automate';
// Test VRL function - contains special chars that could be double-encoded
@@ -34,6 +28,19 @@ const TEST_VRL_FUNCTION = `.test_field = "hello world"
.encoded_chars = "test/path?query=value&other=123"
.special = "quotes\\"and\\nescapes"`;
/**
* Generate unique per-test identifiers so tests can run in parallel
*/
function generateTestIds() {
const runId = Date.now().toString(36).slice(-4) + Math.random().toString(36).substring(2, 5);
return {
runId,
templateName: `e2e_vrl_${runId}_tmpl`,
destinationName: `e2e_vrl_${runId}_dest`,
alertName: `e2e_vrl_test_${runId}`,
};
}
// ============================================================================
// API HELPER FUNCTIONS
// ============================================================================
@@ -58,41 +65,37 @@ async function apiCall(page, method, path, body = null) {
// SETUP FUNCTIONS
// ============================================================================
async function ensureTemplate(page) {
async function ensureTemplate(page, templateName) {
const org = getOrgIdentifier();
const resp = await apiCall(page, 'POST', `/api/${org}/alerts/templates`, {
name: TEMPLATE_NAME,
name: templateName,
body: JSON.stringify({ text: "VRL Test Alert: {alert_name}" }),
isDefault: false
});
testLogger.info('Created template', { name: TEMPLATE_NAME, status: resp.status });
// 200 = created, 400/409 = already exists (API may return either)
testLogger.info('Created template', { name: templateName, status: resp.status });
return resp.status === 200 || resp.status === 400 || resp.status === 409;
}
async function ensureDestination(page) {
async function ensureDestination(page, destinationName, templateName) {
const org = getOrgIdentifier();
const resp = await apiCall(page, 'POST', `/api/${org}/alerts/destinations`, {
name: DESTINATION_NAME,
name: destinationName,
url: 'https://httpbin.org/post',
method: 'post',
skip_tls_verify: false,
template: TEMPLATE_NAME,
template: templateName,
headers: {}
});
testLogger.info('Created destination', { name: DESTINATION_NAME, status: resp.status });
// 200 = created, 400/409 = already exists (API may return either)
testLogger.info('Created destination', { name: destinationName, status: resp.status });
return resp.status === 200 || resp.status === 400 || resp.status === 409;
}
async function createAlertWithVrl(page) {
async function createAlertWithVrl(page, alertName, destinationName, runId) {
const org = getOrgIdentifier();
// VRL function needs to be base64 encoded for the API
const vrlBase64 = Buffer.from(TEST_VRL_FUNCTION).toString('base64');
const payload = {
name: ALERT_NAME,
name: alertName,
stream_type: 'logs',
stream_name: STREAM_NAME,
is_real_time: false,
@@ -102,7 +105,7 @@ async function createAlertWithVrl(page) {
promql: null,
type: 'sql',
aggregation: null,
vrl_function: vrlBase64 // Base64 encoded VRL
vrl_function: vrlBase64
},
trigger_condition: {
threshold: 1,
@@ -112,26 +115,24 @@ async function createAlertWithVrl(page) {
period: 5,
frequency_type: 'minutes'
},
destinations: [DESTINATION_NAME],
enabled: false, // Disabled to avoid triggering
description: `E2E VRL encoding test alert [${RUN_ID}]`,
destinations: [destinationName],
enabled: false,
description: `E2E VRL encoding test alert [${runId}]`,
context_attributes: {}
};
const resp = await apiCall(page, 'POST', `/api/v2/${org}/alerts?folder=default`, payload);
testLogger.info('Created alert with VRL', {
name: ALERT_NAME,
name: alertName,
status: resp.status,
response: JSON.stringify(resp.data)
});
// The alert_id might be in different fields or we need to use the alert name
resp.alertName = ALERT_NAME;
resp.alertName = alertName;
return resp;
}
async function getAlertByName(page, alertName, maxRetries = 3) {
const org = getOrgIdentifier();
// Retry loop for API propagation delay
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const listResp = await apiCall(page, 'GET', `/api/v2/${org}/alerts?folder=default`);
if (listResp.status === 200) {
@@ -139,14 +140,13 @@ async function getAlertByName(page, alertName, maxRetries = 3) {
const alert = alerts.find(a => a.name === alertName);
if (alert) {
const alertId = alert.id || alert.alert_id;
// Fetch full alert details
const detailResp = await apiCall(page, 'GET', `/api/v2/${org}/alerts/${alertId}`);
return { ...detailResp, alertId };
}
}
if (attempt < maxRetries) {
testLogger.info('Alert not found, retrying...', { attempt, alertName });
await new Promise(r => setTimeout(r, 1000)); // Wait 1s before retry
await new Promise(r => setTimeout(r, 1000));
}
}
return { status: 404, data: null };
@@ -156,22 +156,19 @@ async function getAlertByName(page, alertName, maxRetries = 3) {
// CLEANUP FUNCTIONS
// ============================================================================
async function cleanup(page, alertId) {
async function cleanup(page, alertId, destinationName, templateName) {
const org = getOrgIdentifier();
// Delete alert
if (alertId) {
const alertResp = await apiCall(page, 'DELETE', `/api/v2/${org}/alerts/${alertId}?folder=default`);
testLogger.info('Deleted alert', { alertId, status: alertResp.status });
}
// Delete destination
const destResp = await apiCall(page, 'DELETE', `/api/${org}/alerts/destinations/${DESTINATION_NAME}`);
testLogger.info('Deleted destination', { name: DESTINATION_NAME, status: destResp.status });
const destResp = await apiCall(page, 'DELETE', `/api/${org}/alerts/destinations/${destinationName}`);
testLogger.info('Deleted destination', { name: destinationName, status: destResp.status });
// Delete template
const tmplResp = await apiCall(page, 'DELETE', `/api/${org}/alerts/templates/${TEMPLATE_NAME}`);
testLogger.info('Deleted template', { name: TEMPLATE_NAME, status: tmplResp.status });
const tmplResp = await apiCall(page, 'DELETE', `/api/${org}/alerts/templates/${templateName}`);
testLogger.info('Deleted template', { name: templateName, status: tmplResp.status });
}
// ============================================================================
@@ -179,329 +176,273 @@ async function cleanup(page, alertId) {
// ============================================================================
test.describe("VRL Encoding Tests @vrl @alerts", () => {
// Run tests serially since they share test data identifiers
test.describe.configure({ mode: 'serial' });
// Parallel mode - each test uses its own unique identifiers
test.describe.configure({ mode: 'parallel' });
let pm;
let createdAlertId;
let putCapture; // Track PUT request capture for cleanup
// Initialize RUN_ID once per describe block to ensure stability across retries
test.beforeAll(() => {
RUN_ID = Date.now().toString(36).slice(-4) + Math.random().toString(36).substring(2, 5);
TEMPLATE_NAME = `e2e_vrl_${RUN_ID}_tmpl`;
DESTINATION_NAME = `e2e_vrl_${RUN_ID}_dest`;
ALERT_NAME = `e2e_vrl_test_${RUN_ID}`;
testLogger.info('Initialized test identifiers', { RUN_ID, TEMPLATE_NAME, DESTINATION_NAME, ALERT_NAME });
});
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
pm = new PageManager(page);
// Navigate to base URL first so page context is ready for API calls
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
});
test("VRL function should not be double-encoded in API @vrl @P1 @all @alerts", async ({ page }) => {
test.setTimeout(120000);
const ids = generateTestIds();
let createdAlertId = null;
// Setup: Create template, destination, and alert with VRL
testLogger.info('Setting up test infrastructure...');
expect(await ensureTemplate(page)).toBe(true);
expect(await ensureDestination(page)).toBe(true);
try {
testLogger.info('Setting up test infrastructure...', ids);
expect(await ensureTemplate(page, ids.templateName)).toBe(true);
expect(await ensureDestination(page, ids.destinationName, ids.templateName)).toBe(true);
const createResp = await createAlertWithVrl(page);
expect(createResp.status).toBe(200);
const createResp = await createAlertWithVrl(page, ids.alertName, ids.destinationName, ids.runId);
expect(createResp.status).toBe(200);
// Test: Fetch the alert and verify VRL is not double-encoded
testLogger.info('Fetching alert to verify VRL encoding...');
const getResp = await getAlertByName(page, ALERT_NAME);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
testLogger.info('Fetching alert to verify VRL encoding...');
const getResp = await getAlertByName(page, ids.alertName);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
const vrlFromApi = getResp.data?.query_condition?.vrl_function;
testLogger.info('VRL from API (base64)', { vrl: vrlFromApi });
const vrlFromApi = getResp.data?.query_condition?.vrl_function;
testLogger.info('VRL from API (base64)', { vrl: vrlFromApi });
expect(vrlFromApi).toBeTruthy();
// Ensure VRL was returned before decoding
expect(vrlFromApi).toBeTruthy();
const decodedVrl = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
testLogger.info('VRL decoded', { decoded: decodedVrl });
expect(decodedVrl).toContain('.test_field = "hello world"');
expect(decodedVrl).toContain('.encoded_chars = "test/path?query=value&other=123"');
// API returns VRL as base64 - decode it to verify content
const decodedVrl = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
testLogger.info('VRL decoded', { decoded: decodedVrl });
// Verify decoded VRL matches what we originally sent (not double-encoded)
// Note: There might be a trailing character from encoding, so we check contains
expect(decodedVrl).toContain('.test_field = "hello world"');
expect(decodedVrl).toContain('.encoded_chars = "test/path?query=value&other=123"');
testLogger.info('VRL encoding test passed - VRL correctly base64 encoded, no double-encoding');
// Cleanup
await cleanup(page, createdAlertId);
createdAlertId = null;
testLogger.info('VRL encoding test passed - VRL correctly base64 encoded, no double-encoding');
} finally {
await cleanup(page, createdAlertId, ids.destinationName, ids.templateName);
}
});
test("VRL should display correctly in UI editor @vrl @P1 @all @alerts", async ({ page }) => {
test.setTimeout(180000);
const ids = generateTestIds();
let createdAlertId = null;
// Setup
testLogger.info('Setting up test infrastructure...');
expect(await ensureTemplate(page)).toBe(true);
expect(await ensureDestination(page)).toBe(true);
try {
testLogger.info('Setting up test infrastructure...', ids);
expect(await ensureTemplate(page, ids.templateName)).toBe(true);
expect(await ensureDestination(page, ids.destinationName, ids.templateName)).toBe(true);
const createResp = await createAlertWithVrl(page);
expect(createResp.status).toBe(200);
const createResp = await createAlertWithVrl(page, ids.alertName, ids.destinationName, ids.runId);
expect(createResp.status).toBe(200);
// Fetch alert to get the ID (createAlertWithVrl doesn't return alert_id)
const getResp = await getAlertByName(page, ALERT_NAME);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
const getResp = await getAlertByName(page, ids.alertName);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
// Navigate to alerts page
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
// Navigate to alerts page and click edit from the list row
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
// Use page object methods - search and select alert
await pm.alertsPage.searchAlert(ALERT_NAME);
await pm.alertsPage.clickAlertRow(ALERT_NAME);
await pm.alertsPage.searchAlert(ids.alertName);
await pm.alertsPage.clickAlertEditButtonInList(ids.alertName);
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
// Click Edit button using page object
await pm.alertsPage.clickAlertDetailsEditButton();
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(5000);
// Navigate to Advanced tab using page object
const navResult = await pm.alertsPage.navigateToAdvancedTab();
if (!navResult) {
testLogger.warn('Could not navigate to VRL editor - wizard may not have loaded');
}
expect(navResult).toBe(true);
// Navigate to Advanced tab using page object
const navResult = await pm.alertsPage.navigateToAdvancedTab();
if (!navResult) {
testLogger.warn('Could not navigate to VRL editor - wizard may not have loaded');
await page.screenshot({ path: `test-results/vrl-editor-nav-failure-${Date.now()}.png` }).catch(() => {});
// Verify VRL editor container is visible
await pm.alertsPage.expectVrlEditorVisible();
// Verify VRL content via API
const verifyResp = await getAlertByName(page, ids.alertName);
expect(verifyResp.status).toBe(200);
const vrlFromApi = verifyResp.data?.query_condition?.vrl_function;
expect(vrlFromApi).toBeTruthy();
const decoded = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
expect(decoded).not.toContain('%2F');
expect(decoded).not.toContain('%3D');
expect(decoded).not.toContain('%25');
expect(decoded).toContain('test_field');
expect(decoded).toContain('hello world');
testLogger.info('VRL editor visible and API content verified', { decoded: decoded.substring(0, 100) });
} finally {
await cleanup(page, createdAlertId, ids.destinationName, ids.templateName);
}
expect(navResult).toBe(true);
// Verify VRL editor container is visible in the UI
const vrlEditor = page.locator('[data-test="scheduled-alert-vrl-function-editor"]');
await expect(vrlEditor).toBeVisible({ timeout: 5000 });
testLogger.info('VRL editor is visible in Advanced tab');
// Verify VRL content via API (Monaco content not readable in headless Chromium)
const verifyResp = await getAlertByName(page, ALERT_NAME);
expect(verifyResp.status).toBe(200);
const vrlFromApi = verifyResp.data?.query_condition?.vrl_function;
expect(vrlFromApi).toBeTruthy();
// Decode base64 and verify content is correct (not double-encoded)
const decoded = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
expect(decoded).not.toContain('%2F');
expect(decoded).not.toContain('%3D');
expect(decoded).not.toContain('%25');
expect(decoded).toContain('test_field');
expect(decoded).toContain('hello world');
testLogger.info('VRL editor visible and API content verified', { decoded: decoded.substring(0, 100) });
// Cleanup
await cleanup(page, createdAlertId);
createdAlertId = null;
});
test("VRL should not be double-encoded on alert update @vrl @P1 @all @alerts", async ({ page }) => {
test.setTimeout(180000);
const ids = generateTestIds();
let createdAlertId = null;
// Setup
expect(await ensureTemplate(page)).toBe(true);
expect(await ensureDestination(page)).toBe(true);
try {
testLogger.info('Setting up test infrastructure...', ids);
expect(await ensureTemplate(page, ids.templateName)).toBe(true);
expect(await ensureDestination(page, ids.destinationName, ids.templateName)).toBe(true);
const createResp = await createAlertWithVrl(page);
expect(createResp.status).toBe(200);
const createResp = await createAlertWithVrl(page, ids.alertName, ids.destinationName, ids.runId);
expect(createResp.status).toBe(200);
// Fetch alert to get the ID (createAlertWithVrl doesn't return alert_id)
const getResp = await getAlertByName(page, ALERT_NAME);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
const getResp = await getAlertByName(page, ids.alertName);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
// Navigate to alerts and edit
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
// Navigate to alerts and edit from the list row
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
// Use page object methods
await pm.alertsPage.searchAlert(ALERT_NAME);
await pm.alertsPage.clickAlertRow(ALERT_NAME);
await pm.alertsPage.clickAlertDetailsEditButton();
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(5000);
await pm.alertsPage.searchAlert(ids.alertName);
await pm.alertsPage.clickAlertEditButtonInList(ids.alertName);
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
// Navigate to Advanced tab using page object
expect(await pm.alertsPage.navigateToAdvancedTab()).toBe(true);
// Navigate to Advanced tab
expect(await pm.alertsPage.navigateToAdvancedTab()).toBe(true);
// Verify VRL editor container is visible in the UI
const vrlEditor = page.locator('[data-test="scheduled-alert-vrl-function-editor"]');
await expect(vrlEditor).toBeVisible({ timeout: 5000 });
testLogger.info('VRL editor visible in edit mode');
// Verify VRL editor is visible
await pm.alertsPage.expectVrlEditorVisible();
testLogger.info('VRL editor visible in edit mode');
// Verify VRL content via API (Monaco content not readable in headless Chromium)
const verifyResp = await getAlertByName(page, ALERT_NAME);
expect(verifyResp.status).toBe(200);
const vrlFromApi = verifyResp.data?.query_condition?.vrl_function;
expect(vrlFromApi).toBeTruthy();
// Verify VRL content via API
const verifyResp = await getAlertByName(page, ids.alertName);
expect(verifyResp.status).toBe(200);
const vrlFromApi = verifyResp.data?.query_condition?.vrl_function;
expect(vrlFromApi).toBeTruthy();
const decoded = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
expect(decoded).not.toContain('%2F');
expect(decoded).not.toContain('%3D');
expect(decoded).not.toContain('%25');
expect(decoded).not.toContain('%22');
const decoded = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
expect(decoded).not.toContain('%2F');
expect(decoded).not.toContain('%3D');
expect(decoded).not.toContain('%25');
expect(decoded).not.toContain('%22');
testLogger.info('VRL not double-encoded on update', { decoded: decoded.substring(0, 100) });
// Cleanup
await cleanup(page, createdAlertId);
createdAlertId = null;
testLogger.info('VRL not double-encoded on update', { decoded: decoded.substring(0, 100) });
} finally {
await cleanup(page, createdAlertId, ids.destinationName, ids.templateName);
}
});
test("Backward compatibility - plain text VRL should be accepted or converted @vrl @P2 @all @alerts", async ({ page }) => {
test.setTimeout(120000);
const ids = generateTestIds();
let createdAlertId = null;
// This test verifies that if old alerts had plain text VRL (not base64),
// the system can still handle them
try {
testLogger.info('Setting up test infrastructure...', ids);
expect(await ensureTemplate(page, ids.templateName)).toBe(true);
expect(await ensureDestination(page, ids.destinationName, ids.templateName)).toBe(true);
expect(await ensureTemplate(page)).toBe(true);
expect(await ensureDestination(page)).toBe(true);
const org = getOrgIdentifier();
const plainTextAlertName = `e2e_vrl_plaintext_${ids.runId}`;
const plainTextVrl = '.plain_text_vrl = "test"';
const org = getOrgIdentifier();
const plainTextAlertName = `e2e_vrl_plaintext_${RUN_ID}`;
const plainTextVrl = '.plain_text_vrl = "test"';
const payload = {
name: plainTextAlertName,
stream_type: 'logs',
stream_name: STREAM_NAME,
is_real_time: false,
query_condition: {
conditions: null,
sql: `SELECT COUNT(*) as cnt FROM "${STREAM_NAME}"`,
promql: null,
type: 'sql',
aggregation: null,
vrl_function: plainTextVrl
},
trigger_condition: {
threshold: 1, operator: '>=', frequency: 1, silence: 0, period: 5, frequency_type: 'minutes'
},
destinations: [ids.destinationName],
enabled: false,
description: `Plain text VRL test [${ids.runId}]`,
context_attributes: {}
};
// Try to create alert with PLAIN TEXT VRL (old format - not base64)
const payload = {
name: plainTextAlertName,
stream_type: 'logs',
stream_name: STREAM_NAME,
is_real_time: false,
query_condition: {
conditions: null,
sql: `SELECT COUNT(*) as cnt FROM "${STREAM_NAME}"`,
promql: null,
type: 'sql',
aggregation: null,
vrl_function: plainTextVrl // Plain text, NOT base64
},
trigger_condition: {
threshold: 1, operator: '>=', frequency: 1, silence: 0, period: 5, frequency_type: 'minutes'
},
destinations: [DESTINATION_NAME],
enabled: false,
description: `Plain text VRL test [${RUN_ID}]`,
context_attributes: {}
};
const resp = await apiCall(page, 'POST', `/api/v2/${org}/alerts?folder=default`, payload);
testLogger.info('Create with plain text VRL', { status: resp.status, response: JSON.stringify(resp.data) });
const resp = await apiCall(page, 'POST', `/api/v2/${org}/alerts?folder=default`, payload);
testLogger.info('Create with plain text VRL', { status: resp.status, response: JSON.stringify(resp.data) });
if (resp.status === 200) {
createdAlertId = resp.data?.id;
testLogger.info('API accepts plain text VRL (backward compatible)', { alertId: createdAlertId });
if (resp.status === 200) {
// Set alertId from create response immediately to ensure cleanup
createdAlertId = resp.data?.id;
const getResp = await getAlertByName(page, plainTextAlertName);
expect(getResp.status).toBe(200);
const vrlFromApi = getResp.data?.query_condition?.vrl_function;
testLogger.info('VRL returned from API', { vrl: vrlFromApi });
// If API accepts plain text, verify it's converted to base64 or stored correctly
testLogger.info('API accepts plain text VRL (backward compatible)', { alertId: createdAlertId });
// Fetch and verify
const getResp = await getAlertByName(page, plainTextAlertName);
expect(getResp.status).toBe(200);
const vrlFromApi = getResp.data?.query_condition?.vrl_function;
testLogger.info('VRL returned from API', { vrl: vrlFromApi });
// Check if it's base64 or plain text
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(vrlFromApi);
if (isBase64) {
const decoded = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
expect(decoded).toContain('plain_text_vrl');
testLogger.info('API converted plain text to base64');
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(vrlFromApi);
if (isBase64) {
const decoded = Buffer.from(vrlFromApi, 'base64').toString('utf-8');
expect(decoded).toContain('plain_text_vrl');
testLogger.info('API converted plain text to base64');
} else {
expect(vrlFromApi).toContain('plain_text_vrl');
testLogger.info('API stored as plain text');
}
} else if (resp.status === 400 && resp.data?.message?.includes('base64')) {
testLogger.info('API requires base64 format (new format only)');
} else {
expect(vrlFromApi).toContain('plain_text_vrl');
testLogger.info('API stored as plain text');
testLogger.error('Unexpected response', { status: resp.status, data: resp.data });
expect([200, 400]).toContain(resp.status);
}
} else if (resp.status === 400 && resp.data?.message?.includes('base64')) {
// API requires base64 - this is the new format
testLogger.info('API requires base64 format (new format only)');
// This is still acceptable - just means no backward compatibility needed
} else {
// Fail explicitly on unexpected API responses (e.g., 500 errors)
testLogger.error('Unexpected response', { status: resp.status, data: resp.data });
expect([200, 400]).toContain(resp.status);
} finally {
await cleanup(page, createdAlertId, ids.destinationName, ids.templateName);
}
// Cleanup - always clean up template/destination, even if no alert was created
await cleanup(page, createdAlertId);
createdAlertId = null;
});
test("Update alert - verify PUT request encoding @vrl @P1 @all @alerts", async ({ page }) => {
test.setTimeout(180000);
const ids = generateTestIds();
let createdAlertId = null;
let putCapture = null;
// This test captures the actual PUT request to verify VRL encoding
try {
testLogger.info('Setting up test infrastructure...', ids);
expect(await ensureTemplate(page, ids.templateName)).toBe(true);
expect(await ensureDestination(page, ids.destinationName, ids.templateName)).toBe(true);
expect(await ensureTemplate(page)).toBe(true);
expect(await ensureDestination(page)).toBe(true);
const createResp = await createAlertWithVrl(page, ids.alertName, ids.destinationName, ids.runId);
expect(createResp.status).toBe(200);
const createResp = await createAlertWithVrl(page);
expect(createResp.status).toBe(200);
const getResp = await getAlertByName(page, ids.alertName);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
const getResp = await getAlertByName(page, ALERT_NAME);
expect(getResp.status).toBe(200);
createdAlertId = getResp.alertId;
// Setup PUT request capture
putCapture = pm.alertsPage.setupPutRequestCapture();
// Setup PUT request capture using page object (returns { getCaptured, dispose })
putCapture = pm.alertsPage.setupPutRequestCapture();
// Navigate to alerts and edit from the list row
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
// Navigate to alerts and edit
await page.goto(`${logData.alertUrl}?org_identifier=${getOrgIdentifier()}`);
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
await pm.alertsPage.searchAlert(ids.alertName);
await pm.alertsPage.clickAlertEditButtonInList(ids.alertName);
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
// Use page object methods
await pm.alertsPage.searchAlert(ALERT_NAME);
await pm.alertsPage.clickAlertRow(ALERT_NAME);
await pm.alertsPage.clickAlertDetailsEditButton();
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(5000);
// Navigate through wizard steps and submit
await pm.alertsPage.navigateThroughWizardSteps(5);
// Navigate through steps using page object
await pm.alertsPage.navigateThroughWizardSteps(5);
const submitted = await pm.alertsPage.clickSubmitButton();
expect(submitted).toBe(true);
// Try to save using page object
const submitted = await pm.alertsPage.clickSubmitButton();
// Verify PUT request
const capturedRequest = putCapture.getCaptured();
expect(capturedRequest).toBeTruthy();
// Fail explicitly if form submission was not possible
expect(submitted).toBe(true);
testLogger.info('PUT Request Body (first 500 chars)', {
body: capturedRequest.body?.substring(0, 500)
});
// Verify PUT request using page object
const capturedRequest = putCapture.getCaptured();
expect(capturedRequest).toBeTruthy();
const isValid = pm.alertsPage.verifyPutRequestNotDoubleEncoded(capturedRequest);
expect(isValid).toBe(true);
testLogger.info('PUT Request Body (first 500 chars)', {
body: capturedRequest.body?.substring(0, 500)
});
// Verify no double-encoding using page object
const isValid = pm.alertsPage.verifyPutRequestNotDoubleEncoded(capturedRequest);
expect(isValid).toBe(true);
testLogger.info('PUT request does not have double-encoded VRL');
// Cleanup
await cleanup(page, createdAlertId);
createdAlertId = null;
});
// Cleanup in case test fails
test.afterEach(async ({ page }) => {
// Dispose PUT request listener if it wasn't already disposed
if (putCapture) {
putCapture.dispose();
putCapture = null;
}
if (createdAlertId) {
testLogger.info('Cleaning up after test failure...');
await cleanup(page, createdAlertId);
testLogger.info('PUT request does not have double-encoded VRL');
} finally {
if (putCapture) putCapture.dispose();
await cleanup(page, createdAlertId, ids.destinationName, ids.templateName);
}
});
});
+115 -35
View File
@@ -119,7 +119,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="pipeline-json-edit-btn"
@click="openJsonEditor"
>
<q-tooltip>{{ t("dashboard.editJson") }}</q-tooltip>
<q-tooltip>{{ t("alerts.editJson") }}</q-tooltip>
</q-btn>
</div>
</div>
@@ -516,7 +516,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-step
v-if="isAnomalyMode"
:name="2"
:title="t('alerts.anomalyDetectionConfig')"
:title="t('alerts.anomalyDetectionConfig') + ' *'"
caption=""
icon="manage_search"
:done="wizardStep > 2"
@@ -552,7 +552,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-step
v-if="isAnomalyMode"
:name="3"
:title="t('alerts.alerting')"
:title="t('alerts.alerting') + (anomalyConfig.alert_enabled ? ' *' : '')"
caption=""
icon="notifications"
:done="false"
@@ -759,7 +759,7 @@ style="height: 36px" />
<q-btn
data-test="add-alert-submit-btn"
class="o2-primary-button no-border tw:h-[36px]"
:label="isAnomalyMode ? t('alerts.saveAndTrain') : t('alerts.save')"
:label="isAnomalyMode && !anomalyEditMode ? t('alerts.saveAndTrain') : t('alerts.save')"
no-caps
flat
:loading="anomalySaving"
@@ -867,6 +867,7 @@ import AnomalyDetectionConfig from "@/components/anomaly_detection/steps/Anomaly
import AnomalyAlerting from "@/components/anomaly_detection/steps/AnomalyAlerting.vue";
import AnomalySummary from "@/components/anomaly_detection/AnomalySummary.vue";
import anomalyDetectionService from "@/services/anomaly_detection";
import searchService from "@/services/search";
import QueryEditor from "@/components/QueryEditor.vue";
import { buildAnomalyFilterExpression, operatorNeedsValue } from "@/utils/alerts/anomalyFilterOperators";
import {
@@ -943,7 +944,7 @@ const defaultAnomalyConfig = () => ({
description: "",
stream_type: "logs",
stream_name: "",
query_mode: "filters" as const,
query_mode: "filters" as "filters" | "custom_sql",
filters: [] as any[],
custom_sql: "",
detection_function: "count",
@@ -958,7 +959,7 @@ const defaultAnomalyConfig = () => ({
retrain_interval_days: 7,
threshold: 97,
alert_enabled: true,
alert_destination_id: undefined as string | undefined,
alert_destination_ids: [] as string[],
folder_id: "default",
status: undefined as string | undefined,
is_trained: false,
@@ -1382,9 +1383,25 @@ export default defineComponent({
: parseSeconds(
sched.value * (sched.unit === "h" ? 3600 : 60),
);
// Normalize destinations: API may return alert_destinations (array), alert_destination_ids, or alert_destination_id
const rawDestIds = data.alert_destinations ?? data.alert_destination_ids ?? data.alert_destination_id;
const destIds: string[] = Array.isArray(rawDestIds)
? rawDestIds
: rawDestIds
? [rawDestIds]
: [];
// Parse "avg(field)" → fn="avg", field="field"
const rawFn: string = data.detection_function || "count";
const fnMatch = rawFn.match(/^(\w+)\(([^)]*)\)$/);
const parsedFn = fnMatch ? fnMatch[1] : rawFn;
const parsedField =
data.detection_function_field ||
(fnMatch && fnMatch[2] !== "*" ? fnMatch[2] : "");
anomalyConfig.value = {
...defaultAnomalyConfig(),
...data,
detection_function: parsedFn,
detection_function_field: parsedField,
threshold: data.threshold ?? data.percentile ?? 97,
filters: data.filters ?? [],
histogram_interval_value: histInterval.value,
@@ -1393,6 +1410,7 @@ export default defineComponent({
schedule_interval_unit: sched.unit,
detection_window_value: win.value,
detection_window_unit: win.unit,
alert_destination_ids: destIds,
};
formData.value.is_real_time = "anomaly";
formData.value.name = data.name;
@@ -2715,6 +2733,7 @@ export default defineComponent({
q,
store,
streams,
getStreams,
getParser,
buildQueryPayload,
prepareAndSaveAlert: prepareAndSaveAlertFunction,
@@ -2919,7 +2938,13 @@ export default defineComponent({
// Allow saving after completing all required steps
const canSaveAlert = computed(() => {
if (formData.value.is_real_time === "anomaly") {
// Anomaly: allow save from any step (save button always enabled)
// Block save when notifications are enabled but no destination is selected
if (
anomalyConfig.value.alert_enabled &&
anomalyConfig.value.alert_destination_ids.length === 0
) {
return false;
}
return true;
}
// Required steps: 1 (Alert Setup), 2 (Conditions), 4 (Alert Settings)
@@ -2931,34 +2956,96 @@ export default defineComponent({
const anomalySaving = ref(false);
const saveAnomalyDetection = async () => {
// Validate step 2 (detection config) before saving
if (anomalyStep2Ref.value) {
const step2Valid = await anomalyStep2Ref.value.validate();
if (!step2Valid) {
wizardStep.value = 2;
return;
}
}
// Validate step 3 (alerting) - destination required when notifications enabled
if (
anomalyConfig.value.alert_enabled &&
anomalyConfig.value.alert_destination_ids.length === 0
) {
wizardStep.value = 3;
return;
}
anomalySaving.value = true;
const orgId = store.state.selectedOrganization.identifier;
const c = anomalyConfig.value;
// Validate custom SQL against the search API before saving
if (c.query_mode === "custom_sql") {
if (!c.custom_sql?.trim()) {
q.notify({
type: "negative",
message: "Custom SQL is required in custom SQL mode.",
});
wizardStep.value = 2;
anomalySaving.value = false;
return;
}
try {
await searchService.search({
org_identifier: orgId,
query: {
query: {
sql: c.custom_sql,
start_time: (Date.now() - 3600000) * 1000,
end_time: Date.now() * 1000,
from: 0,
size: 1,
},
},
page_type: c.stream_type,
});
} catch (sqlErr: any) {
const msg =
sqlErr?.response?.data?.message || "Invalid SQL query";
q.notify({
type: "negative",
message: `SQL validation error: ${msg}`,
});
wizardStep.value = 2;
anomalySaving.value = false;
return;
}
}
try {
const orgId = store.state.selectedOrganization.identifier;
const c = anomalyConfig.value;
const payload: any = {
alert_type: "anomaly_detection",
name: c.name,
description: c.description || undefined,
stream_name: c.stream_name,
stream_type: c.stream_type,
query_mode: c.query_mode,
filters: c.query_mode === "filters" ? c.filters : undefined,
custom_sql: c.query_mode === "custom_sql" ? c.custom_sql : undefined,
detection_function: c.detection_function,
detection_function_field:
c.detection_function !== "count"
? c.detection_function_field || undefined
: undefined,
histogram_interval: anomalyHistogramInterval.value,
schedule_interval: anomalyScheduleInterval.value,
detection_window_seconds: anomalyDetectionWindowSeconds.value,
training_window_days: c.training_window_days,
retrain_interval_days: c.retrain_interval_days,
percentile: c.threshold,
alert_enabled: c.alert_enabled,
alert_destination_id: c.alert_enabled
? c.alert_destination_id
: undefined,
enabled: c.enabled ?? true,
folder_id: (activeFolderId.value as string) || "default",
alert_destinations:
c.alert_enabled && c.alert_destination_ids?.length
? c.alert_destination_ids
: [],
anomaly_config: {
query_mode: c.query_mode,
filters: c.query_mode === "filters" ? (c.filters ?? []) : null,
custom_sql: c.query_mode === "custom_sql" ? c.custom_sql : null,
detection_function: c.detection_function || "count(*)",
detection_function_field:
c.query_mode === "filters" && c.detection_function !== "count"
? c.detection_function_field || undefined
: undefined,
histogram_interval: anomalyHistogramInterval.value,
schedule_interval: anomalyScheduleInterval.value,
detection_window_seconds: anomalyDetectionWindowSeconds.value,
training_window_days: c.training_window_days,
retrain_interval_days: c.retrain_interval_days,
threshold: c.threshold,
alert_enabled: c.alert_enabled,
},
};
const routeAnomalyId = router.currentRoute.value.params
@@ -2979,14 +3066,7 @@ export default defineComponent({
});
}
emit("update:list");
router.push({
name: "alertList",
query: {
org_identifier: orgId,
tab: "anomalyDetection",
},
});
emit("update:list", (activeFolderId.value as string) || "default");
} catch (err: any) {
q.notify({
type: "negative",
@@ -101,7 +101,11 @@ describe("AlertHistoryDrawer.vue", () => {
});
afterEach(() => {
wrapper?.unmount();
try {
wrapper?.unmount();
} catch {
// Quasar teleported components can throw during unmount in jsdom
}
});
const mountComponent = async (
@@ -131,9 +135,6 @@ describe("AlertHistoryDrawer.vue", () => {
expect(
wrapper.find('[data-test="alert-details-title"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="alert-details-edit-btn"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="alert-details-close-btn"]').exists(),
).toBe(true);
@@ -397,11 +398,12 @@ describe("AlertHistoryDrawer.vue", () => {
});
describe("Pagination", () => {
it("should have pagination component", async () => {
it("should have pagination data initialized", async () => {
await mountComponent();
expect(
wrapper.findComponent({ name: "QTablePagination" }).exists(),
).toBe(true);
const vm = wrapper.vm as any;
expect(vm.pagination).toBeDefined();
expect(vm.pagination.rowsPerPage).toBe(50);
expect(vm.pagination.page).toBe(1);
});
it("should call getHistory when table requests data", async () => {
@@ -438,18 +440,6 @@ describe("AlertHistoryDrawer.vue", () => {
});
describe("Actions", () => {
it("should emit edit event when edit button is clicked", async () => {
await mountComponent();
const editBtn = wrapper.find(
'[data-test="alert-details-edit-btn"]',
);
await editBtn.trigger("click");
expect(wrapper.emitted("edit")).toBeTruthy();
expect(wrapper.emitted("edit")![0]).toEqual([mockAlertDetails]);
});
it("should not crash when alertDetails is null", async () => {
await mountComponent({
alertDetails: null,
+174 -50
View File
@@ -68,7 +68,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</span>
<!-- Alert Type Chip -->
<q-chip
v-if="alertDetails"
v-if="alertDetails && !isAnomaly"
dense
size="sm"
:icon="alertDetails.is_real_time ? 'bolt' : 'schedule'"
@@ -77,6 +77,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:text-color="alertDetails.is_real_time ? 'orange-9' : 'grey-8'"
class="tw:shrink-0"
/>
<q-chip
v-if="alertDetails && isAnomaly"
dense
size="sm"
icon="query_stats"
label="Anomaly Detection"
color="blue-1"
text-color="blue-8"
class="tw:shrink-0"
/>
<!-- Tab toggle -->
<div
class="tab-toggle tw:shrink-0"
@@ -120,17 +130,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-history-drawer-date-picker"
@on:date-change="updateDateTime"
/>
<q-btn
data-test="alert-details-edit-btn"
flat
round
dense
size="sm"
icon="edit"
@click="editAlertFromDrawer"
>
<q-tooltip>{{ t("alerts.edit") }}</q-tooltip>
</q-btn>
<q-btn
data-test="alert-details-close-btn"
v-close-popup="true"
@@ -280,6 +279,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ formatStatus(props.row.status) }}
</span>
<q-icon
v-if="props.row.error"
name="visibility"
size="14px"
class="tw:cursor-pointer tw:text-red-400"
>
<q-tooltip max-width="300px" class="tw:text-xs tw:break-words">
{{ props.row.error }}
</q-tooltip>
</q-icon>
</div>
</template>
<template v-else-if="col.name === 'timestamp'">
@@ -309,28 +318,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
}}
</span>
</template>
<template v-else-if="col.name === 'error'">
<div
v-if="props.row.error"
class="tw:flex tw:items-center tw:gap-1"
>
<q-icon
name="error_outline"
size="16px"
class="tw:text-red-500"
/>
<span
class="tw:text-[12px] tw:text-red-500 tw:truncate tw:max-w-[120px]"
>
{{ props.row.error }}
</span>
<q-tooltip
class="tw:text-xs tw:max-w-xs tw:break-words"
>
{{ props.row.error }}
</q-tooltip>
</div>
<span v-else class="tw:text-gray-400">—</span>
<template v-else-if="col.name === 'anomaly_count'">
<span class="tw:text-[13px] tw:tabular-nums" :class="props.row.anomaly_count > 0 ? 'tw:text-red-500 tw:font-medium' : ''">
{{ props.row.anomaly_count != null ? props.row.anomaly_count : "—" }}
</span>
</template>
</q-td>
</q-tr>
@@ -366,6 +357,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="tw:flex tw:flex-col tw:flex-1 tw:overflow-hidden tw:px-2 tw:pt-2 tw:pb-2"
>
<!-- Anomaly detection condition view — mirrors the alert SQL code block -->
<template v-if="isAnomaly">
<div
class="code-block tw:flex tw:flex-col tw:flex-1 tw:overflow-hidden"
:class="
store.state.theme === 'dark'
? 'code-block-dark'
: 'code-block-light'
"
>
<div
class="code-block-header tw:shrink-0"
:class="
store.state.theme === 'dark'
? 'code-block-header-dark'
: 'code-block-header-light'
"
>
<div class="tw:flex tw:items-center tw:gap-1.5">
<span
class="tw:text-[11px] tw:font-medium"
:class="
store.state.theme === 'dark'
? 'tw:text-gray-400'
: 'tw:text-gray-500'
"
>
SQL
</span>
</div>
<q-btn
v-if="anomalySql"
@click="copyToClipboard(anomalySql, 'SQL')"
flat
dense
size="xs"
icon="content_copy"
:color="store.state.theme === 'dark' ? 'grey-5' : 'grey-7'"
data-test="anomaly-details-copy-sql-btn"
>
<q-tooltip>{{ t("alerts.alertDetails.copy") }}</q-tooltip>
</q-btn>
</div>
<pre
class="code-block-content tw:text-[13px] tw:m-0 tw:leading-relaxed tw:flex-1 tw:overflow-y-auto"
>{{ anomalySql || t("alerts.alertDetails.noCondition") }}</pre
>
</div>
</template>
<!-- Regular alert condition view -->
<template v-else>
<div
class="code-block tw:flex tw:flex-col tw:flex-1 tw:overflow-hidden"
:class="
@@ -443,6 +486,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
}}</pre
>
</div>
</template>
<!-- Description (only show if exists) -->
<div v-if="alertDetails.description" class="tw:mt-3 tw:shrink-0">
@@ -476,13 +520,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ref, watch, computed } from "vue";
import { useStore } from "vuex";
import { useI18n } from "vue-i18n";
import { useQuasar, date } from "quasar";
import DateTime from "@/components/DateTime.vue";
import QTablePagination from "@/components/shared/grid/Pagination.vue";
import alertsService from "@/services/alerts";
import anomalyDetectionService from "@/services/anomaly_detection";
import { buildAnomalyPreviewSql } from "@/utils/alerts/anomalySqlBuilder";
import type { Ref } from "vue";
// Composables
@@ -494,11 +540,24 @@ const $q = useQuasar();
interface Props {
alertDetails: any;
alertId: string;
alertType?: string;
}
const props = defineProps<Props>();
const emit = defineEmits(["edit"]);
const isAnomaly = computed(() => props.alertType === "anomaly_detection");
// Full config fetched from the dedicated anomaly detection endpoint.
// The list API only returns summary fields; we need this for the Condition tab.
const fullAnomalyConfig = ref<any>(null);
const anomalySql = computed(() => {
const d = fullAnomalyConfig.value || props.alertDetails;
if (!d) return "";
return buildAnomalyPreviewSql(d);
});
const emit = defineEmits([]);
const resultTotal = ref(0);
@@ -570,8 +629,8 @@ const onPageChange = (page: number) => {
});
};
// Constants
const historyTableColumns = [
// Columns
const alertHistoryColumns = [
{
name: "#",
label: "#",
@@ -621,14 +680,63 @@ const historyTableColumns = [
},
];
const anomalyHistoryColumns = [
{
name: "#",
label: "#",
field: "#",
align: "left" as const,
sortable: false,
style: "width: 48px;",
},
{
name: "timestamp",
label: t("alerts.historyTable.timestamp"),
field: "timestamp",
align: "left" as const,
sortable: true,
style: "width: 140px;",
},
{
name: "status",
label: "Result",
field: "status",
align: "left" as const,
sortable: true,
style: "width: 120px;",
},
{
name: "evaluation_time",
label: t("alerts.historyTable.evaluationTime"),
field: "evaluation_took_in_secs",
align: "right" as const,
sortable: true,
style: "width: 130px;",
},
{
name: "anomaly_count",
label: "Anomalies",
field: "anomaly_count",
align: "right" as const,
sortable: true,
style: "width: 120px;",
},
];
const historyTableColumns = computed(() =>
isAnomaly.value ? anomalyHistoryColumns : alertHistoryColumns
);
// Helper Functions
const getStatusDotClass = (status: string) => {
switch (status?.toLowerCase()) {
case "firing":
case "error":
case "anomaly":
return "status-dot-error";
case "ok":
case "success":
case "normal":
return "status-dot-success";
case "skipped":
return "status-dot-warning";
@@ -643,9 +751,11 @@ const getStatusTextClass = (status: string) => {
switch (status?.toLowerCase()) {
case "firing":
case "error":
case "anomaly":
return "tw:text-red-500";
case "ok":
case "success":
case "normal":
return "tw:text-green-600";
case "skipped":
return "tw:text-amber-600";
@@ -663,6 +773,7 @@ const getRowClass = (status: string) => {
switch (status?.toLowerCase()) {
case "firing":
case "error":
case "anomaly":
return "row-error-dark";
default:
return "";
@@ -671,6 +782,7 @@ const getRowClass = (status: string) => {
switch (status?.toLowerCase()) {
case "firing":
case "error":
case "anomaly":
return "row-error-light";
default:
return "";
@@ -717,15 +829,20 @@ const fetchAlertHistory = async (alertId: string) => {
const endTime = dateTimeValues.value.endTime;
const from = (currentPage.value - 1) * selectedPerPage.value;
const historyParams: Record<string, any> = {
size: selectedPerPage.value,
from: from,
start_time: startTime,
end_time: endTime,
};
if (isAnomaly.value) {
historyParams.anomaly_id = alertId;
} else {
historyParams.alert_id = alertId;
}
const response = await alertsService.getHistory(
store?.state?.selectedOrganization?.identifier,
{
alert_id: alertId,
size: selectedPerPage.value,
from: from,
start_time: startTime,
end_time: endTime,
},
historyParams,
);
alertHistory.value = response.data?.hits || [];
resultTotal.value = response.data?.total || 0;
@@ -774,11 +891,6 @@ const updateDateTime = (value: any) => {
}
};
const editAlertFromDrawer = () => {
if (!props.alertDetails) return;
emit("edit", props.alertDetails);
};
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard
.writeText(text)
@@ -814,6 +926,18 @@ watch(
pagination: pagination.value,
});
}
// Fetch full config for the Condition tab when this is an anomaly detection alert.
if (isAnomaly.value) {
try {
const org = store?.state?.selectedOrganization?.identifier;
const res = await anomalyDetectionService.getConfig(org, newVal);
fullAnomalyConfig.value = res.data;
} catch {
fullAnomalyConfig.value = null;
}
} else {
fullAnomalyConfig.value = null;
}
}
},
{ immediate: true },
+17 -2
View File
@@ -15,6 +15,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin";
import { Dialog, Notify } from "quasar";
@@ -35,6 +36,8 @@ vi.mock("@/services/alerts", () => ({
delete_by_alert_id: vi.fn(),
create_by_alert_id: vi.fn(),
getHistory: vi.fn(),
export_by_id: vi.fn(),
retrain_by_id: vi.fn(),
},
}));
vi.mock("@/services/alert_templates", () => ({
@@ -172,6 +175,8 @@ beforeEach(() => {
// ensure foldersByType has 'alerts' key and alerts map exists
(store.state as any).organizationData.foldersByType = [{ type: 'alerts', folders: [{ id: 'default', name: 'Default' }] }];
(store.state as any).organizationData.allAlertsListByFolderId = {};
// Reset alert list filters to prevent leaking between tests
(store.state as any).alertListFilters = { searchQuery: "", filterQuery: "", searchAcrossFolders: false };
alertsDB = [
makeAlert(1, { is_real_time: false, enabled: true, name: "Scheduled Alert A", owner: "averylongownername@example.com" }),
@@ -209,6 +214,15 @@ beforeEach(() => {
return Promise.resolve({ data: { total: 0, hits: [] } } as any);
});
(alertsSvc.export_by_id as any) = vi.fn().mockImplementation(async (_org: any, id: string) => {
const alert = alertsDB.find((a) => a.alert_id === id) ?? { name: "exported" };
return Promise.resolve({ data: { ...alert } } as any);
});
(alertsSvc.retrain_by_id as any) = vi.fn().mockImplementation(async () => {
return Promise.resolve({ data: { code: 200 } } as any);
});
(alertsSvc.create_by_alert_id as any) = vi.fn().mockImplementation(async (_org: any, body: any, folder?: string) => {
const newId = `alert-${Math.floor(Math.random() * 100000)}`;
const cloned: AlertV2 = makeAlert(999, {
@@ -788,6 +802,7 @@ describe("AlertList - micro validations", () => {
const wrapper: any = await mountAlertList();
await waitData(wrapper);
wrapper.vm.filterQuery = "abc";
await nextTick();
wrapper.vm.searchAcrossFolders = true;
await flushPromises();
expect(wrapper.vm.searchQuery).toBe("abc");
@@ -890,14 +905,14 @@ describe("AlertList - isAnomalyDetectionEnabled", () => {
expect(tabValues).not.toContain("anomalyDetection");
});
it("should disable anomalyDetection tab when isCloud=true (cloud build)", async () => {
it("should enable anomalyDetection tab when isCloud=true (cloud build)", async () => {
(config as any).isEnterprise = "true";
(config as any).isCloud = "true";
(store.state as any).zoConfig.build_type = "enterprise";
const wrapper: any = await mountAlertList();
const tabValues = wrapper.vm.alertTabs.map((t: any) => t.value);
expect(tabValues).not.toContain("anomalyDetection");
expect(tabValues).toContain("anomalyDetection");
});
it("should disable anomalyDetection tab when isEnterprise=false (opensource frontend)", async () => {
+376 -94
View File
@@ -104,11 +104,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="q-ml-sm o2-primary-button tw:h-[36px]"
no-caps
flat
:disable="activeTab !== 'anomalyDetection' && !destinations.length"
:title="activeTab !== 'anomalyDetection' && !destinations.length ? t('alerts.noDestinations') : ''"
:disable="!destinations.length || !templates.length"
:title="!destinations.length ? t('alerts.noDestinations') : ''"
:label="t(`alerts.add`)"
@click="activeTab === 'anomalyDetection'
? router.push({ name: 'addAnomalyDetection', query: { org_identifier: store.state.selectedOrganization.identifier } })
? router.push({ name: 'addAnomalyDetection', query: { org_identifier: store.state.selectedOrganization.identifier, folder: activeFolderId, tab: activeTab } })
: showAddUpdateFn({})"
/>
</div>
@@ -178,7 +178,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:class="col.classes"
:style="col.style"
>
{{ col.label }}
<span :style="col.name === 'name' ? 'padding-left: 21px' : ''">{{ col.label }}</span>
</q-th>
</q-tr>
</template>
@@ -207,7 +207,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-td v-for="col in columns" :key="col.name" :props="props">
<template v-if="col.name === 'name'">
<div class="tw:flex tw:items-center tw:gap-1">
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon
v-if="props.row.is_real_time === 'anomaly'"
name="query_stats"
size="15px"
class="tw:text-blue-600 tw:shrink-0"
/>
<q-icon
v-else-if="props.row.is_real_time"
name="bolt"
size="15px"
class="tw:text-orange-500 tw:shrink-0"
/>
<q-icon
v-else
name="schedule"
size="15px"
class="tw:text-grey-7 tw:shrink-0"
/>
<span>{{ computedName(props.row[col.field]) }}</span>
</div>
<q-tooltip
@@ -234,8 +252,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ props.row[col.field] }}
</template>
<template v-else-if="col.name === 'status'">
<q-badge
:color="
props.row.status === 'failed' ? 'negative' :
props.row.status === 'active' ? 'positive' :
props.row.status === 'training' ? 'warning' :
props.row.status === 'disabled' ? 'grey' :
'info'
"
:label="props.row.status !== '--' ? props.row.status : ''"
style="text-transform: capitalize; cursor: default"
>
<q-tooltip
v-if="props.row.status === 'failed' && props.row.last_error"
max-width="400px"
anchor="top middle"
self="bottom middle"
>
{{ props.row.last_error }}
</q-tooltip>
</q-badge>
<span v-if="props.row.status === '--'">--</span>
</template>
<template v-else-if="col.name === 'period'">
{{ props.row[col.field] ? props.row[col.field] + " Mins" : "--" }}
{{ props.row[col.field] ? (props.row[col.field] >= 60 ? (props.row[col.field] % 60 === 0 ? `${Math.floor(props.row[col.field] / 60)} Hours` : `${Math.floor(props.row[col.field] / 60)} Hours ${props.row[col.field] % 60} Mins`) : `${props.row[col.field]} Mins`) : "--" }}
</template>
<template v-else-if="col.name === 'frequency'">
{{ props.row[col.field] ? props.row[col.field] + (props.row?.frequency_type == "cron" ? "" : " Mins") : "--" }}
@@ -364,7 +405,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-item-section>Export</q-item-section>
</q-item>
<q-separator />
<!-- Anomaly Detection: Trigger Detection + Re-train (always available) -->
<template v-if="props.row.type === 'anomaly'">
<q-item
class="flex items-center justify-center"
clickable
v-close-popup
:data-test="`alert-list-${props.row.name}-trigger-detection`"
@click="triggerAlert(props.row)"
>
<q-item-section dense avatar>
<q-icon size="16px" :name="symOutlinedSoundSampler" />
</q-item-section>
<q-item-section>Trigger Detection</q-item-section>
</q-item>
<q-item
class="flex items-center justify-center"
clickable
v-close-popup
:data-test="`alert-list-${props.row.name}-retrain-anomaly`"
@click="retrainAnomaly(props.row)"
>
<q-item-section dense avatar>
<q-icon size="16px" name="replay" />
</q-item-section>
<q-item-section>Re-train</q-item-section>
</q-item>
</template>
<!-- Regular alerts: existing Trigger Alert item -->
<q-item
v-else
class="flex items-center justify-center"
clickable
v-close-popup
@@ -559,6 +629,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:destinations="destinations"
:templates="templates"
:alerts="store?.state?.organizationData?.allAlertsListByFolderId[activeFolderId]"
:folderId="activeFolderId"
@update:alerts="refreshImportedAlerts"
@update:destinations="refreshDestination"
@update:templates="getTemplates"
@@ -683,6 +754,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<MoveAcrossFolders
:activeFolderId="activeFolderToMove"
:moduleId="selectedAlertToMove"
:anomalyConfigIds="selectedAnomalyConfigsToMove"
type="alerts"
@updated="updateAcrossFolders"
/>
@@ -699,6 +771,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<AlertHistoryDrawer
:alert-details="selectedAlertDetails"
:alert-id="selectedAlertDetails?.alert_id || ''"
:alert-type="selectedAlertDetails?.alert_type"
@close="showAlertDetailsDrawer = false"
@edit="editAlertFromDrawer"
/>
@@ -797,6 +870,13 @@ export default defineComponent({
const { track } = useReo();
const formData: Ref<Alert | {}> = ref({});
const toBeClonedAlert: Ref<any> = ref({});
// Flag to skip clearing search state on the first folder emission after mount.
// When returning from add/edit screens, the FolderList emits the active folder
// which would normally clear restored search filters. This flag prevents that.
const savedAlertListFilters = store.state.alertListFilters || {};
const isRestoringState = ref(!!savedAlertListFilters.searchQuery
|| !!savedAlertListFilters.filterQuery
|| !!savedAlertListFilters.searchAcrossFolders);
const showAddAlertDialog: any = ref(false);
const qTable: Ref<InstanceType<typeof QTable> | null> = ref(null);
const selectedDelete: any = ref(null);
@@ -819,6 +899,7 @@ export default defineComponent({
const toBeCloneAlertName = ref("");
const toBeClonedID = ref("");
const toBeClonedIsAnomaly = ref(false);
const toBeClonestreamType = ref("");
const toBeClonestreamName = ref("");
const streamTypes = ref(["logs", "metrics", "traces"]);
@@ -921,21 +1002,13 @@ export default defineComponent({
document.addEventListener('click', handleClickOutside, true);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('click', handleClickOutside, true);
});
const activeFolderToMove = ref("default");
// Show anomaly detection only when the backend is an enterprise or cloud build.
// The frontend build flag alone is not sufficient — an enterprise UI can be
// pointed at an OSS backend which does not have the feature.
const isAnomalyDetectionEnabled = computed(
() =>
store.state.zoConfig.build_type !== "opensource" &&
config.isEnterprise === "true" &&
config.isCloud === "false",
config.isEnterprise === "true",
);
// Initialize activeTab from URL query parameter, default to "all".
@@ -947,6 +1020,59 @@ export default defineComponent({
: rawTab,
);
const filteredResults: Ref<any[]> = ref([]);
// ── Anomaly polling ──────────────────────────────────────────────────────
// Statuses that mean the anomaly job is done — no need to keep polling
const ANOMALY_TERMINAL_STATUSES = ["failed", "active", "completed"];
const hasNonTerminalAnomalyRows = computed(
() =>
activeTab.value === "anomalyDetection" &&
filteredResults.value.some(
(row: any) =>
row.type === "anomaly" &&
!ANOMALY_TERMINAL_STATUSES.includes(row.status),
),
);
let anomalyPollingTimer: ReturnType<typeof setInterval> | null = null;
const stopAnomalyPolling = () => {
if (anomalyPollingTimer !== null) {
clearInterval(anomalyPollingTimer);
anomalyPollingTimer = null;
}
};
const startAnomalyPolling = () => {
if (anomalyPollingTimer !== null) return; // already running
anomalyPollingTimer = setInterval(async () => {
if (!hasNonTerminalAnomalyRows.value) {
stopAnomalyPolling();
return;
}
await getAlertsFn(store, activeFolderId.value);
}, 10000);
};
watch(hasNonTerminalAnomalyRows, (hasNonTerminal) => {
if (hasNonTerminal) {
startAnomalyPolling();
} else {
stopAnomalyPolling();
}
});
// ── End anomaly polling ──────────────────────────────────────────────────
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('click', handleClickOutside, true);
stopAnomalyPolling();
});
const activeFolderToMove = ref("default");
// Tabs for alerts view
const alertTabs = computed(() => {
const tabs: { label: string; value: string }[] = [
@@ -1000,19 +1126,8 @@ export default defineComponent({
sortable: true,
style: "width: 150px",
},
// Anomaly Detection tab — extra columns
...(activeTab.value === 'anomalyDetection' ? [
{
name: "detection_window",
field: "detection_window",
label: "Detection Window",
align: "center" as const,
sortable: true,
style: "width: 150px",
},
] : []),
// "period" column — conditional
...(activeTab.value !== 'realTime' && activeTab.value !== 'anomalyDetection' ? [{
// "period" (Look back window) — all tabs except realTime
...(activeTab.value !== 'realTime' ? [{
name: "period",
field: "period",
label: t("alerts.period"),
@@ -1020,8 +1135,8 @@ export default defineComponent({
sortable: true,
style: "width: 150px",
}] : []),
// "frequency" column — conditional
...(activeTab.value !== 'realTime' && activeTab.value !== 'anomalyDetection' ? [{
// "frequency" (Check every) — all tabs except realTime
...(activeTab.value !== 'realTime' ? [{
name: "frequency",
field: "frequency",
label: t("alerts.frequency"),
@@ -1055,6 +1170,14 @@ export default defineComponent({
sortable: true,
style: "width: 150px",
},
{
name: "status",
field: "status",
label: "Status",
align: "left" as const,
sortable: true,
style: "width: 120px",
},
] : []),
{
name: "actions",
@@ -1088,50 +1211,69 @@ export default defineComponent({
const allSelectedAlerts = ref(false);
const allAlerts: Ref<any[]> = ref([]);
const searchQuery = ref<any>("");
const filterQuery = ref<any>("");
const searchAcrossFolders = ref<any>(false);
const filteredResults: Ref<any[]> = ref([]);
const selectedAlertToMove: Ref<any> = ref({});
const searchQuery = ref<any>(savedAlertListFilters.searchQuery || "");
const filterQuery = ref<any>(savedAlertListFilters.filterQuery || "");
const searchAcrossFolders = ref<any>(savedAlertListFilters.searchAcrossFolders || false);
const selectedAlertToMove: Ref<any[]> = ref([]);
const selectedAnomalyConfigsToMove: Ref<any[]> = ref([]);
const folderIdToBeCloned = ref<any>(router.currentRoute.value.query.folder ?? "default");
// ---------------------------------------------------------------------------
// Anomaly detection scaffolding — TEMPORARY until backend returns anomaly
// data as part of the alert list API. Remove this block when that happens.
// ---------------------------------------------------------------------------
// Normalizes an anomaly-detection item returned by the merged alerts list API
// (alert_type === "anomaly_detection") into the standard alert row shape.
const normalizeAnomalyToAlertRow = (anomaly: any, counter: number): any => ({
"#": counter <= 9 ? `0${counter}` : `${counter}`,
alert_id: anomaly.anomaly_id,
anomaly_id: anomaly.anomaly_id,
alert_id: anomaly.alert_id || anomaly.anomaly_id || anomaly.id,
anomaly_id: anomaly.alert_id || anomaly.anomaly_id || anomaly.id,
name: anomaly.name,
alert_type: "Anomaly Detection",
alert_type: "anomaly_detection",
stream_name: anomaly.stream_name || "--",
stream_type: anomaly.stream_type || "--",
enabled: anomaly.enabled,
conditions: "--",
rawCondition: {},
detection_function: anomaly.detection_function || "",
query_mode: anomaly.query_mode || "filters",
custom_sql: anomaly.custom_sql || "",
filters: anomaly.filters || [],
histogram_interval: anomaly.histogram_interval || "",
description: anomaly.description || "",
uuid: getUUID(),
owner: anomaly.owner || "",
period: "",
frequency: anomaly.schedule_interval != null ? String(anomaly.schedule_interval) : "--",
frequency_type: "cron",
last_triggered_at: anomaly.last_detection_run
? convertUnixToQuasarFormat(anomaly.last_detection_run)
period: anomaly.trigger_condition?.period ?? "",
frequency: anomaly.trigger_condition?.frequency ?? "",
frequency_type: anomaly.trigger_condition?.frequency_type ?? "minutes",
last_triggered_at: anomaly.last_triggered_at
? convertUnixToQuasarFormat(anomaly.last_triggered_at)
: "",
last_satisfied_at: anomaly.last_anomaly_detected_at
? convertUnixToQuasarFormat(anomaly.last_anomaly_detected_at)
last_satisfied_at: anomaly.last_satisfied_at
? convertUnixToQuasarFormat(anomaly.last_satisfied_at)
: "",
detection_window: anomaly.detection_window_seconds
? (anomaly.detection_window_seconds >= 3600 && anomaly.detection_window_seconds % 3600 === 0
? `${anomaly.detection_window_seconds / 3600}h`
: `${Math.round(anomaly.detection_window_seconds / 60)} mins`)
: "--",
last_trained_at: anomaly.training_completed_at
? convertUnixToQuasarFormat(anomaly.training_completed_at)
detection_window: (() => {
const mins = anomaly.trigger_condition?.period_minutes;
if (!mins) return "--";
if (mins >= 60) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return m === 0 ? `${h} Hours` : `${h} Hours ${m} Mins`;
}
return `${mins} mins`;
})(),
last_trained_at: anomaly.last_trained_at
? convertUnixToQuasarFormat(anomaly.last_trained_at)
: "",
total_evaluations: anomaly.total_evaluations ?? "--",
firing_count: anomaly.firing_count ?? "--",
status: anomaly.status || "--",
last_error: anomaly.last_error || null,
selected: false,
type: "anomaly",
folder_name: { name: "—", id: "" },
folder_name: {
name: anomaly.folder_name || "default",
id: anomaly.folder_id || "",
},
// Marker: "anomaly" distinguishes these rows from boolean is_real_time alerts
is_real_time: "anomaly",
});
@@ -1193,7 +1335,14 @@ export default defineComponent({
//localAllAlerts is the alerts that we use to store
// PERFORMANCE OPTIMIZATION: Store raw condition data without conversion
// Conversion happens lazily only when user expands an alert (in triggerExpand)
// Anomaly detection items (alert_type === "anomaly_detection") are handled by
// normalizeAnomalyToAlertRow which reads period/frequency from trigger_condition.
localAllAlerts = localAllAlerts.map((data: any) => {
if (data.alert_type === "anomaly_detection") {
const num = counter++;
return normalizeAnomalyToAlertRow(data, num);
}
let frequency = "";
if (data.trigger_condition?.frequency_type == "cron") {
frequency = data.trigger_condition.cron;
@@ -1232,26 +1381,6 @@ export default defineComponent({
is_real_time: data.is_real_time,
};
});
// ---------------------------------------------------------------------------
// TEMPORARY: Fetch anomaly detections and merge as synthetic alert rows.
// Remove this block once the backend returns anomaly data in the alert list API.
// ---------------------------------------------------------------------------
if (isAnomalyDetectionEnabled.value) {
try {
const anomalyRes = await anomalyDetectionService.list(
store?.state?.selectedOrganization?.identifier,
);
const anomalyList: any[] = anomalyRes.data?.list ?? anomalyRes.data ?? [];
let anomalyCounter = localAllAlerts.length + 1;
const anomalyRows = anomalyList.map((a: any) =>
normalizeAnomalyToAlertRow(a, anomalyCounter++),
);
localAllAlerts = [...localAllAlerts, ...anomalyRows];
} catch (anomalyError) {
console.warn("Failed to fetch anomaly detections:", anomalyError);
}
}
// ---------------------------------------------------------------------------
//this is the condition where we are setting the alertStateLoadingMap
localAllAlerts.forEach((alert: any) => {
@@ -1552,14 +1681,75 @@ export default defineComponent({
const duplicateAlert = async (row: any) => {
toBeClonedID.value = row.alert_id;
toBeCloneAlertName.value = row.name;
toBeClonedIsAnomaly.value = row.type === "anomaly";
toBeClonestreamName.value = "";
toBeClonestreamType.value = "";
showForm.value = true;
toBeClonedAlert.value = await getAlertById(row.alert_id);
// Anomaly rows use the /clone endpoint — no need to pre-fetch full data
if (!toBeClonedIsAnomaly.value) {
toBeClonedAlert.value = await getAlertById(row.alert_id);
}
};
const submitForm = async () => {
// Anomaly rows: use the dedicated /clone endpoint (no fetch+mutate dance needed)
if (toBeClonedIsAnomaly.value) {
if (!toBeClonestreamType.value) {
$q.notify({
type: "negative",
message: "Please select stream type ",
timeout: 2000,
});
return;
}
if (!toBeClonestreamName.value) {
$q.notify({
type: "negative",
message: "Please select stream name",
timeout: 2000,
});
return;
}
isSubmitting.value = true;
const dismiss = $q.notify({
spinner: true,
message: "Please wait...",
timeout: 2000,
});
try {
await alertsService.clone_by_id(
store.state.selectedOrganization.identifier,
toBeClonedID.value,
{
name: toBeCloneAlertName.value,
folder_id: (folderIdToBeCloned.value as string) || "default",
stream_type: toBeClonestreamType.value,
stream_name: toBeClonestreamName.value,
},
);
dismiss();
$q.notify({
type: "positive",
message: "Anomaly detection cloned successfully",
timeout: 2000,
});
showForm.value = false;
await getAlertsFn(store, folderIdToBeCloned.value);
activeFolderId.value = folderIdToBeCloned.value;
} catch (e: any) {
dismiss();
$q.notify({
type: "negative",
message: e?.response?.data?.message || "Failed to clone anomaly detection",
timeout: 2000,
});
} finally {
isSubmitting.value = false;
}
return;
}
if (!toBeClonedAlert.value) {
$q.notify({
type: "negative",
@@ -1708,14 +1898,20 @@ export default defineComponent({
}
};
const refreshList = async (folderId: string) => {
const refreshList = async (folderId?: string) => {
//here we are fetching the alerts from the server because after creating the alert we should get the latest alerts
//and then we are setting the activeFolderId to the folderId
//this is done to avoid multiple api calls , when we assign the folderId before fetching it will trigger the watch and it will fetch the alerts again
//and we dont need to fetch the alerts again because we are already fetching the alerts in the getAlertsFn
await getAlertsFn(store, folderId);
const resolvedFolderId = folderId || activeFolderId.value || "default";
// Always fetch the latest alerts for the folder from backend
await getAlertsFn(store, resolvedFolderId);
// Re-apply active search/filter on the freshly fetched data
if (filterQuery.value) {
filterAlertsByQuery(filterQuery.value);
}
await hideForm();
activeFolderId.value = folderId;
activeFolderId.value = resolvedFolderId;
};
const hideForm = async () => {
showAddAlertDialog.value = false;
@@ -1724,6 +1920,7 @@ export default defineComponent({
query: {
org_identifier: store.state.selectedOrganization.identifier,
folder: activeFolderId.value,
tab: activeTab.value,
},
});
track("Button Click", {
@@ -1908,8 +2105,12 @@ export default defineComponent({
};
const exportAlert = async (row: any) => {
// Find the alert based on uuid
const alertToBeExported = await getAlertById(row.alert_id);
// Use the /export endpoint — strips runtime fields for anomaly configs, works for regular alerts too
const res = await alertsService.export_by_id(
store.state.selectedOrganization.identifier,
row.alert_id,
);
const alertToBeExported = res.data;
if (alertToBeExported.hasOwnProperty("id")) {
delete alertToBeExported.id;
@@ -1956,6 +2157,9 @@ export default defineComponent({
message: t("alerts.alertTriggeredSuccess"),
timeout: 2000,
});
if (row.type === "anomaly") {
await getAlertsFn(store, activeFolderId.value);
}
} catch (error: any) {
$q.notify({
type: "negative",
@@ -1965,14 +2169,61 @@ export default defineComponent({
}
};
const retrainAnomaly = async (row: any) => {
try {
await alertsService.retrain_by_id(
store.state.selectedOrganization.identifier,
row.alert_id,
);
row.status = "training";
$q.notify({
type: "positive",
message: "Retraining triggered",
timeout: 2000,
});
} catch (error: any) {
$q.notify({
type: "negative",
message: error?.response?.data?.message || "Failed to trigger retraining",
timeout: 2000,
});
}
};
const updateActiveFolderId = async (newVal: any) => {
//this is the condition we kept because when we we click on the any folder that is there in the the row when we do search across folders
//at that time if it is the same folder it wont trigger the watch and it will show the alerts of the filtered only
//at that time if it is the same folder it wont trigger the watch and it will show the alerts of the filtered only
//so we are fetching the alerts by the folderId and then filtering the alerts by the activeTab this is done explicitly on only if users clicks on same folder
if(newVal == activeFolderId.value){
getAlertsByFolderId(store, newVal);
if (isRestoringState.value) {
// Force fresh fetch from backend (cache may be stale after add/edit on another route)
isRestoringState.value = false;
await getAlertsFn(store, newVal);
if (searchAcrossFolders.value && searchQuery.value) {
await getAlertsFn(store, activeFolderId.value, searchQuery.value);
filterAlertsByTab();
} else if (filterQuery.value) {
filterAlertsByQuery(filterQuery.value);
} else {
filterAlertsByTab();
}
return;
}
await getAlertsByFolderId(store, newVal);
filterAlertsByTab();
}
// When restoring state from a prior navigation, skip clearing search filters
if (isRestoringState.value) {
isRestoringState.value = false;
await getAlertsFn(store, newVal);
if (filterQuery.value) {
filterAlertsByQuery(filterQuery.value);
} else {
filterAlertsByTab();
}
activeFolderId.value = newVal;
return;
}
//here we are resetting the searchQuery, filterQuery, searchAcrossFolders, allSelectedAlerts, selectedAlerts
//here we only reset if the value is not null
if(searchQuery.value) searchQuery.value = "";
@@ -1990,6 +2241,15 @@ export default defineComponent({
};
const editAlert = async (row: any) => {
// Anomaly detection rows route to the dedicated edit page
if (row.type === "anomaly") {
await router.push({
name: "editAnomalyDetection",
params: { anomaly_id: row.alert_id },
query: { org_identifier: store.state.selectedOrganization.identifier, folder: activeFolderId.value, tab: activeTab.value },
});
return;
}
// Don't fetch alert data here - let the watcher handle it to avoid duplicate API calls
// Just trigger the route change with alert_id, the watcher will fetch and call showAddUpdateFn
await router.push({
@@ -2011,14 +2271,19 @@ export default defineComponent({
// Close the drawer first
showAlertDetailsDrawer.value = false;
// Get the full alert data and open the edit form
const selectedAlert = await getAlertById(selectedAlertDetails.value.alert_id);
showAddUpdateFn({ row: selectedAlert });
// Reuse the same edit flow as the listing page
await editAlert(selectedAlertDetails.value);
};
const moveAlertToAnotherFolder = (row: any) => {
showMoveAlertDialog.value = true;
selectedAlertToMove.value = [row.alert_id];
if (row.type === "anomaly") {
selectedAlertToMove.value = [];
selectedAnomalyConfigsToMove.value = [row.alert_id];
} else {
selectedAlertToMove.value = [row.alert_id];
selectedAnomalyConfigsToMove.value = [];
}
activeFolderToMove.value = activeFolderId.value;
};
@@ -2031,6 +2296,7 @@ export default defineComponent({
await getAlertsFn(store, activeFolderId);
showMoveAlertDialog.value = false;
selectedAlertToMove.value = [];
selectedAnomalyConfigsToMove.value = [];
activeFolderToMove.value = "";
selectedAlerts.value = [];
allSelectedAlerts.value = false;
@@ -2046,10 +2312,12 @@ export default defineComponent({
const moveMultipleAlerts = () => {
showMoveAlertDialog.value = true;
const selectedAlertsToMove = selectedAlerts.value.map((alert: any) => {
return alert.alert_id;
});
selectedAlertToMove.value = selectedAlertsToMove;
selectedAlertToMove.value = selectedAlerts.value
.filter((alert: any) => alert.type !== "anomaly")
.map((alert: any) => alert.alert_id);
selectedAnomalyConfigsToMove.value = selectedAlerts.value
.filter((alert: any) => alert.type === "anomaly")
.map((alert: any) => alert.alert_id);
activeFolderToMove.value = activeFolderId.value;
};
@@ -2113,6 +2381,17 @@ export default defineComponent({
searchQuery.value = null;
}
});
// Persist filter state to Vuex so it survives navigation to add/edit screens
watch(
[searchQuery, filterQuery, searchAcrossFolders],
() => {
store.commit("setAlertListFilters", {
searchQuery: searchQuery.value || "",
filterQuery: filterQuery.value || "",
searchAcrossFolders: !!searchAcrossFolders.value,
});
},
);
watch(activeTab, async (newVal) => {
//here we are resetting the filterQuery when the activeTab is changed
//this is done because we need to trigger again the filterQuery watch
@@ -2169,11 +2448,11 @@ export default defineComponent({
const alertsData = await Promise.all(
selectedAlertsToExport.map(async (alertId: string) => {
const alertData = await getAlertById(alertId);
if (alertData.hasOwnProperty("id")) {
delete alertData.id;
}
return alertData;
const res = await alertsService.export_by_id(
store.state.selectedOrganization.identifier,
alertId,
);
return res.data;
}),
);
alertToBeExported.push(...alertsData);
@@ -2475,6 +2754,7 @@ export default defineComponent({
showAddAlertDialog,
showForm,
toBeCloneAlertName,
toBeClonedIsAnomaly,
toBeClonestreamType,
toBeClonestreamName,
streamTypes,
@@ -2520,6 +2800,7 @@ export default defineComponent({
getTemplates,
exportAlert,
triggerAlert,
retrainAnomaly,
updateActiveFolderId,
activeFolderId,
editAlert,
@@ -2528,6 +2809,7 @@ export default defineComponent({
showMoveAlertDialog,
outlinedDriveFileMove,
selectedAlertToMove,
selectedAnomalyConfigsToMove,
moveAlertToAnotherFolder,
activeFolderToMove,
updateAcrossFolders,
+56 -2
View File
@@ -411,6 +411,7 @@ import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { useQuasar } from "quasar";
import alertsService from "../../services/alerts";
import anomalyDetectionService from "../../services/anomaly_detection";
import useStreams from "@/composables/useStreams";
import BaseImport from "../common/BaseImport.vue";
import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue";
@@ -440,6 +441,10 @@ export default defineComponent({
type: Array,
default: () => [],
},
folderId: {
type: String,
default: "",
},
},
emits: ["update:destinations", "update:templates", "update:alerts"],
setup(props, { emit }) {
@@ -486,10 +491,11 @@ export default defineComponent({
});
const streamTypes = ["logs", "metrics", "traces"];
const selectedFolderId = ref<any>(
router.currentRoute.value.query.folder || "default",
props.folderId || router.currentRoute.value.query.folder || "default",
);
const activeFolderId = ref(
router.currentRoute.value.query.folder ||
props.folderId ||
router.currentRoute.value.query.folder ||
router.currentRoute.value.query?.folderId,
);
const activeFolderAlerts = ref<any>([]);
@@ -570,6 +576,7 @@ export default defineComponent({
onMounted(() => {
activeFolderId.value =
props.folderId ||
router.currentRoute.value.query?.folder ||
router.currentRoute.value.query?.folderId;
getActiveFolderAlerts(activeFolderId.value as string);
@@ -643,8 +650,55 @@ export default defineComponent({
}
};
const importAnomalyConfig = async (jsonObj: any, index: number) => {
try {
const org = store.state.selectedOrganization.identifier;
// Convert the exported anomaly config (GET format) back to the create (POST) format.
const payload: any = {
alert_type: "anomaly_detection",
name: jsonObj.name,
stream_name: jsonObj.stream_name,
stream_type: jsonObj.stream_type,
destinations: [],
anomaly_config: {
detection_function: jsonObj.detection_function,
histogram_interval: jsonObj.histogram_interval,
schedule_interval: jsonObj.schedule_interval,
detection_window_seconds: jsonObj.detection_window_seconds,
training_window_days: jsonObj.training_window_days,
retrain_interval_days: jsonObj.retrain_interval_days ?? 0,
threshold: jsonObj.threshold,
seasonality: jsonObj.seasonality ?? "none",
query_mode: jsonObj.query_mode ?? "filters",
filters: jsonObj.filters ?? [],
custom_sql: jsonObj.custom_sql ?? "",
alert_destinations: jsonObj.alert_destinations ?? [],
alert_enabled: jsonObj.alert_enabled ?? true,
},
};
await anomalyDetectionService.create(org, payload);
alertCreators.value.push({
message: `Anomaly Detection - ${index}: "${jsonObj.name}" imported successfully`,
success: true,
});
return true;
} catch (e: any) {
alertCreators.value.push({
message: `Anomaly Detection - ${index}: "${jsonObj.name}" import failed — ${e?.response?.data?.message || "Unknown Error"}`,
success: false,
});
return false;
}
};
const processJsonObject = async (jsonObj: any, index: number) => {
try {
// Anomaly detection configs have anomaly_id — route to separate import path.
// Regular alert flow is completely unchanged.
if (jsonObj.anomaly_id !== undefined) {
return await importAnomalyConfig(jsonObj, index);
}
const isValidAlert = await validateAlertInputs(jsonObj, index);
if (!isValidAlert) {
return false;
@@ -342,7 +342,11 @@ export default defineComponent({
};
const formatSeconds = (secs: number) => {
if (secs >= 3600 && secs % 3600 === 0) return `${secs / 3600}h`;
if (secs >= 3600) {
const h = Math.floor(secs / 3600);
const m = Math.round((secs % 3600) / 60);
return m === 0 ? `${h} Hours` : `${h} Hours ${m} Mins`;
}
return `${Math.round(secs / 60)} mins`;
};
@@ -59,20 +59,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="tw:flex tw:flex-col">
<div class="tw:flex tw:items-center">
<q-select
v-model="config.alert_destination_id"
v-model="config.alert_destination_ids"
:options="filteredDestinations"
option-label="name"
option-value="name"
emit-value
map-options
multiple
use-chips
dense
borderless
use-input
input-debounce="300"
style="width: 300px; background: none"
style="min-width: 300px; max-width: 420px; background: none"
data-test="anomaly-destination"
@filter="filterDestinations"
>
<template #option="{ itemProps, opt, selected, toggleOption }">
<q-item v-bind="itemProps">
<q-item-section side>
<q-checkbox
:model-value="selected"
dense
@update:model-value="toggleOption(opt)"
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ opt.name }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #no-option>
<q-item>
<q-item-section class="text-grey">No destinations found</q-item-section>
@@ -101,11 +117,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<div
v-if="config.alert_enabled && !config.alert_destination_id"
v-if="config.alert_enabled && config.alert_destination_ids.length === 0"
class="text-red-8 q-pt-xs"
style="font-size: 11px; line-height: 12px"
data-test="anomaly-destination-error"
>
Field is required!
At least one destination is required!
</div>
</div>
</div>
@@ -0,0 +1,467 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
// vi.mock must be hoisted — declared before component import
vi.mock("@/services/stream", () => ({
default: {
schema: vi.fn().mockResolvedValue({ data: { schema: [] } }),
},
}));
vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({
default: { template: '<div data-test="panel-schema-renderer" />' },
}));
vi.mock("@/components/QueryEditor.vue", () => ({
default: {
template: '<div data-test="query-editor" />',
props: [
"query",
"editorId",
"language",
"readOnly",
"showAutoComplete",
"hideNlToggle",
],
},
}));
import AnomalyDetectionConfig from "./AnomalyDetectionConfig.vue";
installQuasar();
// ---------------------------------------------------------------------------
// Mount factory — keeps stubs and global plugins in one place
// ---------------------------------------------------------------------------
function mountConfig(configOverrides: Record<string, unknown> = {}) {
const config = {
query_mode: "filters",
stream_name: "my_stream",
stream_type: "logs",
histogram_interval_value: 5,
histogram_interval_unit: "m",
detection_function: "count",
detection_function_field: "",
filters: [] as Array<{ field: string; operator: string; value: string }>,
custom_sql: "",
detection_window_value: 30,
detection_window_unit: "m",
training_window_days: 7,
threshold: 97,
threshold_min: 0,
...configOverrides,
};
return mount(AnomalyDetectionConfig, {
global: {
plugins: [store, i18n],
stubs: {
PanelSchemaRenderer: true,
QueryEditor: true,
},
},
props: { config },
});
}
// ---------------------------------------------------------------------------
// Helper: call loadPreview() and return the normalised SQL string
// ---------------------------------------------------------------------------
async function getSqlFromPreview(wrapper: VueWrapper): Promise<string> {
(wrapper.vm as any).loadPreview();
await flushPromises();
return (wrapper.vm as any).previewPanelSchema?.queries?.[0]?.query ?? "";
}
// ===========================================================================
describe("AnomalyDetectionConfig", () => {
let wrapper: VueWrapper;
afterEach(() => {
wrapper?.unmount();
vi.clearAllMocks();
});
// =========================================================================
describe("filters mode — detection function", () => {
it("should use count(*) AS value when detection_function is count", async () => {
wrapper = mountConfig({ detection_function: "count" });
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("count(*) AS value");
});
it("should use count(*) AS value when detection_function is not set", async () => {
wrapper = mountConfig({ detection_function: "" });
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("count(*) AS value");
});
it("should use avg(response_time) AS value when detection_function is avg", async () => {
wrapper = mountConfig({
detection_function: "avg",
detection_function_field: "response_time",
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("avg(response_time) AS value");
});
it("should use sum(bytes) AS value when detection_function is sum", async () => {
wrapper = mountConfig({
detection_function: "sum",
detection_function_field: "bytes",
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("sum(bytes) AS value");
});
it("should use p95(latency) AS value when detection_function is p95", async () => {
wrapper = mountConfig({
detection_function: "p95",
detection_function_field: "latency",
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("p95(latency) AS value");
});
it("should use p99(latency) AS value when detection_function is p99", async () => {
wrapper = mountConfig({
detection_function: "p99",
detection_function_field: "latency",
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("p99(latency) AS value");
});
it("should include the stream name in FROM clause", async () => {
wrapper = mountConfig({ stream_name: "my_stream" });
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain('FROM "my_stream"');
});
it("should include GROUP BY and ORDER BY time_bucket", async () => {
wrapper = mountConfig();
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("GROUP BY time_bucket");
expect(sql).toContain("ORDER BY time_bucket");
});
it("should produce a full default count query with no filters", async () => {
wrapper = mountConfig();
const sql = await getSqlFromPreview(wrapper);
expect(sql).toBe(
"SELECT histogram(_timestamp, '5m') AS time_bucket, count(*) AS value FROM \"my_stream\" GROUP BY time_bucket ORDER BY time_bucket",
);
});
});
// =========================================================================
describe("filters mode — histogram interval", () => {
it("should embed 15m interval when histogram_interval_value is 15 and unit is m", async () => {
wrapper = mountConfig({
histogram_interval_value: 15,
histogram_interval_unit: "m",
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("histogram(_timestamp, '15m')");
});
it("should embed 1h interval when histogram_interval_value is 1 and unit is h", async () => {
wrapper = mountConfig({
histogram_interval_value: 1,
histogram_interval_unit: "h",
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("histogram(_timestamp, '1h')");
});
it("should leave previewPanelSchema null when stream_name is empty", async () => {
wrapper = mountConfig({ stream_name: "" });
(wrapper.vm as any).loadPreview();
await flushPromises();
expect((wrapper.vm as any).previewPanelSchema).toBeNull();
});
});
// =========================================================================
describe("filters mode — WHERE clause from filters", () => {
it("should not include WHERE clause when filters array is empty", async () => {
wrapper = mountConfig({ filters: [] });
const sql = await getSqlFromPreview(wrapper);
expect(sql).not.toContain("WHERE");
});
it("should include WHERE status = '200' for a single equality filter", async () => {
wrapper = mountConfig({
filters: [{ field: "status", operator: "=", value: "200" }],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE status = '200'");
});
it("should chain additional filters with AND for multiple filters", async () => {
wrapper = mountConfig({
filters: [
{ field: "status", operator: "=", value: "200" },
{ field: "env", operator: "=", value: "prod" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE status = '200'");
expect(sql).toContain("AND env = 'prod'");
});
it("should include field IS NULL for an Is Null filter", async () => {
wrapper = mountConfig({
filters: [{ field: "error_msg", operator: "Is Null", value: "" }],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE error_msg IS NULL");
});
it("should include field IS NOT NULL for an Is Not Null filter", async () => {
wrapper = mountConfig({
filters: [{ field: "error_msg", operator: "Is Not Null", value: "" }],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE error_msg IS NOT NULL");
});
it("should include field IN (values) for an IN filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "status", operator: "IN", value: "200,404,500" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE status IN (200,404,500)");
});
it("should include field NOT IN (values) for a NOT IN filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "status", operator: "NOT IN", value: "500,503" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE status NOT IN (500,503)");
});
it("should use str_match for a Contains filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "message", operator: "Contains", value: "error" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE str_match(message, 'error')");
});
it("should use str_match for a str_match filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "message", operator: "str_match", value: "timeout" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE str_match(message, 'timeout')");
});
it("should use str_match_ignore_case for a str_match_ignore_case filter", async () => {
wrapper = mountConfig({
filters: [
{
field: "message",
operator: "str_match_ignore_case",
value: "ERROR",
},
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain(
"WHERE str_match_ignore_case(message, 'ERROR')",
);
});
it("should use re_match for a re_match filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "level", operator: "re_match", value: "^(error|warn)$" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE re_match(level, '^(error|warn)$')");
});
it("should use match_all for a match_all filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "message", operator: "match_all", value: "critical" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE match_all('critical')");
});
it("should use LIKE pattern for a Starts With filter", async () => {
wrapper = mountConfig({
filters: [{ field: "path", operator: "Starts With", value: "/api" }],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE path LIKE '/api%'");
});
it("should use LIKE pattern for an Ends With filter", async () => {
wrapper = mountConfig({
filters: [{ field: "path", operator: "Ends With", value: ".json" }],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE path LIKE '%.json'");
});
it("should use NOT LIKE pattern for a Not Contains filter", async () => {
wrapper = mountConfig({
filters: [
{ field: "message", operator: "Not Contains", value: "debug" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE message NOT LIKE '%debug%'");
});
it("should skip a filter whose field is empty", async () => {
wrapper = mountConfig({
filters: [
{ field: "", operator: "=", value: "200" },
{ field: "env", operator: "=", value: "prod" },
],
});
const sql = await getSqlFromPreview(wrapper);
// The empty-field filter must not produce a stray fragment
expect(sql).not.toContain("= '200'");
// The valid filter must still appear
expect(sql).toContain("WHERE env = 'prod'");
});
it("should skip a filter whose value is empty and operator needs a value", async () => {
wrapper = mountConfig({
filters: [
{ field: "status", operator: "=", value: "" },
{ field: "env", operator: "=", value: "prod" },
],
});
const sql = await getSqlFromPreview(wrapper);
// The valueless filter (= '') should be skipped
expect(sql).not.toContain("status =");
expect(sql).toContain("WHERE env = 'prod'");
});
it("should produce a complete WHERE clause with three filters", async () => {
wrapper = mountConfig({
filters: [
{ field: "status", operator: "=", value: "200" },
{ field: "env", operator: "=", value: "prod" },
{ field: "region", operator: "=", value: "us-east" },
],
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toContain("WHERE status = '200'");
expect(sql).toContain("AND env = 'prod'");
expect(sql).toContain("AND region = 'us-east'");
});
});
// =========================================================================
describe("custom_sql mode — query passed through as-is (normalized)", () => {
it("should use custom SQL directly when query_mode is custom_sql", async () => {
const customSql =
"SELECT histogram(_timestamp, '5m') AS time_bucket, count(*) AS value FROM \"events\" GROUP BY time_bucket ORDER BY time_bucket";
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: customSql,
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toBe(customSql);
});
it("should pass through a JOIN query unchanged after normalization", async () => {
const rawSql =
'SELECT histogram(_timestamp, \'5m\') AS time_bucket, count(*) AS value FROM "events" e JOIN "users" u ON e.user_id = u.id GROUP BY time_bucket ORDER BY time_bucket';
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: rawSql,
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toBe(rawSql);
});
it("should pass through a subquery unchanged after normalization", async () => {
const rawSql =
"SELECT histogram(_timestamp, '5m') AS time_bucket, count(*) AS value FROM (SELECT * FROM \"raw_events\" WHERE env = 'prod') sub GROUP BY time_bucket ORDER BY time_bucket";
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "raw_events",
custom_sql: rawSql,
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toBe(rawSql);
});
it("should pass through a CTE query unchanged after normalization", async () => {
const rawSql =
"WITH filtered AS (SELECT * FROM \"events\" WHERE level = 'error') SELECT histogram(_timestamp, '5m') AS time_bucket, count(*) AS value FROM filtered GROUP BY time_bucket ORDER BY time_bucket";
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: rawSql,
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toBe(rawSql);
});
it("should collapse multiline SQL to a single line", async () => {
const multilineSql =
"SELECT histogram(_timestamp, '5m') AS time_bucket,\n count(*) AS value\nFROM \"events\"\nGROUP BY time_bucket\nORDER BY time_bucket";
const expectedSql =
"SELECT histogram(_timestamp, '5m') AS time_bucket, count(*) AS value FROM \"events\" GROUP BY time_bucket ORDER BY time_bucket";
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: multilineSql,
});
const sql = await getSqlFromPreview(wrapper);
expect(sql).toBe(expectedSql);
});
it("should leave previewPanelSchema null when custom_sql is empty and stream_name is empty", async () => {
// stream_name must also be empty — the immediate watcher seeds custom_sql
// from buildDefaultSql() when stream_name is set and custom_sql is blank.
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "",
custom_sql: "",
});
(wrapper.vm as any).loadPreview();
await flushPromises();
expect((wrapper.vm as any).previewPanelSchema).toBeNull();
});
});
});
@@ -180,6 +180,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
SQL is required in custom SQL mode
</div>
<div
v-if="hasTimestampAlias"
class="text-red-8 q-pt-xs"
data-test="anomaly-custom-sql-timestamp-alias-error"
style="font-size: 11px; line-height: 12px"
>
<code>{{
store.state.zoConfig.timestamp_column || "_timestamp"
}}</code>
cannot be used as a column alias. Use
<code>time_bucket</code> instead.
</div>
<div
class="text-caption tw:mt-1"
:class="
@@ -316,13 +328,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<!-- Schedule Interval -->
<!-- Check Every (schedule interval) -->
<div class="flex items-start alert-settings-row">
<div
class="tw:font-semibold flex items-center"
style="width: 190px; height: 36px"
>
Schedule Interval <span class="text-negative tw:ml-1">*</span>
Check Every <span class="text-negative tw:ml-1">*</span>
<q-icon
name="info"
size="17px"
@@ -404,7 +416,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<span style="font-size: 14px">
How far back each detection run queries. Caps the window even
after a long pause. Defaults to Schedule Interval.
after a long pause. Defaults to Check Every interval.
</span>
</q-tooltip>
</q-icon>
@@ -435,6 +447,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="anomaly-detection-window-unit"
/>
</div>
<div
v-if="
!config.detection_window_value ||
config.detection_window_value < 1
"
class="text-red-8 q-pt-xs"
style="font-size: 11px; line-height: 12px"
data-test="anomaly-detection-window-error"
>
Field is required!
</div>
</div>
</div>
@@ -589,14 +612,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
no-caps
size="sm"
icon="refresh"
:loading="historyLoading"
:disable="!config.id || historyLoading"
:disable="
!config.stream_name ||
(config.query_mode === 'custom_sql' && !config.custom_sql)
"
class="text-caption"
data-test="anomaly-sensitivity-load-btn"
@click="loadSensitivityHistory"
@click="loadPreview"
>
<q-tooltip v-if="!config.id"
>Available after saving</q-tooltip
<q-tooltip v-if="!config.stream_name"
>Select a stream first</q-tooltip
>
<q-tooltip
v-else-if="
config.query_mode === 'custom_sql' && !config.custom_sql
"
>Enter a SQL query first</q-tooltip
>
<span v-else class="q-ml-xs">Load Data</span>
</q-btn>
@@ -604,54 +635,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Chart + Vertical Slider row -->
<div class="tw:flex tw:gap-3">
<!-- ECharts time series chart -->
<!-- Time series chart -->
<div class="sensitivity-chart-wrapper tw:flex-1">
<div
v-if="!config.id"
v-if="!previewActive"
class="sensitivity-empty-state"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-6'
"
data-test="anomaly-sensitivity-empty"
>
<q-icon
name="bar_chart"
size="2rem"
class="tw:mb-2 tw:opacity-40"
/>
<span class="text-caption"
>Save first to load historical data</span
>
<span class="text-caption">{{
!config.stream_name
? "Select a stream first"
: "Click Load Data to preview the time series"
}}</span>
</div>
<div
v-else-if="historyLoading"
class="sensitivity-empty-state"
>
<q-spinner size="1.5rem" color="primary" />
</div>
<div
v-else-if="historyPoints.length === 0"
class="sensitivity-empty-state"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-6'
"
>
<q-icon
name="timeline"
size="2rem"
class="tw:mb-2 tw:opacity-40"
/>
<span class="text-caption"
>No history data yet. Click Load Data.</span
>
</div>
<div
<PanelSchemaRenderer
v-else
ref="chartRef"
class="sensitivity-chart"
:key="previewKey"
:panelSchema="previewPanelSchema"
:selectedTimeObj="previewTimeObj"
:variablesData="{}"
:forceLoad="true"
searchType="UI"
style="height: 180px; width: 100%"
data-test="anomaly-sensitivity-chart"
/>
</div>
@@ -686,22 +701,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts">
import { defineComponent, ref, watch, onUnmounted, type PropType } from "vue";
import { computed, defineComponent, ref, watch, type PropType } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import * as echarts from "echarts";
import streamService from "@/services/stream";
import anomalyDetectionService from "@/services/anomaly_detection";
import {
ANOMALY_FILTER_OPERATORS,
buildAnomalyFilterExpression,
operatorNeedsValue,
} from "@/utils/alerts/anomalyFilterOperators";
import QueryEditor from "@/components/QueryEditor.vue";
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
export default defineComponent({
name: "AnomalyDetectionConfig",
components: { QueryEditor },
components: { QueryEditor, PanelSchemaRenderer },
props: {
config: {
@@ -722,6 +737,7 @@ export default defineComponent({
"sum",
"min",
"max",
"p50",
"p95",
"p99",
];
@@ -736,6 +752,18 @@ export default defineComponent({
{ label: "14 days", value: 14 },
];
// Check if the custom SQL uses the timestamp column name as an alias
const hasTimestampAlias = computed(() => {
const sql = props.config.custom_sql;
if (!sql || props.config.query_mode !== "custom_sql") return false;
const tsCol = store.state.zoConfig.timestamp_column || "_timestamp";
const escaped = tsCol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`\\bAS\\s+["'\`]?${escaped}["'\`]?\\s*(?:,|\\s|$)`,
"i",
).test(sql);
});
// Build default SQL template for a given stream name and histogram interval
const buildDefaultSql = (
streamName: string,
@@ -746,15 +774,26 @@ export default defineComponent({
// Stream fields for filter field selector and detection function field
const allStreamFields = ref<string[]>([]);
const numericStreamFields = ref<string[]>([]); // only numeric types for avg/sum/min/max/pXX
const filteredStreamFields = ref<string[]>([]);
const filteredDetectionFields = ref<string[]>([]);
const loadingFields = ref(false);
const NUMERIC_FIELD_TYPES = new Set([
"Int8", "Int16", "Int32", "Int64",
"UInt8", "UInt16", "UInt32", "UInt64",
"Float16", "Float32", "Float64",
]);
const requiresNumericField = (fn: string) =>
["avg", "sum", "min", "max", "p50", "p95", "p99"].includes(fn);
const loadStreamFields = async () => {
const streamName = props.config.stream_name;
const streamType = props.config.stream_type;
if (!streamName || !streamType) {
allStreamFields.value = [];
numericStreamFields.value = [];
filteredStreamFields.value = [];
filteredDetectionFields.value = [];
return;
@@ -772,10 +811,22 @@ export default defineComponent({
? schema.uds_schema
: schema.schema || schema.fields || [];
allStreamFields.value = fieldsArray.map((f: any) => f.name).sort();
numericStreamFields.value = fieldsArray
.filter((f: any) => {
const t: string = f.field_type || f.data_type || f.type || "";
return NUMERIC_FIELD_TYPES.has(t);
})
.map((f: any) => f.name)
.sort();
filteredStreamFields.value = allStreamFields.value;
filteredDetectionFields.value = allStreamFields.value;
filteredDetectionFields.value = requiresNumericField(
props.config.detection_function,
)
? numericStreamFields.value
: allStreamFields.value;
} catch {
allStreamFields.value = [];
numericStreamFields.value = [];
filteredStreamFields.value = [];
filteredDetectionFields.value = [];
} finally {
@@ -797,11 +848,12 @@ export default defineComponent({
const filterDetectionFieldOptions = (val: string, update: any) => {
update(() => {
const needle = val.toLowerCase();
filteredDetectionFields.value = needle
? allStreamFields.value.filter((f) =>
f.toLowerCase().includes(needle),
)
const base = requiresNumericField(props.config.detection_function)
? numericStreamFields.value
: allStreamFields.value;
filteredDetectionFields.value = needle
? base.filter((f) => f.toLowerCase().includes(needle))
: base;
});
};
@@ -809,6 +861,19 @@ export default defineComponent({
if (fn === "count") {
props.config.detection_function_field = "";
}
// Refresh available fields based on whether the new function needs numeric fields.
const base = requiresNumericField(fn)
? numericStreamFields.value
: allStreamFields.value;
filteredDetectionFields.value = base;
// Clear the selected field if it's no longer valid for the new function.
if (
requiresNumericField(fn) &&
props.config.detection_function_field &&
!numericStreamFields.value.includes(props.config.detection_function_field)
) {
props.config.detection_function_field = "";
}
};
watch(
@@ -849,6 +914,21 @@ export default defineComponent({
},
);
// Sync histogram interval changes into the custom SQL histogram() call
watch(
() => [
props.config.histogram_interval_value,
props.config.histogram_interval_unit,
],
([newValue, newUnit]) => {
if (props.config.query_mode !== "custom_sql" || !props.config.custom_sql) return;
props.config.custom_sql = props.config.custom_sql.replace(
/histogram\(\s*_timestamp\s*,\s*'[^']+'\s*\)/gi,
`histogram(_timestamp, '${newValue}${newUnit}')`,
);
},
);
const addFilter = () => {
props.config.filters.push({ field: "", operator: "=", value: "" });
};
@@ -865,162 +945,231 @@ export default defineComponent({
) {
return false;
}
if (hasTimestampAlias.value) {
return false;
}
if (
!props.config.histogram_interval_value ||
props.config.histogram_interval_value < 1
) {
return false;
}
if (
!props.config.schedule_interval_value ||
props.config.schedule_interval_value < 1
) {
return false;
}
if (
!props.config.detection_window_value ||
props.config.detection_window_value < 1
) {
return false;
}
if (
props.config.query_mode === "filters" &&
props.config.detection_function &&
props.config.detection_function !== "count" &&
!props.config.detection_function_field
) {
return false;
}
return formValid;
};
// ── Sensitivity chart ───────────────────────────────────────────────────
const chartRef = ref<HTMLElement | null>(null);
const historyLoading = ref(false);
const historyPoints = ref<
Array<{ ts: number; value: number; score: number }>
>([]);
// ── Data Preview chart ──────────────────────────────────────────────────
const previewActive = ref(false);
const previewKey = ref(0);
const previewPanelSchema = ref<any>(null);
const previewTimeObj = ref<any>(null);
const buildPreviewSql = () => {
let sql: string;
if (props.config.query_mode === "custom_sql") {
sql = props.config.custom_sql || "";
} else {
const streamName = props.config.stream_name;
if (!streamName) {
sql = "";
} else {
const intervalValue = props.config.histogram_interval_value ?? 5;
const intervalUnit = props.config.histogram_interval_unit ?? "m";
const interval = `${intervalValue}${intervalUnit}`;
const fn =
props.config.detection_function === "count" ||
!props.config.detection_function
? "count(*)"
: `${props.config.detection_function}(${props.config.detection_function_field || "*"})`;
const filterLines = (props.config.filters || [])
.filter(
(f: any) =>
f.field &&
(operatorNeedsValue(f.operator) ? f.value : true),
)
.map(
(f: any) =>
` AND ${buildAnomalyFilterExpression(f.field, f.operator, f.value)}`,
);
const where = filterLines.length
? [
"WHERE",
...filterLines.map((l: string, i: number) =>
i === 0 ? l.replace(/^\s+AND /, " ") : l,
),
].join("\n")
: "";
sql = [
`SELECT histogram(_timestamp, '${interval}') AS time_bucket,`,
` ${fn} AS value`,
`FROM "${streamName}"`,
where,
`GROUP BY time_bucket`,
`ORDER BY time_bucket`,
]
.filter(Boolean)
.join("\n");
}
}
// Normalize multiline SQL to a single line — the dashboard panel
// query executor can truncate at newlines in some code paths
return sql.replace(/\s+/g, " ").trim();
};
const loadPreview = () => {
const sql = buildPreviewSql();
if (!sql || !props.config.stream_name) return;
const windowValue = props.config.detection_window_value ?? 30;
const windowUnit = props.config.detection_window_unit ?? "m";
const windowMs =
windowValue * (windowUnit === "h" ? 3600000 : 60000);
// The dashboard DateTime picker returns microseconds (ms * 1000).
// viewDashboard wraps those with new Date(microseconds), so the Date
// object's internal value IS the microsecond number.
// usePanelDataLoader then calls .getTime() which returns the microsecond
// value unchanged. We must replicate that convention here.
const endMicros = new Date().getTime() * 1000;
const startMicros = endMicros - windowMs * 1000;
previewTimeObj.value = {
start_time: new Date(startMicros),
end_time: new Date(endMicros),
};
// PanelSchemaRenderer expects the inner data object directly (not wrapped)
previewPanelSchema.value = {
version: 2,
id: "anomaly-preview",
type: "line",
title: "",
description: "",
config: {
show_legends: false,
legends_position: "bottom",
unit: "short",
unit_custom: "",
promql_legend: "",
axis_border_show: false,
connect_nulls: true,
no_value_replacement: "",
wrap_table_cells: false,
table_transpose: false,
table_dynamic_columns: false,
base_map: { type: "osm" },
map_view: { zoom: 1, lat: 0, lng: 0 },
custom_chart_options: {
tooltip: { appendToBody: true, confine: false },
},
},
queryType: "sql",
queries: [
{
query: sql,
customQuery: true,
vrlFunctionQuery: null,
query_fn: null,
fields: {
stream: props.config.stream_name,
stream_type: props.config.stream_type || "logs",
x: [
{
alias: "time_bucket",
column: "time_bucket",
label: "time_bucket",
color: null,
},
],
y: [
{
alias: "value",
column: "value",
label: "value",
color: "#5960b2",
},
],
z: [],
breakdown: [],
filter: {
filterType: "group",
logicalOperator: "AND",
conditions: [],
},
latitude: null,
longitude: null,
weight: null,
},
config: {
promql_legend: "",
layer_type: "scatter",
weight_fixed: 1,
limit: 0,
min: 0,
max: 100,
time_shift: [],
},
},
],
};
previewKey.value++;
previewActive.value = true;
};
// Auto-refresh when Look Back Window, Detection Resolution, filters, or
// detection function changes
let previewRefreshTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => [
props.config.detection_window_value,
props.config.detection_window_unit,
props.config.histogram_interval_value,
props.config.histogram_interval_unit,
props.config.query_mode,
props.config.custom_sql,
props.config.detection_function,
props.config.detection_function_field,
props.config.filters,
],
() => {
if (!previewActive.value) return;
if (previewRefreshTimer) clearTimeout(previewRefreshTimer);
previewRefreshTimer = setTimeout(() => {
loadPreview();
}, 600);
},
{ deep: true },
);
// ── Sensitivity slider ──────────────────────────────────────────────────
const thresholdRange = ref<{ min: number; max: number }>({
min: props.config.threshold_min ?? 0,
max: props.config.threshold ?? 97,
});
let chartInstance: echarts.ECharts | null = null;
const isDark = () => store.state.theme === "dark";
const buildChartOption = () => {
const pts = historyPoints.value;
const scatterData = pts.map((p) => [p.ts, p.value, p.score]);
const lineData = pts.map((p) => [p.ts, p.value]);
const scores = pts.map((p) => p.score);
const minScore = scores.length ? Math.min(...scores) : 0;
const maxScore = scores.length ? Math.max(...scores) : 100;
return {
backgroundColor: "transparent",
animation: false,
grid: { left: 50, right: 90, top: 10, bottom: 30 },
xAxis: {
type: "time",
axisLabel: {
color: isDark() ? "#9e9e9e" : "#666",
fontSize: 10,
formatter: (val: number) => {
const d = new Date(val);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
},
},
axisLine: { lineStyle: { color: isDark() ? "#444" : "#ddd" } },
splitLine: { show: false },
},
yAxis: {
type: "value",
name: "Value",
nameTextStyle: { color: isDark() ? "#9e9e9e" : "#666", fontSize: 10 },
axisLabel: { color: isDark() ? "#9e9e9e" : "#666", fontSize: 10 },
axisLine: { lineStyle: { color: isDark() ? "#444" : "#ddd" } },
splitLine: { lineStyle: { color: isDark() ? "#333" : "#eee" } },
},
visualMap: {
min: minScore,
max: maxScore,
dimension: 2,
orient: "vertical",
right: 0,
top: "center",
text: ["High", "Low"],
textStyle: { color: isDark() ? "#9e9e9e" : "#666", fontSize: 10 },
calculable: false,
inRange: {
color: ["#5470c6", "#91cc75", "#fac858", "#ee6666"],
},
width: 12,
itemHeight: 100,
},
tooltip: {
trigger: "item",
formatter: (params: any) => {
if (params.seriesIndex === 0) {
const [ts, val, score] = params.data;
const d = new Date(ts);
return `${d.toLocaleString()}<br/>Value: ${Number(val).toFixed(4)}<br/>Score: ${Number(score).toFixed(2)}`;
}
return "";
},
},
series: [
{
name: "Data",
type: "scatter",
data: scatterData,
symbolSize: 6,
encode: { x: 0, y: 1, tooltip: [0, 1, 2] },
},
{
name: "Baseline",
type: "line",
data: lineData,
lineStyle: { color: isDark() ? "#555" : "#ccc", width: 1 },
symbol: "none",
z: 0,
},
],
};
};
const renderChart = () => {
if (!chartRef.value || historyPoints.value.length === 0) return;
if (!chartInstance) {
chartInstance = echarts.init(
chartRef.value,
isDark() ? "dark" : undefined,
);
}
chartInstance.setOption(buildChartOption(), true);
};
watch(historyPoints, () => {
setTimeout(renderChart, 50);
});
watch(
() => store.state.theme,
() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
setTimeout(renderChart, 50);
},
);
const loadSensitivityHistory = async () => {
const orgId = store.state.selectedOrganization?.identifier;
if (!orgId || !props.config.id) return;
historyLoading.value = true;
try {
const res = await anomalyDetectionService.getHistory(
orgId,
props.config.id,
200,
);
const raw: any[] = res.data?.history ?? res.data?.list ?? [];
historyPoints.value = raw
.map((r: any) => ({
ts: r.timestamp ?? r._timestamp ?? r.ts ?? 0,
value: r.actual_value ?? r.value ?? 0,
score: r.anomaly_score ?? r.score ?? 0,
}))
.filter((p) => p.ts > 0)
.sort((a, b) => a.ts - b.ts);
} catch {
historyPoints.value = [];
} finally {
historyLoading.value = false;
}
};
const onThresholdRangeChange = (val: { min: number; max: number }) => {
props.config.threshold_min = val.min;
props.config.threshold = val.max;
};
// init range from config
// sync range when config changes externally
watch(
() => [props.config.threshold, props.config.threshold_min],
([max, min]) => {
@@ -1031,10 +1180,6 @@ export default defineComponent({
},
);
onUnmounted(() => {
chartInstance?.dispose();
chartInstance = null;
});
return {
t,
@@ -1055,12 +1200,14 @@ export default defineComponent({
addFilter,
removeFilter,
validate,
chartRef,
historyLoading,
historyPoints,
hasTimestampAlias,
thresholdRange,
loadSensitivityHistory,
onThresholdRangeChange,
previewActive,
previewKey,
previewPanelSchema,
previewTimeObj,
loadPreview,
};
},
});
@@ -1121,10 +1268,6 @@ export default defineComponent({
position: relative;
}
.sensitivity-chart {
width: 100%;
height: 180px;
}
.sensitivity-empty-state {
width: 100%;
@@ -1239,4 +1382,5 @@ export default defineComponent({
}
}
}
</style>
+5 -2
View File
@@ -77,15 +77,18 @@
<q-card-actions align="right" class="q-pa-md">
<q-btn
flat
no-caps
:label="t('common.cancel')"
class="q-ml-sm o2-secondary-button tw:h-[36px]"
:class="store.state.theme === 'dark' ? 'o2-secondary-button-dark' : 'o2-secondary-button-light'"
v-close-popup
data-test="json-editor-cancel"
/>
<q-btn
class="q-ml-sm o2-primary-button tw:h-[36px]"
padding="sm lg"
flat
no-caps
class="q-ml-sm o2-primary-button no-border tw:h-[36px]"
:class="store.state.theme === 'dark' ? 'o2-primary-button-dark' : 'o2-primary-button-light'"
:label="t('common.save')"
@click="saveChanges"
data-test="json-editor-save"
@@ -114,6 +114,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
type: Array,
default: [],
},
anomalyConfigIds: {
type: Array,
default: [],
},
type: {
type: String,
default: "alerts",
@@ -142,10 +146,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
try {
const moduleIds = props.moduleId
const data = {
const data: Record<string, any> = {
[getModuleName()]: moduleIds,
dst_folder_id: selectedFolder.value.value,
}
if (props.anomalyConfigIds && (props.anomalyConfigIds as any[]).length > 0) {
data.anomaly_config_ids = props.anomalyConfigIds;
}
await moveModuleToAnotherFolder(
store,
data,
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Konfiguration der Abfrage- und Erkennungsparameter",
"alerting": "Richten Sie Benachrichtigungen ein, wenn Anomalien erkannt werden"
},
"updateAnomalyDetection": "Anomalieerkennung aktualisieren:"
"updateAnomalyDetection": "Anomalieerkennung aktualisieren:",
"anomalyCreated": "Konfiguration zur Erkennung von Anomalien wurde erstellt. Die Schulung beginnt in Kürze.",
"editJson": "Alert-JSON bearbeiten"
},
"alert_templates": {
"header": "Vorlagen",
+6 -4
View File
@@ -1987,9 +1987,9 @@
"alertType": "Alert Type",
"streamType": "Stream Type",
"state": "State",
"realTime": "Real-Time Alert",
"scheduled": "Scheduled Alert",
"anomalyDetection": "Anomaly Detection",
"realTime": "Realtime",
"scheduled": "Scheduled",
"anomalyDetection": "Anomalies",
"newAnomalyDetection": "New anomaly detection",
"editAnomalyDetection": "Edit anomaly detection",
"updateAnomalyDetection": "Update anomaly detection:",
@@ -1997,6 +1997,7 @@
"anomalyDetectionConfig": "Detection Config",
"alerting": "Alerting",
"saveAndTrain": "Save & Train",
"anomalyCreated": "Anomaly detection config created. Training will start shortly.",
"anomalyStepCaptions": {
"alertSetup": "Name your detector and select a stream to monitor",
"detectionConfig": "Configure the query and detection parameters",
@@ -2448,7 +2449,8 @@
"variablesTooltip": "Variables are used to pass data from the alert to the destination.",
"rowTemplateTooltip": "Row Template is used to format the alert message.",
"templateType": "Template Type:"
}
},
"editJson": "Edit Alert JSON"
},
"alert_templates": {
"header": "Templates",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Configurar los parámetros de consulta y detección",
"alerting": "Configure las notificaciones cuando se detecten anomalías"
},
"updateAnomalyDetection": "Actualizar la detección de anomalías:"
"updateAnomalyDetection": "Actualizar la detección de anomalías:",
"anomalyCreated": "Se creó una configuración de detección de anomalías. La formación comenzará en breve.",
"editJson": "Editar alerta JSON"
},
"alert_templates": {
"header": "Plantillas",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Configuration des paramètres de requête et de détection",
"alerting": "Configurer des notifications lorsque des anomalies sont détectées"
},
"updateAnomalyDetection": "Mettre à jour la détection des anomalies :"
"updateAnomalyDetection": "Mettre à jour la détection des anomalies :",
"anomalyCreated": "Configuration de détection des anomalies créée. La formation débutera prochainement.",
"editJson": "Modifier le JSON d'alerte"
},
"alert_templates": {
"header": "Modèles",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Configurazione dei parametri di interrogazione e rilevamento",
"alerting": "Imposta notifiche quando vengono rilevate anomalie"
},
"updateAnomalyDetection": "Aggiorna il rilevamento delle anomalie:"
"updateAnomalyDetection": "Aggiorna il rilevamento delle anomalie:",
"anomalyCreated": "Configurazione di rilevamento delle anomalie creata. La formazione inizierà a breve.",
"editJson": "Modifica avviso JSON"
},
"alert_templates": {
"header": "Modelli",
+3 -1
View File
@@ -2167,7 +2167,9 @@
"detectionConfig": "クエリと検出パラメータの設定",
"alerting": "異常が検出されたときの通知を設定する"
},
"updateAnomalyDetection": "異常検出の更新:"
"updateAnomalyDetection": "異常検出の更新:",
"anomalyCreated": "異常検知設定が作成されました。トレーニングはまもなく開始されます。",
"editJson": "アラート JSON の編集"
},
"alert_templates": {
"header": "テンプレート",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "쿼리 및 탐지 매개변수 구성",
"alerting": "이상 징후 감지 시 알림 설정"
},
"updateAnomalyDetection": "이상 탐지 업데이트:"
"updateAnomalyDetection": "이상 탐지 업데이트:",
"anomalyCreated": "이상 탐지 구성이 생성되었습니다.훈련이 곧 시작될 예정입니다.",
"editJson": "알림 JSON 편집"
},
"alert_templates": {
"header": "템플릿",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Configureer de query- en detectieparameters",
"alerting": "Meldingen instellen wanneer er afwijkingen worden gedetecteerd"
},
"updateAnomalyDetection": "Anomaliedetectie bijwerken:"
"updateAnomalyDetection": "Anomaliedetectie bijwerken:",
"anomalyCreated": "Configuratie voor anomaliedetectie gemaakt. De training start binnenkort.",
"editJson": "Waarschuwings-JSON bewerken"
},
"alert_templates": {
"header": "Sjablonen",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Configurar os parâmetros de consulta e detecção",
"alerting": "Configure notificações quando anomalias forem detectadas"
},
"updateAnomalyDetection": "Atualize a detecção de anomalias:"
"updateAnomalyDetection": "Atualize a detecção de anomalias:",
"anomalyCreated": "Configuração de detecção de anomalias criada. O treinamento começará em breve.",
"editJson": "Editar alerta JSON"
},
"alert_templates": {
"header": "Modelos",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "Sorgu ve algılama parametrelerini yapılandırma",
"alerting": "Anormallikler tespit edildiğinde bildirimleri ayarlayın"
},
"updateAnomalyDetection": "Anomali algılamayı güncelle:"
"updateAnomalyDetection": "Anomali algılamayı güncelle:",
"anomalyCreated": "Anomali algılama yapılandırması oluşturuldu. Eğitim kısa süre içinde başlayacak.",
"editJson": "Uyarı JSON'unu Düzenle"
},
"alert_templates": {
"header": "Şablonlar",
+3 -1
View File
@@ -2150,7 +2150,9 @@
"detectionConfig": "配置查询和检测参数",
"alerting": "在检测到异常时设置通知"
},
"updateAnomalyDetection": "更新异常检测:"
"updateAnomalyDetection": "更新异常检测:",
"anomalyCreated": "异常检测配置已创建。培训将很快开始。",
"editJson": "编辑警报 JSON"
},
"alert_templates": {
"header": "模板",
+5 -2
View File
@@ -669,14 +669,17 @@ export default {
return;
}
const available = await isCorrelationAvailable();
// Show button if correlation is available AND we're in detail view (sidebar or expanded)
// AND service_streams is enabled in config
// AND hideViewRelated prop is not set (used by DetailTable drawer to hide the button)
// Mode can be 'sidebar' (when opened from sidebar) or 'expanded' (when log row is expanded in table)
const isDetailView = props.mode === 'sidebar' || props.mode === 'expanded';
const serviceStreamsEnabled = store.state.zoConfig.service_streams_enabled !== false; // Default to true if not set
showViewRelatedBtn.value = available && isDetailView && serviceStreamsEnabled && !props.hideViewRelated;
if(isDetailView && serviceStreamsEnabled) {
const available = await isCorrelationAvailable();
showViewRelatedBtn.value = available && isDetailView && serviceStreamsEnabled && !props.hideViewRelated;
}
} catch (err) {
console.error("[JsonPreview] Error checking correlation availability:", err);
showViewRelatedBtn.value = false;
+24
View File
@@ -37,6 +37,7 @@ const alerts = {
org_identifier: string,
folder_id?: string,
query?: string,
alert_type?: string,
) => {
let url = `/api/v2/${org_identifier}/alerts?sort_by=${sort_by}&desc=${desc}&name=${name}`;
if (folder_id) {
@@ -45,6 +46,9 @@ const alerts = {
if (query) {
url += `&alert_name_substring=${query}`;
}
if (alert_type) {
url += `&alert_type=${alert_type}`;
}
return http().get(url);
},
create: (
@@ -193,6 +197,7 @@ const alerts = {
},
getHistory: (org_identifier: string, query: any) => {
const params = new URLSearchParams();
if (query.anomaly_id) params.append("anomaly_id", query.anomaly_id);
if (query.alert_id) params.append("alert_id", query.alert_id);
if (query.start_time) params.append("start_time", query.start_time);
if (query.end_time) params.append("end_time", query.end_time);
@@ -244,6 +249,25 @@ const alerts = {
data,
);
},
// POST /api/v2/{org}/alerts/{id}/clone — clones regular alert or anomaly config
clone_by_id: (
org_identifier: string,
alert_id: string,
data: { name?: string; folder_id?: string; stream_type?: string; stream_name?: string },
) => {
return http().post(
`/api/v2/${org_identifier}/alerts/${alert_id}/clone`,
data,
);
},
// POST /api/v2/{org}/alerts/{id}/export — returns config with runtime fields stripped
export_by_id: (org_identifier: string, alert_id: string) => {
return http().post(`/api/v2/${org_identifier}/alerts/${alert_id}/export`);
},
// PATCH /api/v2/{org}/alerts/{id}/retrain — triggers model retrain (anomaly configs only)
retrain_by_id: (org_identifier: string, alert_id: string) => {
return http().patch(`/api/v2/${org_identifier}/alerts/${alert_id}/retrain`);
},
};
export default alerts;
+12 -12
View File
@@ -87,7 +87,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.get(org_identifier, anomaly_id);
expect(mockHttpInstance.get).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`
`/api/v2/${org_identifier}/alerts/${anomaly_id}`
);
});
@@ -102,7 +102,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.get(org_identifier, anomaly_id);
expect(mockHttpInstance.get).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`
`/api/v2/${org_identifier}/alerts/${anomaly_id}`
);
});
});
@@ -122,7 +122,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.create(org_identifier, data);
expect(mockHttpInstance.post).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection`,
`/api/v2/${org_identifier}/alerts`,
data
);
});
@@ -136,7 +136,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.create(org_identifier, data);
expect(mockHttpInstance.post).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection`,
`/api/v2/${org_identifier}/alerts`,
data
);
});
@@ -157,7 +157,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.update(org_identifier, anomaly_id, data);
expect(mockHttpInstance.put).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
data
);
});
@@ -172,7 +172,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.update(org_identifier, anomaly_id, data);
expect(mockHttpInstance.put).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
data
);
});
@@ -188,7 +188,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.delete(org_identifier, anomaly_id);
expect(mockHttpInstance.delete).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`
`/api/v2/${org_identifier}/alerts/${anomaly_id}`
);
});
@@ -201,7 +201,7 @@ describe("anomaly_detection service", () => {
await anomaly_detection.delete(org_identifier, anomaly_id);
expect(mockHttpInstance.delete).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`
`/api/v2/${org_identifier}/alerts/${anomaly_id}`
);
});
});
@@ -217,8 +217,8 @@ describe("anomaly_detection service", () => {
await anomaly_detection.toggleEnabled(org_identifier, anomaly_id, enabled);
expect(mockHttpInstance.put).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
{ enabled }
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
{ alert_type: "anomaly_detection", anomaly_config: { enabled } }
);
});
@@ -232,8 +232,8 @@ describe("anomaly_detection service", () => {
await anomaly_detection.toggleEnabled(org_identifier, anomaly_id, enabled);
expect(mockHttpInstance.put).toHaveBeenCalledWith(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
{ enabled: false }
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
{ alert_type: "anomaly_detection", anomaly_config: { enabled: false } }
);
});
});
+16 -8
View File
@@ -20,36 +20,44 @@ const anomaly_detection = {
return http().get(`/api/${org_identifier}/anomaly_detection`);
},
get: (org_identifier: string, anomaly_id: string) => {
getConfig: (org_identifier: string, anomaly_id: string) => {
return http().get(`/api/${org_identifier}/anomaly_detection/${anomaly_id}`);
},
create: (org_identifier: string, data: object) => {
return http().post(`/api/${org_identifier}/anomaly_detection`, data);
// GET /api/v2/{org}/alerts/{id} — falls back to anomaly table when ID not in alerts
get: (org_identifier: string, anomaly_id: string) => {
return http().get(`/api/v2/${org_identifier}/alerts/${anomaly_id}`);
},
// POST /api/v2/{org}/alerts — delegates to anomaly config creation when alert_type is "anomaly_detection"
create: (org_identifier: string, data: object) => {
return http().post(`/api/v2/${org_identifier}/alerts`, data);
},
// PUT /api/v2/{org}/alerts/{id} — delegates to anomaly config update when alert_type is "anomaly_detection"
update: (org_identifier: string, anomaly_id: string, data: object) => {
return http().put(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
data,
);
},
// DELETE /api/v2/{org}/alerts/{id} — falls back to anomaly config delete when ID not in alerts
delete: (org_identifier: string, anomaly_id: string) => {
return http().delete(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
);
},
// Pause (enabled=false) or Resume (enabled=true) via update endpoint
// Pause (enabled=false) or Resume (enabled=true) via unified alerts update endpoint
toggleEnabled: (
org_identifier: string,
anomaly_id: string,
enabled: boolean,
) => {
return http().put(
`/api/${org_identifier}/anomaly_detection/${anomaly_id}`,
{ enabled },
`/api/v2/${org_identifier}/alerts/${anomaly_id}`,
{ alert_type: "anomaly_detection", anomaly_config: { enabled } },
);
},
+10
View File
@@ -124,6 +124,13 @@ export default createStore({
// Share URL state for Safari-compatible clipboard copy
// Polling mechanism checks this value and copies when available
pendingShortURL: null,
// Alert list filter state — persisted across navigation so returning
// from add/edit alert screens restores the previous search & toggle state
alertListFilters: {
searchQuery: "",
filterQuery: "",
searchAcrossFolders: false,
},
},
mutations: {
login(state, payload) {
@@ -303,6 +310,9 @@ export default createStore({
clearPendingShortURL(state) {
state.pendingShortURL = null;
},
setAlertListFilters(state, payload) {
state.alertListFilters = { ...state.alertListFilters, ...payload };
},
/**
* Set GitHub dashboard gallery cache
*/
+9 -1
View File
@@ -109,7 +109,12 @@ const store = createStore({
isDataIngested: false,
allDashboardData: {},
foldersByType: [],
}
},
alertListFilters: {
searchQuery: "",
filterQuery: "",
searchAcrossFolders: false,
},
},
mutations: {
login(state, payload) {
@@ -260,6 +265,9 @@ const store = createStore({
clearPendingShortURL(state) {
// Mock mutation for tests - clears pending short URL state
},
setAlertListFilters(state, payload) {
state.alertListFilters = { ...state.alertListFilters, ...payload };
},
},
modules: {
streams: {
+3 -3
View File
@@ -6,7 +6,7 @@
import searchService from "@/services/search";
import CronExpressionParser from "cron-parser";
import { b64EncodeUnicode } from "@/utils/zincutils";
import useStreams from "@/composables/useStreams";
interface QueryCondition {
conditions?: {
@@ -362,6 +362,7 @@ export interface JsonValidationContext {
q: any;
store: any;
streams: any;
getStreams: (streamType: string, schema: boolean) => Promise<any>;
getParser: (sql: string) => boolean;
buildQueryPayload: (options: any) => any;
prepareAndSaveAlert: (data: any) => Promise<void>;
@@ -570,11 +571,10 @@ export const saveAlertJson = async (
streams,
getParser,
buildQueryPayload,
getStreams,
prepareAndSaveAlert,
} = context;
const { getStreams } = useStreams();
let jsonPayload = JSON.parse(json);
let destinationsList = [];
props.destinations.forEach((destination: any) => {
@@ -0,0 +1,287 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
ANOMALY_FILTER_OPERATORS,
buildAnomalyFilterExpression,
operatorNeedsValue,
} from "./anomalyFilterOperators";
describe("ANOMALY_FILTER_OPERATORS", () => {
it("should include all expected operator strings", () => {
expect(ANOMALY_FILTER_OPERATORS).toContain("=");
expect(ANOMALY_FILTER_OPERATORS).toContain("<>");
expect(ANOMALY_FILTER_OPERATORS).toContain(">=");
expect(ANOMALY_FILTER_OPERATORS).toContain("<=");
expect(ANOMALY_FILTER_OPERATORS).toContain(">");
expect(ANOMALY_FILTER_OPERATORS).toContain("<");
expect(ANOMALY_FILTER_OPERATORS).toContain("IN");
expect(ANOMALY_FILTER_OPERATORS).toContain("NOT IN");
expect(ANOMALY_FILTER_OPERATORS).toContain("str_match");
expect(ANOMALY_FILTER_OPERATORS).toContain("str_match_ignore_case");
expect(ANOMALY_FILTER_OPERATORS).toContain("match_all");
expect(ANOMALY_FILTER_OPERATORS).toContain("re_match");
expect(ANOMALY_FILTER_OPERATORS).toContain("re_not_match");
expect(ANOMALY_FILTER_OPERATORS).toContain("Contains");
expect(ANOMALY_FILTER_OPERATORS).toContain("Starts With");
expect(ANOMALY_FILTER_OPERATORS).toContain("Ends With");
expect(ANOMALY_FILTER_OPERATORS).toContain("Not Contains");
expect(ANOMALY_FILTER_OPERATORS).toContain("Is Null");
expect(ANOMALY_FILTER_OPERATORS).toContain("Is Not Null");
});
it("should contain exactly 19 operators", () => {
expect(ANOMALY_FILTER_OPERATORS.length).toBe(19);
});
});
describe("operatorNeedsValue", () => {
it("should return false when operator is Is Null", () => {
expect(operatorNeedsValue("Is Null")).toBe(false);
});
it("should return false when operator is Is Not Null", () => {
expect(operatorNeedsValue("Is Not Null")).toBe(false);
});
it("should return true when operator is =", () => {
expect(operatorNeedsValue("=")).toBe(true);
});
it("should return true when operator is <>", () => {
expect(operatorNeedsValue("<>")).toBe(true);
});
it("should return true when operator is >=", () => {
expect(operatorNeedsValue(">=")).toBe(true);
});
it("should return true when operator is <=", () => {
expect(operatorNeedsValue("<=")).toBe(true);
});
it("should return true when operator is >", () => {
expect(operatorNeedsValue(">")).toBe(true);
});
it("should return true when operator is <", () => {
expect(operatorNeedsValue("<")).toBe(true);
});
it("should return true when operator is IN", () => {
expect(operatorNeedsValue("IN")).toBe(true);
});
it("should return true when operator is NOT IN", () => {
expect(operatorNeedsValue("NOT IN")).toBe(true);
});
it("should return true when operator is str_match", () => {
expect(operatorNeedsValue("str_match")).toBe(true);
});
it("should return true when operator is str_match_ignore_case", () => {
expect(operatorNeedsValue("str_match_ignore_case")).toBe(true);
});
it("should return true when operator is match_all", () => {
expect(operatorNeedsValue("match_all")).toBe(true);
});
it("should return true when operator is re_match", () => {
expect(operatorNeedsValue("re_match")).toBe(true);
});
it("should return true when operator is re_not_match", () => {
expect(operatorNeedsValue("re_not_match")).toBe(true);
});
it("should return true when operator is Contains", () => {
expect(operatorNeedsValue("Contains")).toBe(true);
});
it("should return true when operator is Starts With", () => {
expect(operatorNeedsValue("Starts With")).toBe(true);
});
it("should return true when operator is Ends With", () => {
expect(operatorNeedsValue("Ends With")).toBe(true);
});
it("should return true when operator is Not Contains", () => {
expect(operatorNeedsValue("Not Contains")).toBe(true);
});
it("should return true for an unknown operator", () => {
expect(operatorNeedsValue("UNKNOWN")).toBe(true);
});
});
describe("buildAnomalyFilterExpression", () => {
describe("empty field guard", () => {
it("should return empty string when field is empty", () => {
expect(buildAnomalyFilterExpression("", "=", "200")).toBe("");
});
it("should return empty string when field is empty and operator is Is Null", () => {
expect(buildAnomalyFilterExpression("", "Is Null", "")).toBe("");
});
});
describe("comparison operators", () => {
it("should return field = 'value' when operator is =", () => {
expect(buildAnomalyFilterExpression("status", "=", "200")).toBe(
"status = '200'",
);
});
it("should return field <> 'value' when operator is <>", () => {
expect(buildAnomalyFilterExpression("status", "<>", "500")).toBe(
"status <> '500'",
);
});
it("should return field >= 'value' when operator is >=", () => {
expect(buildAnomalyFilterExpression("latency", ">=", "100")).toBe(
"latency >= '100'",
);
});
it("should return field <= 'value' when operator is <=", () => {
expect(buildAnomalyFilterExpression("latency", "<=", "500")).toBe(
"latency <= '500'",
);
});
it("should return field > 'value' when operator is >", () => {
expect(buildAnomalyFilterExpression("bytes", ">", "1024")).toBe(
"bytes > '1024'",
);
});
it("should return field < 'value' when operator is <", () => {
expect(buildAnomalyFilterExpression("bytes", "<", "1024")).toBe(
"bytes < '1024'",
);
});
});
describe("IN / NOT IN operators", () => {
it("should return field IN (value) when operator is IN", () => {
expect(
buildAnomalyFilterExpression("status", "IN", "200,404,500"),
).toBe("status IN (200,404,500)");
});
it("should return field NOT IN (value) when operator is NOT IN", () => {
expect(
buildAnomalyFilterExpression("status", "NOT IN", "200,404"),
).toBe("status NOT IN (200,404)");
});
});
describe("null check operators", () => {
it("should return field IS NULL when operator is Is Null", () => {
expect(buildAnomalyFilterExpression("error_msg", "Is Null", "")).toBe(
"error_msg IS NULL",
);
});
it("should return field IS NOT NULL when operator is Is Not Null", () => {
expect(
buildAnomalyFilterExpression("error_msg", "Is Not Null", ""),
).toBe("error_msg IS NOT NULL");
});
});
describe("string match operators", () => {
it("should return str_match(field, 'value') when operator is str_match", () => {
expect(
buildAnomalyFilterExpression("message", "str_match", "error"),
).toBe("str_match(message, 'error')");
});
it("should return str_match(field, 'value') when operator is Contains", () => {
expect(
buildAnomalyFilterExpression("message", "Contains", "error"),
).toBe("str_match(message, 'error')");
});
it("should return str_match_ignore_case(field, 'value') when operator is str_match_ignore_case", () => {
expect(
buildAnomalyFilterExpression(
"message",
"str_match_ignore_case",
"ERROR",
),
).toBe("str_match_ignore_case(message, 'ERROR')");
});
it("should return match_all('value') when operator is match_all", () => {
expect(buildAnomalyFilterExpression("message", "match_all", "error")).toBe(
"match_all('error')",
);
});
});
describe("regex operators", () => {
it("should return re_match(field, 'pattern') when operator is re_match", () => {
expect(
buildAnomalyFilterExpression("level", "re_match", "^(error|warn)$"),
).toBe("re_match(level, '^(error|warn)$')");
});
it("should return re_not_match(field, 'pattern') when operator is re_not_match", () => {
expect(
buildAnomalyFilterExpression("level", "re_not_match", "^debug$"),
).toBe("re_not_match(level, '^debug$')");
});
});
describe("LIKE operators", () => {
it("should return field LIKE 'value%' when operator is Starts With", () => {
expect(
buildAnomalyFilterExpression("path", "Starts With", "/api"),
).toBe("path LIKE '/api%'");
});
it("should return field LIKE '%value' when operator is Ends With", () => {
expect(buildAnomalyFilterExpression("path", "Ends With", ".json")).toBe(
"path LIKE '%.json'",
);
});
it("should return field NOT LIKE '%value%' when operator is Not Contains", () => {
expect(
buildAnomalyFilterExpression("message", "Not Contains", "debug"),
).toBe("message NOT LIKE '%debug%'");
});
});
describe("special characters in values", () => {
it("should embed single quotes in the value as-is for comparison operators", () => {
expect(buildAnomalyFilterExpression("env", "=", "prod")).toBe(
"env = 'prod'",
);
});
it("should handle values with spaces for str_match", () => {
expect(
buildAnomalyFilterExpression("message", "str_match", "out of memory"),
).toBe("str_match(message, 'out of memory')");
});
});
});
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
buildAnomalyFilterExpression,
operatorNeedsValue,
} from "@/utils/alerts/anomalyFilterOperators";
/**
* Builds the SQL query for an anomaly detection config, including seasonality
* columns (hour, dow) that match the SQL Preview shown during config setup.
*
* For custom_sql mode, returns the user-provided SQL.
* For filters mode, generates the full SQL from stream, function, filters, and
* training window.
*/
export const buildAnomalyPreviewSql = (config: any): string => {
if (!config) return "";
if (config.query_mode === "custom_sql") {
return config.custom_sql || "";
}
const streamName = config.stream_name;
if (!streamName) return "";
// Support both combined "5m" (API) and separate value+unit (form) formats
const interval =
config.histogram_interval ||
`${config.histogram_interval_value ?? 5}${config.histogram_interval_unit ?? "m"}`;
const rawFn = config.detection_function || "count";
// The API may return "count(*)" or "avg(field)" (with parens already),
// while the form stores "count" + separate field. Handle both.
const fnAlreadyWrapped = rawFn.includes("(");
const fn = fnAlreadyWrapped
? rawFn
: rawFn === "count"
? "count(*)"
: `${rawFn}(${config.detection_function_field || "*"})`;
const filterLines = (config.filters || [])
.filter(
(f: any) =>
f.field && (operatorNeedsValue(f.operator) ? f.value : true),
)
.map(
(f: any) =>
` AND ${buildAnomalyFilterExpression(f.field, f.operator, f.value)}`,
);
const where = filterLines.length
? [
"WHERE",
...filterLines.map((l: string, i: number) =>
i === 0 ? l.replace(/^\s+AND /, " ") : l,
),
].join("\n")
: "";
// Seasonality columns based on training window (matches AddAlert SQL preview)
const trainingDays = config.training_window_days ?? 14;
const autoSeasonality = trainingDays >= 7 ? "week" : "day";
const seasonalSelect =
autoSeasonality === "week"
? ",\n date_part('hour', to_timestamp(_timestamp / 1000000)) AS hour,\n date_part('dow', to_timestamp(_timestamp / 1000000)) AS dow"
: ",\n date_part('hour', to_timestamp(_timestamp / 1000000)) AS hour";
const seasonalGroup =
autoSeasonality === "week" ? ", hour, dow" : ", hour";
return [
`SELECT histogram(_timestamp, '${interval}') AS time_bucket,`,
` ${fn} AS value${seasonalSelect}`,
`FROM ${streamName}`,
where,
`GROUP BY time_bucket${seasonalGroup}`,
`ORDER BY time_bucket`,
]
.filter(Boolean)
.join("\n");
};
@@ -82,16 +82,22 @@ export function generateAnomalySummary(
`✓ Alerting: <span class="summary-clickable">Disabled</span>`,
);
} else {
const dest = destinations?.find(
(d: any) =>
d.value === config.alert_destination_id ||
d.id === config.alert_destination_id ||
d.name === config.alert_destination_id,
);
const destName = dest?.name ?? dest?.label ?? config.alert_destination_id;
if (destName) {
const ids: string[] = Array.isArray(config.alert_destination_ids)
? config.alert_destination_ids
: config.alert_destination_id
? [config.alert_destination_id]
: [];
const destNames = ids
.map((id: string) => {
const d = destinations?.find(
(d: any) => d.value === id || d.id === id || d.name === id,
);
return d?.name ?? d?.label ?? id;
})
.filter(Boolean);
if (destNames.length > 0) {
parts.push(
`✓ Alerting: Enabled → <span class="summary-clickable">${destName}</span>`,
`✓ Alerting: Enabled → <span class="summary-clickable">${destNames.join(", ")}</span>`,
);
} else {
parts.push(
+7
View File
@@ -23,6 +23,12 @@ describe('AddAlertView.vue', () => {
selectedOrganization: {
identifier: 'test-org',
},
organizationData: {
allAlertsListByFolderId: {},
},
},
actions: {
setAllAlertsListByFolderId: vi.fn(),
},
});
@@ -160,6 +166,7 @@ describe('AddAlertView.vue', () => {
query: {
org_identifier: 'test-org',
folder: 'default',
tab: 'all',
},
});
});
+27 -2
View File
@@ -64,13 +64,24 @@ export default defineComponent({
}
};
const handleUpdateList = () => {
const handleUpdateList = (folderId?: string) => {
const resolvedFolder = folderId || (route.query.folder as string) || "default";
// Invalidate cached alerts for this folder so the AlertList
// component fetches fresh data when it mounts.
const cached = store.state.organizationData.allAlertsListByFolderId;
if (cached && cached[resolvedFolder]) {
const { [resolvedFolder]: _, ...rest } = cached;
store.dispatch("setAllAlertsListByFolderId", rest);
}
// Navigate back to alert list after successful save
router.push({
name: "alertList",
query: {
org_identifier: store.state.selectedOrganization.identifier,
folder: route.query.folder || "default",
folder: resolvedFolder,
tab: route.query.tab || "all",
},
});
};
@@ -82,6 +93,20 @@ export default defineComponent({
onBeforeMount(async () => {
await getDestinations();
if (!destinations.value.length) {
$q.notify({
type: "warning",
message:
"No destinations found. Please create a destination first.",
timeout: 3000,
});
router.push({
name: "alertList",
query: {
org_identifier: store.state.selectedOrganization.identifier,
},
});
}
});
return {