use anyhow::Result;
use owo_colors::OwoColorize;
use serde_json::{Map, Value};
use crate::api::client::ApiClient;
use crate::api::models::AlertRule;
use crate::commands::monitors::shell_word;
use crate::exit;
use crate::output::{self, Format};
use crate::render::sanitise;
use crate::render::table::{Cell, Table};
use crate::render::theme;
use crate::render::time;
pub enum AlertKind<'a> {
Logs(&'a str),
ErrorRate(f64),
Latency(u32),
}
pub struct AlertDraft<'a> {
pub project: &'a str,
pub service: &'a str,
pub kind: AlertKind<'a>,
pub regex: bool,
pub count: Option<u32>,
pub minutes: Option<u32>,
pub min_requests: Option<u32>,
pub cooldown: Option<u32>,
pub name: Option<&'a str>,
}
#[derive(Default)]
pub struct AlertChange<'a> {
pub name: Option<&'a str>,
pub pattern: Option<&'a str>,
pub pattern_kind: Option<&'a str>,
pub threshold: Option<f64>,
pub minutes: Option<u32>,
pub min_requests: Option<u32>,
pub cooldown: Option<u32>,
}
pub async fn list(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.alerts(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&["alerta", "estado", "servicio", "regla", "última vez"]);
for alert in &answer.body.alerts {
table.push(vec![
Cell::styled(sanitise::plain(&alert.name), theme::strong()),
state_cell(alert),
Cell::plain(sanitise::plain(
alert.service.name.as_deref().unwrap_or("—"),
)),
Cell::styled(sanitise::plain(&alert.description), theme::muted()),
Cell::styled(last_fired(alert), theme::muted()),
]);
}
table.print_or(format_args!(
"`{}` no tiene alertas. fundaia alerts add {} <servicio> --logs ERROR",
project.name, project.slug
));
Ok(())
}
pub async fn add(client: &ApiClient, draft: AlertDraft<'_>, format: Format) -> Result<()> {
let (project, service) = client.locate(draft.project, draft.service).await?;
let answer = client
.create_alert(&project.id, &add_body(&draft, &service.id))
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let alert = &answer.body.alert;
println!(
" {} {} vigila {}: {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&alert.name).style(theme::strong()),
sanitise::plain(&service.name),
sanitise::plain(&alert.description).style(theme::muted()),
);
Ok(())
}
pub async fn edit(
client: &ApiClient,
project_reference: &str,
alert: &str,
change: AlertChange<'_>,
format: Format,
) -> Result<()> {
let body = change_body(&change);
if body.as_object().is_none_or(Map::is_empty) {
return Err(exit::Refused::invalid(
"nada que cambiar: usa --name, --pattern, --regex, --substring, --threshold, --minutes, --min-requests o --cooldown",
)
.into());
}
let project = client.find_project(project_reference).await?;
let answer = client.update_alert(&project.id, alert, &body).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let alert = &answer.body.alert;
println!(
" {} {}: {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&alert.name).style(theme::strong()),
sanitise::plain(&alert.description).style(theme::muted()),
);
Ok(())
}
pub async fn switch(
client: &ApiClient,
project_reference: &str,
alert: &str,
enabled: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client
.update_alert(
&project.id,
alert,
&serde_json::json!({ "enabled": enabled }),
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
println!(
" {} {} {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&answer.body.alert.name).style(theme::strong()),
if enabled {
"vigila de nuevo"
} else {
"en pausa"
}
.style(theme::muted()),
);
Ok(())
}
pub async fn remove(
client: &ApiClient,
project_reference: &str,
alert: &str,
confirmation: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.alert(&project.id, alert).await?.body.alert;
if confirmation != Some(found.name.as_str()) {
return Err(exit::Refused::invalid(format!(
"para borrar la alerta `{}` repite su nombre con --confirm {}",
sanitise::plain(&found.name),
shell_word(&found.name)
))
.into());
}
let removal = client.delete_alert(&project.id, &found.id).await?;
if format.is_json() {
output::emit(&removal);
return Ok(());
}
println!(
" {} {} borrada",
theme::CHECK.style(theme::success()),
sanitise::plain(&found.name).style(theme::strong()),
);
Ok(())
}
fn add_body(draft: &AlertDraft<'_>, service_environment_id: &str) -> Value {
let mut body = Map::new();
body.insert("serviceEnvironmentId".into(), service_environment_id.into());
match draft.kind {
AlertKind::Logs(pattern) => {
body.insert("kind".into(), "log".into());
body.insert("pattern".into(), pattern.into());
body.insert(
"patternKind".into(),
if draft.regex { "regex" } else { "substring" }.into(),
);
if let Some(count) = draft.count {
body.insert("threshold".into(), count.into());
}
if let Some(minutes) = draft.minutes {
body.insert("windowMinutes".into(), minutes.into());
}
}
AlertKind::ErrorRate(percent) => {
body.insert("kind".into(), "error-rate".into());
body.insert("threshold".into(), percent.into());
}
AlertKind::Latency(millis) => {
body.insert("kind".into(), "latency".into());
body.insert("threshold".into(), millis.into());
}
}
if let Some(minimum) = draft.min_requests {
body.insert("minimumRequests".into(), minimum.into());
}
if let Some(cooldown) = draft.cooldown {
body.insert("cooldownMinutes".into(), cooldown.into());
}
if let Some(name) = draft.name {
body.insert("name".into(), name.into());
}
Value::Object(body)
}
fn change_body(change: &AlertChange<'_>) -> Value {
let mut body = Map::new();
if let Some(name) = change.name {
body.insert("name".into(), name.into());
}
if let Some(pattern) = change.pattern {
body.insert("pattern".into(), pattern.into());
}
if let Some(kind) = change.pattern_kind {
body.insert("patternKind".into(), kind.into());
}
if let Some(threshold) = change.threshold {
body.insert("threshold".into(), threshold.into());
}
if let Some(minutes) = change.minutes {
body.insert("windowMinutes".into(), minutes.into());
}
if let Some(minimum) = change.min_requests {
body.insert("minimumRequests".into(), minimum.into());
}
if let Some(cooldown) = change.cooldown {
body.insert("cooldownMinutes".into(), cooldown.into());
}
Value::Object(body)
}
fn state_cell(alert: &AlertRule) -> Cell {
if !alert.enabled {
return Cell::styled("en pausa", theme::muted());
}
if alert.state == "firing" {
Cell::styled("disparada", theme::danger())
} else {
Cell::styled("tranquila", theme::success())
}
}
fn last_fired(alert: &AlertRule) -> String {
alert
.fired_at
.as_deref()
.and_then(time::relative_time)
.unwrap_or_else(|| "nunca".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn draft(kind: AlertKind<'static>) -> AlertDraft<'static> {
AlertDraft {
project: "tienda",
service: "api",
kind,
regex: false,
count: None,
minutes: None,
min_requests: None,
cooldown: None,
name: None,
}
}
#[test]
fn it_should_send_a_log_alert_as_a_substring_unless_told() {
assert_eq!(
add_body(&draft(AlertKind::Logs("ERROR")), "sve_1")["patternKind"],
"substring"
);
}
#[test]
fn it_should_send_a_regular_expression_when_asked() {
let regex = AlertDraft {
regex: true,
..draft(AlertKind::Logs("timeout \\d+"))
};
assert_eq!(add_body(®ex, "sve_1")["patternKind"], "regex");
}
#[test]
fn it_should_send_the_error_rate_as_the_threshold() {
assert_eq!(
add_body(&draft(AlertKind::ErrorRate(2.5)), "sve_1")["threshold"],
2.5
);
}
#[test]
fn it_should_send_a_latency_alert_with_its_kind() {
assert_eq!(
add_body(&draft(AlertKind::Latency(800)), "sve_1")["kind"],
"latency"
);
}
#[test]
fn it_should_send_only_what_changes() {
let change = AlertChange {
cooldown: Some(10),
..AlertChange::default()
};
assert_eq!(
change_body(&change),
serde_json::json!({ "cooldownMinutes": 10 })
);
}
}