mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
co-authored by
Bhargav
github-actions[bot]
Claude Sonnet 4.6
claude[bot]
Loakesh Indiran
ByteBaker
Shrinath Rao
parent
e4f156b152
commit
36c2ef4418
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+269
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2167,7 +2167,9 @@
|
||||
"detectionConfig": "クエリと検出パラメータの設定",
|
||||
"alerting": "異常が検出されたときの通知を設定する"
|
||||
},
|
||||
"updateAnomalyDetection": "異常検出の更新:"
|
||||
"updateAnomalyDetection": "異常検出の更新:",
|
||||
"anomalyCreated": "異常検知設定が作成されました。トレーニングはまもなく開始されます。",
|
||||
"editJson": "アラート JSON の編集"
|
||||
},
|
||||
"alert_templates": {
|
||||
"header": "テンプレート",
|
||||
|
||||
@@ -2150,7 +2150,9 @@
|
||||
"detectionConfig": "쿼리 및 탐지 매개변수 구성",
|
||||
"alerting": "이상 징후 감지 시 알림 설정"
|
||||
},
|
||||
"updateAnomalyDetection": "이상 탐지 업데이트:"
|
||||
"updateAnomalyDetection": "이상 탐지 업데이트:",
|
||||
"anomalyCreated": "이상 탐지 구성이 생성되었습니다.훈련이 곧 시작될 예정입니다.",
|
||||
"editJson": "알림 JSON 편집"
|
||||
},
|
||||
"alert_templates": {
|
||||
"header": "템플릿",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2150,7 +2150,9 @@
|
||||
"detectionConfig": "配置查询和检测参数",
|
||||
"alerting": "在检测到异常时设置通知"
|
||||
},
|
||||
"updateAnomalyDetection": "更新异常检测:"
|
||||
"updateAnomalyDetection": "更新异常检测:",
|
||||
"anomalyCreated": "异常检测配置已创建。培训将很快开始。",
|
||||
"editJson": "编辑警报 JSON"
|
||||
},
|
||||
"alert_templates": {
|
||||
"header": "模板",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user