use anyhow::Result;
use owo_colors::OwoColorize;
use serde_json::{Map, Value};
use crate::api::client::ApiClient;
use crate::api::models::Monitor;
use crate::commands::read::sparkline;
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 struct MonitorDraft<'a> {
pub project: &'a str,
pub service: Option<&'a str>,
pub url: Option<&'a str>,
pub path: Option<&'a str>,
pub internal: bool,
pub every: Option<u32>,
pub timeout: Option<u32>,
pub expect: Option<&'a str>,
pub keyword: Option<&'a str>,
pub failures: Option<u32>,
pub wake: bool,
pub name: Option<&'a str>,
}
#[derive(Default)]
pub struct MonitorChange<'a> {
pub name: Option<&'a str>,
pub path: Option<&'a str>,
pub url: Option<&'a str>,
pub via: Option<&'a str>,
pub every: Option<u32>,
pub timeout: Option<u32>,
pub expect: Option<&'a str>,
pub keyword: Option<Option<&'a str>>,
pub failures: Option<u32>,
pub wake: Option<bool>,
}
pub async fn list(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.monitors(&project.id).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let mut table = Table::new(&[
"monitor",
"estado",
"comprueba",
"cada",
"24 h",
"30 dรญas",
"รบltima",
]);
for monitor in &answer.body.monitors {
table.push(vec![
Cell::styled(sanitise::plain(&monitor.name), theme::strong()),
state_cell(&monitor.state),
Cell::styled(sanitise::plain(&monitor.target), theme::muted()),
Cell::plain(seconds_word(monitor.interval_seconds)),
Cell::plain(percent_word(monitor.uptime.day)),
Cell::plain(percent_word(monitor.uptime.month)),
Cell::styled(last_check(monitor), theme::muted()),
]);
}
table.print_or(format_args!(
"`{}` no tiene monitores. fundaia monitors add {} <servicio> --path /health",
project.name, project.slug
));
Ok(())
}
pub async fn show(
client: &ApiClient,
project_reference: &str,
monitor: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client.monitor(&project.id, monitor).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let monitor = &answer.body.monitor;
println!();
println!(
" {} {}",
sanitise::plain(&monitor.name).style(theme::strong()),
state_word(&monitor.state).style(theme::state_style(state_tone(&monitor.state))),
);
println!(
" {} {}",
"comprueba".style(theme::muted()),
sanitise::plain(&monitor.target)
);
println!(
" {} {} ยท espera {} ยท {} fallos seguidos para darlo por caรญdo",
"cada".style(theme::muted()),
seconds_word(monitor.interval_seconds),
sanitise::plain(&monitor.expected_status),
monitor.failures_to_alert,
);
if let Some(keyword) = &monitor.keyword {
println!(
" {} ยซ{}ยป",
"debe contener".style(theme::muted()),
sanitise::plain(keyword)
);
}
println!(
" {} 24 h {} ยท 7 dรญas {} ยท 30 dรญas {}",
"disponible".style(theme::muted()),
percent_word(monitor.uptime.day),
percent_word(monitor.uptime.week),
percent_word(monitor.uptime.month),
);
let times: Vec<u64> = monitor
.sparkline
.iter()
.map(|point| point.average_millis.unwrap_or(0))
.collect();
if times.iter().any(|value| *value > 0) {
println!(
" {} {}",
"respuesta, รบltimas 24 h".style(theme::muted()),
sparkline(×).style(theme::accent())
);
}
println!(
" {} {}",
"รบltima".style(theme::muted()),
last_check(monitor)
);
if let Some(error) = &monitor.last_error {
println!(" {}", sanitise::plain(error).style(theme::danger()));
}
println!();
Ok(())
}
pub async fn add(client: &ApiClient, draft: MonitorDraft<'_>, format: Format) -> Result<()> {
if draft.service.is_none() == draft.url.is_none() {
return Err(exit::Refused::invalid(
"di quรฉ comprobar: un servicio, o una direcciรณn con --url",
)
.into());
}
let project = client.find_project(draft.project).await?;
let service_environment_id = match draft.service {
Some(reference) => Some(client.find_service(&project.id, reference).await?.id),
None => None,
};
let answer = client
.create_monitor(
&project.id,
&add_body(&draft, service_environment_id.as_deref()),
)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let monitor = &answer.body.monitor;
println!(
" {} {} comprueba {} cada {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&monitor.name).style(theme::strong()),
sanitise::plain(&monitor.target),
seconds_word(monitor.interval_seconds),
);
Ok(())
}
pub async fn edit(
client: &ApiClient,
project_reference: &str,
monitor: &str,
change: MonitorChange<'_>,
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, --path, --url, --every, --timeout, --expect, --keyword, --failures o --wake",
)
.into());
}
let project = client.find_project(project_reference).await?;
let answer = client.update_monitor(&project.id, monitor, &body).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let monitor = &answer.body.monitor;
println!(
" {} {} comprueba {} cada {}",
theme::CHECK.style(theme::success()),
sanitise::plain(&monitor.name).style(theme::strong()),
sanitise::plain(&monitor.target),
seconds_word(monitor.interval_seconds),
);
Ok(())
}
pub async fn switch(
client: &ApiClient,
project_reference: &str,
monitor: &str,
enabled: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let answer = client
.update_monitor(
&project.id,
monitor,
&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.monitor.name).style(theme::strong()),
if enabled {
"vuelve a comprobarse"
} else {
"en pausa, con su historia"
}
.style(theme::muted()),
);
Ok(())
}
pub async fn remove(
client: &ApiClient,
project_reference: &str,
monitor: &str,
confirmation: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.monitor(&project.id, monitor).await?.body.monitor;
if confirmation != Some(found.name.as_str()) {
return Err(exit::Refused::invalid(format!(
"borrar el monitor `{}` se lleva su historia: repite su nombre con --confirm {}",
sanitise::plain(&found.name),
shell_word(&found.name)
))
.into());
}
let removal = client.delete_monitor(&project.id, &found.id).await?;
if format.is_json() {
output::emit(&removal);
return Ok(());
}
println!(
" {} {} borrado",
theme::CHECK.style(theme::success()),
sanitise::plain(&found.name).style(theme::strong()),
);
Ok(())
}
pub fn parse_seconds(text: &str) -> Result<u32, String> {
let trimmed = text.trim().to_lowercase();
let (digits, factor) = if let Some(minutes) = trimmed.strip_suffix('m') {
(minutes, 60)
} else if let Some(seconds) = trimmed.strip_suffix('s') {
(seconds, 1)
} else {
(trimmed.as_str(), 1)
};
digits
.trim()
.parse::<u32>()
.ok()
.and_then(|value| value.checked_mul(factor))
.filter(|value| *value > 0)
.ok_or_else(|| format!("`{text}` no es una duraciรณn: 30s, 90, 5m"))
}
fn add_body(draft: &MonitorDraft<'_>, service_environment_id: Option<&str>) -> Value {
let mut body = Map::new();
if let Some(id) = service_environment_id {
body.insert("serviceEnvironmentId".into(), id.into());
body.insert(
"via".into(),
if draft.internal { "internal" } else { "public" }.into(),
);
if draft.wake {
body.insert("wake".into(), true.into());
}
}
if let Some(url) = draft.url {
body.insert("url".into(), url.into());
}
if let Some(path) = draft.path {
body.insert("path".into(), path.into());
}
if let Some(every) = draft.every {
body.insert("intervalSeconds".into(), every.into());
}
if let Some(timeout) = draft.timeout {
body.insert("timeoutSeconds".into(), timeout.into());
}
if let Some(expect) = draft.expect {
body.insert("expectedStatus".into(), expect.into());
}
if let Some(keyword) = draft.keyword {
body.insert("keyword".into(), keyword.into());
}
if let Some(failures) = draft.failures {
body.insert("failuresToAlert".into(), failures.into());
}
if let Some(name) = draft.name {
body.insert("name".into(), name.into());
}
Value::Object(body)
}
fn change_body(change: &MonitorChange<'_>) -> Value {
let mut body = Map::new();
if let Some(name) = change.name {
body.insert("name".into(), name.into());
}
if let Some(path) = change.path {
body.insert("path".into(), path.into());
}
if let Some(url) = change.url {
body.insert("url".into(), url.into());
}
if let Some(via) = change.via {
body.insert("via".into(), via.into());
}
if let Some(every) = change.every {
body.insert("intervalSeconds".into(), every.into());
}
if let Some(timeout) = change.timeout {
body.insert("timeoutSeconds".into(), timeout.into());
}
if let Some(expect) = change.expect {
body.insert("expectedStatus".into(), expect.into());
}
match change.keyword {
Some(Some(keyword)) => {
body.insert("keyword".into(), keyword.into());
}
Some(None) => {
body.insert("keyword".into(), Value::Null);
}
None => {}
}
if let Some(failures) = change.failures {
body.insert("failuresToAlert".into(), failures.into());
}
if let Some(wake) = change.wake {
body.insert("wake".into(), wake.into());
}
Value::Object(body)
}
fn state_word(state: &str) -> &str {
match state {
"up" => "en marcha",
"down" => "caรญdo",
"sleeping" => "durmiendo",
"paused" => "en pausa",
_ => "comprobando",
}
}
fn state_tone(state: &str) -> &'static str {
match state {
"up" => "active",
"down" => "failed",
"sleeping" => "sleeping",
_ => "queued",
}
}
fn state_cell(state: &str) -> Cell {
Cell::styled(state_word(state), theme::state_style(state_tone(state)))
}
pub fn seconds_word(seconds: u32) -> String {
if seconds >= 60 && seconds % 60 == 0 {
format!("{} min", seconds / 60)
} else {
format!("{seconds} s")
}
}
pub fn percent_word(value: Option<f64>) -> String {
match value {
None => "โ".to_owned(),
Some(percent) => format!(
"{} %",
format!("{percent:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
)
.replace('.', ","),
}
}
fn last_check(monitor: &Monitor) -> String {
let when = monitor
.last_checked_at
.as_deref()
.and_then(time::relative_time)
.unwrap_or_else(|| "nunca".to_owned());
match (monitor.last_status_code, monitor.last_response_millis) {
(Some(status), Some(millis)) => format!("{when} ยท {status} en {millis} ms"),
_ => when,
}
}
pub fn shell_word(name: &str) -> String {
if name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
{
name.to_owned()
} else {
format!("'{}'", name.replace('\'', "'\\''"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn draft() -> MonitorDraft<'static> {
MonitorDraft {
project: "tienda",
service: Some("api"),
url: None,
path: Some("/health"),
internal: true,
every: Some(30),
timeout: None,
expect: None,
keyword: None,
failures: None,
wake: false,
name: None,
}
}
#[test]
fn it_should_read_minutes() {
assert_eq!(parse_seconds("5m"), Ok(300));
}
#[test]
fn it_should_read_bare_seconds() {
assert_eq!(parse_seconds("90"), Ok(90));
}
#[test]
fn it_should_refuse_a_duration_it_cannot_read() {
assert!(parse_seconds("soon").is_err());
}
#[test]
fn it_should_send_the_internal_way_in_for_a_service() {
assert_eq!(add_body(&draft(), Some("sve_1"))["via"], "internal");
}
#[test]
fn it_should_send_no_way_in_for_a_url() {
let url = MonitorDraft {
service: None,
url: Some("https://example.com"),
..draft()
};
assert!(add_body(&url, None).get("via").is_none());
}
#[test]
fn it_should_send_a_null_keyword_to_remove_it() {
let change = MonitorChange {
keyword: Some(None),
..MonitorChange::default()
};
assert_eq!(change_body(&change), serde_json::json!({ "keyword": null }));
}
#[test]
fn it_should_write_a_percentage_the_spanish_way() {
assert_eq!(percent_word(Some(99.95)), "99,95 %");
}
#[test]
fn it_should_drop_the_decimals_of_a_whole_percentage() {
assert_eq!(percent_word(Some(100.0)), "100 %");
}
#[test]
fn it_should_say_a_minute_in_minutes() {
assert_eq!(seconds_word(120), "2 min");
}
#[test]
fn it_should_quote_a_name_with_a_space() {
assert_eq!(shell_word("api /health"), "'api /health'");
}
}