use std::io::{self, Write};
use anyhow::{bail, Result};
use serde_json::{json, Value};
use crate::api::client::ApiClient;
use crate::api::error::ApiError;
use crate::config::credentials::resolve_api_key;
use crate::config::manager::ConfigManager;
use super::args::AlertCommands;
use super::dispatch::{print_output_with_opts, OutputOpts};
const COMPARISON_MAP: &[(&str, &str)] = &[
(">", "GREATER_THAN"),
("gt", "GREATER_THAN"),
("greater_than", "GREATER_THAN"),
("<", "LESS_THAN"),
("lt", "LESS_THAN"),
("less_than", "LESS_THAN"),
(">=", "GREATER_THAN_OR_EQUAL"),
("gte", "GREATER_THAN_OR_EQUAL"),
("greater_than_or_equal", "GREATER_THAN_OR_EQUAL"),
("<=", "LESS_THAN_OR_EQUAL"),
("lte", "LESS_THAN_OR_EQUAL"),
("less_than_or_equal", "LESS_THAN_OR_EQUAL"),
("=", "EQUAL"),
("eq", "EQUAL"),
("equal", "EQUAL"),
];
const COMPARISON_SYMBOLS: &[(&str, &str)] = &[
("GREATER_THAN", ">"),
("LESS_THAN", "<"),
("GREATER_THAN_OR_EQUAL", ">="),
("LESS_THAN_OR_EQUAL", "<="),
("EQUAL", "="),
];
const ACTION_CONFIG_KEYS: &[(&str, &str)] = &[
("email", "email"),
("slack", "webhookUrl"),
("webhook", "url"),
("pagerduty", "routingKey"),
("opsgenie", "apiKey"),
("wecom", "webhookUrl"),
("dingtalk", "webhookUrl"),
("lark", "webhookUrl"),
];
fn resolve_comparison(input: &str) -> Result<String> {
let key = input.to_lowercase();
let key = key.trim();
for (k, v) in COMPARISON_MAP {
if *k == key {
return Ok(v.to_string());
}
}
bail!(
"Invalid comparison '{}'. Use: >, <, >=, <=, = (or gt, lt, gte, lte, eq)",
input
);
}
fn comparison_symbol(api_value: &str) -> &str {
COMPARISON_SYMBOLS
.iter()
.find(|(k, _)| *k == api_value)
.map(|(_, v)| *v)
.unwrap_or("?")
}
fn parse_action(action_str: &str) -> Result<Value> {
let parts: Vec<&str> = action_str.splitn(2, ':').collect();
if parts.len() != 2 {
bail!(
"Invalid action format '{}'. Use type:config (e.g. email:user@example.com)",
action_str
);
}
let action_type = parts[0].to_lowercase();
let config_value = parts[1].trim();
let config_key = ACTION_CONFIG_KEYS
.iter()
.find(|(k, _)| *k == action_type)
.map(|(_, v)| *v);
match config_key {
Some(key) => Ok(json!({
"type": action_type.to_uppercase(),
"config": { key: config_value }
})),
None => {
let valid: Vec<&str> = ACTION_CONFIG_KEYS.iter().map(|(k, _)| *k).collect();
bail!(
"Unknown action type '{}'. Valid types: {}",
action_type,
valid.join(", ")
);
}
}
}
fn make_client(config_mgr: &ConfigManager) -> Result<ApiClient> {
let api_key = resolve_api_key(None, config_mgr).ok_or_else(|| ApiError::NoApiKey)?;
Ok(ApiClient::new(
api_key,
"https://api.cloud.zilliz.com".to_string(),
))
}
pub async fn run(
config_mgr: &ConfigManager,
cmd: AlertCommands,
output_opts: &OutputOpts<'_>,
) -> Result<()> {
let client = make_client(config_mgr)?;
match cmd {
AlertCommands::List {
project_id,
page_size,
page,
} => {
let mut body = json!({ "projectId": project_id });
if let Some(ps) = page_size {
body["pageSize"] = json!(ps);
}
if let Some(p) = page {
body["currentPage"] = json!(p);
}
let result = client.call("GET", "/v2/alertRules", None, Some(&body)).await?;
if output_opts.format == "table" && output_opts.query.is_none() {
print_alert_table(&result, output_opts.no_header);
} else {
print_output_with_opts(&result, output_opts, None);
}
}
AlertCommands::Create {
project_id,
metric_name,
threshold,
comparison,
rule_name,
level,
window_size,
cluster_id,
action,
send_resolved,
repeat_interval,
enabled,
} => {
let comparison_method = resolve_comparison(&comparison)?;
let actions: Vec<Value> = action
.iter()
.map(|a| parse_action(a))
.collect::<Result<Vec<_>>>()?;
let mut body = json!({
"projectId": project_id,
"metricName": metric_name.to_uppercase(),
"threshold": threshold,
"comparisonMethod": comparison_method,
"level": level.to_uppercase(),
});
let obj = body.as_object_mut().unwrap();
if let Some(name) = rule_name {
obj.insert("ruleName".to_string(), json!(name));
} else {
obj.insert(
"ruleName".to_string(),
json!(format!("{} alert", metric_name.to_uppercase())),
);
}
if let Some(ws) = window_size {
obj.insert("windowSize".to_string(), json!(ws));
}
if !cluster_id.is_empty() {
obj.insert("targetInstanceIds".to_string(), json!(cluster_id));
}
if !actions.is_empty() {
obj.insert("actions".to_string(), json!(actions));
}
if let Some(sr) = send_resolved {
obj.insert("sendResolved".to_string(), json!(sr));
}
if let Some(ri) = repeat_interval {
obj.insert("repeatIntervalSeconds".to_string(), json!(ri));
}
if let Some(en) = enabled {
obj.insert("enabled".to_string(), json!(en));
}
let result = client.call("POST", "/v2/alertRules", None, Some(&body)).await?;
if output_opts.format == "table" {
println!("Alert rule created successfully.");
if let Some(id) = result.get("id").and_then(|v| v.as_str()) {
println!(" ID: {}", id);
}
} else {
print_output_with_opts(&result, output_opts, None);
}
}
AlertCommands::Update {
id,
rule_name,
metric_name,
threshold,
comparison,
level,
window_size,
cluster_id,
action,
send_resolved,
repeat_interval,
enabled,
} => {
let mut body = serde_json::Map::new();
if let Some(name) = rule_name {
body.insert("ruleName".to_string(), json!(name));
}
if let Some(m) = metric_name {
body.insert("metricName".to_string(), json!(m.to_uppercase()));
}
if let Some(t) = threshold {
body.insert("threshold".to_string(), json!(t));
}
if let Some(c) = comparison {
body.insert("comparisonMethod".to_string(), json!(resolve_comparison(&c)?));
}
if let Some(l) = level {
body.insert("level".to_string(), json!(l.to_uppercase()));
}
if let Some(ws) = window_size {
body.insert("windowSize".to_string(), json!(ws));
}
if !cluster_id.is_empty() {
body.insert("targetInstanceIds".to_string(), json!(cluster_id));
}
if !action.is_empty() {
let actions: Vec<Value> = action
.iter()
.map(|a| parse_action(a))
.collect::<Result<Vec<_>>>()?;
body.insert("actions".to_string(), json!(actions));
}
if let Some(sr) = send_resolved {
body.insert("sendResolved".to_string(), json!(sr));
}
if let Some(ri) = repeat_interval {
body.insert("repeatIntervalSeconds".to_string(), json!(ri));
}
if let Some(en) = enabled {
body.insert("enabled".to_string(), json!(en));
}
if body.is_empty() {
bail!("No fields to update.");
}
let path = format!("/v2/alertRules/{}", urlencoding::encode(&id));
let body_val = Value::Object(body);
let result = client.call("PUT", &path, None, Some(&body_val)).await?;
if output_opts.format == "table" {
println!("Alert rule {} updated successfully.", id);
} else {
print_output_with_opts(&result, output_opts, None);
}
}
AlertCommands::Delete { id, yes } => {
if !yes {
print!("Delete alert rule {}? [y/N] ", id);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let path = format!("/v2/alertRules/{}", urlencoding::encode(&id));
let _ = client.call("DELETE", &path, None, None).await?;
println!("Alert rule {} deleted.", id);
}
AlertCommands::Enable { id } => {
let path = format!("/v2/alertRules/{}", urlencoding::encode(&id));
let body = json!({"enabled": true});
let _ = client.call("PUT", &path, None, Some(&body)).await?;
println!("Alert rule {} enabled.", id);
}
AlertCommands::Disable { id } => {
let path = format!("/v2/alertRules/{}", urlencoding::encode(&id));
let body = json!({"enabled": false});
let _ = client.call("PUT", &path, None, Some(&body)).await?;
println!("Alert rule {} disabled.", id);
}
}
Ok(())
}
fn print_alert_table(result: &Value, no_header: bool) {
let rules = result
.get("alertRules")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if rules.is_empty() {
println!("No alert rules found.");
return;
}
let columns = &["ID", "Rule Name", "Metric", "Level", "Enabled", "Condition", "Actions"];
let rows: Vec<Vec<String>> = rules
.iter()
.map(|r| {
let id = r.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let name = r
.get("ruleName")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let metric = r
.get("metricName")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let level = r
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let enabled = r
.get("enabled")
.and_then(|v| v.as_bool())
.map(|b| b.to_string())
.unwrap_or_default();
let cmp = r
.get("comparisonMethod")
.and_then(|v| v.as_str())
.unwrap_or("");
let threshold = r
.get("threshold")
.map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_default();
let condition = format!("{} {}", comparison_symbol(cmp), threshold);
let actions = r
.get("actions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("type").and_then(|t| t.as_str()))
.map(|t| t.to_lowercase())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
vec![id, name, metric, level, enabled, condition, actions]
})
.collect();
use comfy_table::{presets::UTF8_FULL_CONDENSED, Table};
let mut table = Table::new();
table.load_preset(UTF8_FULL_CONDENSED);
if !no_header {
table.set_header(columns);
}
for row in rows {
table.add_row(row);
}
println!("{}", table);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_comparison() {
assert_eq!(resolve_comparison(">").unwrap(), "GREATER_THAN");
assert_eq!(resolve_comparison("gt").unwrap(), "GREATER_THAN");
assert_eq!(resolve_comparison("<=").unwrap(), "LESS_THAN_OR_EQUAL");
assert_eq!(resolve_comparison("eq").unwrap(), "EQUAL");
assert!(resolve_comparison("invalid").is_err());
}
#[test]
fn test_parse_action() {
let action = parse_action("email:user@example.com").unwrap();
assert_eq!(action["type"], "EMAIL");
assert_eq!(action["config"]["email"], "user@example.com");
let action = parse_action("slack:https://hooks.slack.com/xxx").unwrap();
assert_eq!(action["type"], "SLACK");
assert_eq!(action["config"]["webhookUrl"], "https://hooks.slack.com/xxx");
assert!(parse_action("invalid").is_err());
assert!(parse_action("unknown:value").is_err());
}
#[test]
fn test_comparison_symbol() {
assert_eq!(comparison_symbol("GREATER_THAN"), ">");
assert_eq!(comparison_symbol("LESS_THAN_OR_EQUAL"), "<=");
assert_eq!(comparison_symbol("UNKNOWN"), "?");
}
}