diff --git a/Cargo.lock b/Cargo.lock index ddbc6e1..03dcc5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -83,6 +92,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -94,6 +109,7 @@ name = "betterstack-cli" version = "0.7.0" dependencies = [ "anyhow", + "chrono", "clap", "clap_complete", "libc", @@ -147,6 +163,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.5.60" @@ -202,6 +229,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "deadpool" version = "0.12.3" @@ -562,6 +595,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -779,6 +836,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1685,12 +1751,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 4ad4982..8456be8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4.5.66" libc = "0.2" diff --git a/src/commands/heartbeats.rs b/src/commands/heartbeats.rs index 8add6c1..d0d691a 100644 --- a/src/commands/heartbeats.rs +++ b/src/commands/heartbeats.rs @@ -2,6 +2,7 @@ use anyhow::Result; use crate::context::AppContext; use crate::output::CommandOutput; +use crate::output::fmt; use crate::types::{ CreateHeartbeatRequest, HeartbeatFilters, HeartbeatResource, UpdateHeartbeatRequest, }; @@ -294,10 +295,10 @@ fn heartbeats_to_table(heartbeats: Vec) -> CommandOutput { h.id.clone(), a.name.clone().unwrap_or_else(|| "-".to_string()), a.period - .map(format_seconds) + .map(fmt::duration) .unwrap_or_else(|| "-".to_string()), a.grace - .map(format_seconds) + .map(fmt::duration) .unwrap_or_else(|| "-".to_string()), a.status.clone().unwrap_or_else(|| "-".to_string()), a.url.clone().unwrap_or_else(|| "-".to_string()), @@ -326,13 +327,13 @@ fn heartbeat_to_detail(h: &HeartbeatResource) -> CommandOutput { ( "Period".to_string(), a.period - .map(format_seconds) + .map(fmt::duration) .unwrap_or_else(|| "-".to_string()), ), ( "Grace".to_string(), a.grace - .map(format_seconds) + .map(fmt::duration) .unwrap_or_else(|| "-".to_string()), ), ( @@ -365,7 +366,10 @@ fn heartbeat_to_detail(h: &HeartbeatResource) -> CommandOutput { ), ( "Created".to_string(), - a.created_at.clone().unwrap_or_else(|| "-".to_string()), + a.created_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ), ]; CommandOutput::Detail { fields } @@ -384,7 +388,7 @@ fn sla_to_detail(sla: &crate::types::SlaResource) -> CommandOutput { ( "Total Downtime".to_string(), a.total_downtime - .map(|v| format_seconds(v as u64)) + .map(|v| fmt::duration(v as u64)) .unwrap_or_else(|| "-".to_string()), ), ( @@ -396,31 +400,19 @@ fn sla_to_detail(sla: &crate::types::SlaResource) -> CommandOutput { ( "Longest Incident".to_string(), a.longest_incident - .map(|v| format_seconds(v as u64)) + .map(|v| fmt::duration(v as u64)) .unwrap_or_else(|| "-".to_string()), ), ( "Avg Incident".to_string(), a.average_incident - .map(|v| format_seconds(v as u64)) + .map(|v| fmt::duration(v as u64)) .unwrap_or_else(|| "-".to_string()), ), ]; CommandOutput::Detail { fields } } -fn format_seconds(s: u64) -> String { - if s < 60 { - format!("{s}s") - } else if s < 3600 { - format!("{}m", s / 60) - } else if s < 86400 { - format!("{}h", s / 3600) - } else { - format!("{}d", s / 86400) - } -} - fn default_update() -> UpdateHeartbeatRequest { UpdateHeartbeatRequest { name: None, diff --git a/src/commands/incidents.rs b/src/commands/incidents.rs index d800bfe..86e0f89 100644 --- a/src/commands/incidents.rs +++ b/src/commands/incidents.rs @@ -1,8 +1,10 @@ use anyhow::Result; use crate::context::AppContext; +use crate::context::OutputFormat; use crate::output::CommandOutput; use crate::output::color; +use crate::output::fmt; use crate::types::{ CommentResource, CreateCommentRequest, CreateIncidentRequest, EscalateIncidentRequest, IncidentFilters, IncidentResource, TimelineEvent, @@ -218,11 +220,15 @@ impl IncidentsCmd { } IncidentsSubCmd::Get { id } => { let incident = ctx.uptime.get_incident(id).await?; - let timeline = ctx.uptime.incident_timeline(id).await?; - let comments = ctx.uptime.list_comments(id).await.unwrap_or_default(); - Ok(incident_detail_with_timeline( - &incident, &timeline, &comments, - )) + if ctx.global.output_format == OutputFormat::Table { + let timeline = ctx.uptime.incident_timeline(id).await?; + let comments = ctx.uptime.list_comments(id).await.unwrap_or_default(); + Ok(incident_detail_with_timeline( + &incident, &timeline, &comments, + )) + } else { + Ok(incident_to_detail(&incident)) + } } IncidentsSubCmd::Create { name, @@ -352,12 +358,20 @@ impl IncidentsCmd { } IncidentsSubCmd::Timeline { id } => { let events = ctx.uptime.incident_timeline(id).await?; - Ok(CommandOutput::Raw(render_timeline(&events))) + if ctx.global.output_format == OutputFormat::Table { + Ok(CommandOutput::Raw(render_timeline(&events))) + } else { + Ok(timeline_to_table(&events)) + } } IncidentsSubCmd::Comments(sub) => match sub { CommentsSubCmd::List { incident_id } => { let comments = ctx.uptime.list_comments(incident_id).await?; - Ok(CommandOutput::Raw(render_comments(&comments))) + if ctx.global.output_format == OutputFormat::Table { + Ok(CommandOutput::Raw(render_comments(&comments))) + } else { + Ok(comments_to_table(&comments)) + } } CommentsSubCmd::Add { incident_id, @@ -663,12 +677,7 @@ fn s(opt: &Option) -> String { fn fmt_time(t: Option<&str>) -> String { match t { - Some(ts) => ts - .split('T') - .nth(1) - .and_then(|t| t.strip_suffix('Z')) - .map(|t| t.to_string()) - .unwrap_or_else(|| ts.to_string()), + Some(ts) => fmt::relative_time(ts), None => "-".to_string(), } } @@ -683,3 +692,52 @@ fn resolve_by(flag: &Option, ctx: &AppContext) -> Result { } anyhow::bail!("No --by provided and no email configured. Run `bs auth init` to set your email.") } + +fn timeline_to_table(events: &[TimelineEvent]) -> CommandOutput { + let headers = vec!["At".to_string(), "Type".to_string(), "Content".to_string()]; + let rows = events + .iter() + .map(|e| { + let a = &e.attributes; + let content = a + .data + .as_ref() + .and_then(|d| match &d.content { + Some(serde_json::Value::String(s)) => Some(s.clone()), + Some(serde_json::Value::Object(obj)) => { + obj.get("text").and_then(|t| t.as_str()).map(String::from) + } + _ => None, + }) + .unwrap_or_else(|| "-".to_string()); + vec![ + a.at.clone().unwrap_or_else(|| "-".to_string()), + a.item_type.clone().unwrap_or_else(|| "-".to_string()), + content, + ] + }) + .collect(); + CommandOutput::Table { headers, rows } +} + +fn comments_to_table(comments: &[CommentResource]) -> CommandOutput { + let headers = vec![ + "ID".to_string(), + "Author".to_string(), + "Content".to_string(), + "Created At".to_string(), + ]; + let rows = comments + .iter() + .map(|c| { + let a = &c.attributes; + vec![ + c.id.clone(), + a.user_email.clone().unwrap_or_else(|| "-".to_string()), + a.content.clone().unwrap_or_else(|| "-".to_string()), + a.created_at.clone().unwrap_or_else(|| "-".to_string()), + ] + }) + .collect(); + CommandOutput::Table { headers, rows } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ab0b331..f8fead8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod oncall; pub mod policies; pub mod severities; pub mod sources; +pub mod status; pub mod status_pages; pub mod upgrade; @@ -23,6 +24,7 @@ pub use oncall::OnCallCmd; pub use policies::PoliciesCmd; pub use severities::SeveritiesCmd; pub use sources::SourcesCmd; +pub use status::StatusCmd; pub use status_pages::StatusPagesCmd; use std::io::{self, BufRead, Write}; diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs index b0f25b5..3532c4d 100644 --- a/src/commands/monitors.rs +++ b/src/commands/monitors.rs @@ -1,8 +1,13 @@ use anyhow::Result; -use crate::context::AppContext; +use crate::context::{AppContext, OutputFormat}; use crate::output::CommandOutput; -use crate::types::{CreateMonitorRequest, MonitorFilters, MonitorResource, UpdateMonitorRequest}; +use crate::output::color; +use crate::output::fmt; +use crate::types::{ + CreateMonitorRequest, MonitorFilters, MonitorResource, RegionResponseTimes, + ResponseTimesResource, SlaResource, UpdateMonitorRequest, +}; #[derive(clap::Args)] pub struct MonitorsCmd { @@ -165,7 +170,15 @@ impl MonitorsCmd { } MonitorsSubCmd::Get { id } => { let monitor = ctx.uptime.get_monitor(id).await?; - Ok(monitor_to_detail(&monitor)) + if ctx.global.output_format == OutputFormat::Table { + let sla = ctx.uptime.monitor_sla(id, None, None).await.ok(); + let rt = ctx.uptime.monitor_response_times(id, None, None).await.ok(); + Ok(monitor_detail_rich(&monitor, sla.as_ref(), rt.as_ref())) + } else { + Ok(CommandOutput::Detail { + fields: build_monitor_fields(&monitor), + }) + } } MonitorsSubCmd::Create { url, @@ -189,7 +202,9 @@ impl MonitorsCmd { ..default_create_request() }; let monitor = ctx.uptime.create_monitor(&req).await?; - Ok(monitor_to_detail(&monitor)) + Ok(CommandOutput::Detail { + fields: build_monitor_fields(&monitor), + }) } MonitorsSubCmd::Pause { id } => { let monitor = ctx.uptime.pause_monitor(id).await?; @@ -246,7 +261,9 @@ impl MonitorsCmd { paused: None, }; let monitor = ctx.uptime.update_monitor(id, &req).await?; - Ok(monitor_to_detail(&monitor)) + Ok(CommandOutput::Detail { + fields: build_monitor_fields(&monitor), + }) } MonitorsSubCmd::Delete { id } => { ctx.uptime.delete_monitor(id).await?; @@ -319,9 +336,12 @@ fn monitors_to_table(monitors: Vec) -> CommandOutput { a.monitor_type.clone().unwrap_or_else(|| "-".to_string()), a.status.clone().unwrap_or_else(|| "-".to_string()), a.check_frequency - .map(|f| format!("{}s", f)) + .map(|f| fmt::duration(f as u64)) + .unwrap_or_else(|| "-".to_string()), + a.last_checked_at + .as_deref() + .map(fmt::relative_time) .unwrap_or_else(|| "-".to_string()), - a.last_checked_at.clone().unwrap_or_else(|| "-".to_string()), ] }) .collect(); @@ -329,9 +349,9 @@ fn monitors_to_table(monitors: Vec) -> CommandOutput { CommandOutput::Table { headers, rows } } -fn monitor_to_detail(m: &MonitorResource) -> CommandOutput { +fn build_monitor_fields(m: &MonitorResource) -> Vec<(String, String)> { let a = &m.attributes; - let fields = vec![ + vec![ ("ID".to_string(), m.id.clone()), ( "Name".to_string(), @@ -354,7 +374,7 @@ fn monitor_to_detail(m: &MonitorResource) -> CommandOutput { ( "Frequency".to_string(), a.check_frequency - .map(|f| format!("{}s", f)) + .map(|f| fmt::duration(f as u64)) .unwrap_or_else(|| "-".to_string()), ), ( @@ -376,17 +396,138 @@ fn monitor_to_detail(m: &MonitorResource) -> CommandOutput { ), ( "Last Checked".to_string(), - a.last_checked_at.clone().unwrap_or_else(|| "-".to_string()), + a.last_checked_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ), ( "Created".to_string(), - a.created_at.clone().unwrap_or_else(|| "-".to_string()), + a.created_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ), - ]; - CommandOutput::Detail { fields } + ] +} + +fn monitor_detail_rich( + m: &MonitorResource, + sla: Option<&SlaResource>, + rt: Option<&ResponseTimesResource>, +) -> CommandOutput { + let mut out = String::new(); + let fields = build_monitor_fields(m); + let max_label = fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + for (key, value) in &fields { + out.push_str(&format!( + "{} {}\n", + color::bold(&format!("{key: 0.0 + { + out.push_str(&format!( + " Longest {}\n", + fmt::duration(longest as u64) + )); + } + } + + // Inline response times (per-region percentiles) + if let Some(rt) = rt + && let Some(regions) = &rt.attributes.regions + { + let summaries: Vec<(String, RegionSummary)> = regions + .iter() + .filter_map(|r| { + let name = r.region.as_deref().unwrap_or("unknown").to_string(); + compute_percentiles(r).map(|s| (name, s)) + }) + .collect(); + if !summaries.is_empty() { + out.push_str(&format!("\n{}\n", color::bold("Response Times"))); + for (name, s) in &summaries { + out.push_str(&format!( + " {:<4} avg={:<8} p50={:<8} p95={:<8} p99={:<8} ({} checks)\n", + name, + format!("{:.0}ms", s.avg), + format!("{:.0}ms", s.p50), + format!("{:.0}ms", s.p95), + format!("{:.0}ms", s.p99), + s.count, + )); + } + } + } + + CommandOutput::Raw(out.trim_end().to_string()) +} + +fn format_availability(pct: f64) -> String { + let text = format!("{:.4}%", pct); + if pct >= 99.9 { + color::green(&text) + } else if pct >= 99.0 { + color::yellow(&text) + } else { + color::red(&text) + } +} + +struct RegionSummary { + avg: f64, + p50: f64, + p95: f64, + p99: f64, + count: usize, +} + +fn compute_percentiles(region: &RegionResponseTimes) -> Option { + let entries = region.response_times.as_ref()?; + let mut times: Vec = entries.iter().filter_map(|e| e.response_time).collect(); + if times.is_empty() { + return None; + } + times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let count = times.len(); + let avg: f64 = times.iter().sum::() / count as f64; + // Convert seconds to milliseconds + let to_ms = 1000.0; + Some(RegionSummary { + avg: avg * to_ms, + p50: percentile(×, 50.0) * to_ms, + p95: percentile(×, 95.0) * to_ms, + p99: percentile(×, 99.0) * to_ms, + count, + }) } -fn sla_to_detail(sla: &crate::types::SlaResource) -> CommandOutput { +fn percentile(sorted: &[f64], pct: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let idx = (pct / 100.0 * (sorted.len() - 1) as f64).round() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +fn sla_to_detail(sla: &SlaResource) -> CommandOutput { let a = &sla.attributes; let fields = vec![ ("Monitor ID".to_string(), sla.id.clone()), @@ -399,7 +540,7 @@ fn sla_to_detail(sla: &crate::types::SlaResource) -> CommandOutput { ( "Total Downtime".to_string(), a.total_downtime - .map(format_duration) + .map(|v| fmt::duration(v as u64)) .unwrap_or_else(|| "-".to_string()), ), ( @@ -411,20 +552,20 @@ fn sla_to_detail(sla: &crate::types::SlaResource) -> CommandOutput { ( "Longest Incident".to_string(), a.longest_incident - .map(format_duration) + .map(|v| fmt::duration(v as u64)) .unwrap_or_else(|| "-".to_string()), ), ( "Avg Incident".to_string(), a.average_incident - .map(format_duration) + .map(|v| fmt::duration(v as u64)) .unwrap_or_else(|| "-".to_string()), ), ]; CommandOutput::Detail { fields } } -fn response_times_to_table(resource: &crate::types::ResponseTimesResource) -> CommandOutput { +fn response_times_to_table(resource: &ResponseTimesResource) -> CommandOutput { let headers = vec![ "At".to_string(), "Region".to_string(), @@ -438,11 +579,15 @@ fn response_times_to_table(resource: &crate::types::ResponseTimesResource) -> Co if let Some(entries) = ®ion_data.response_times { for entry in entries { rows.push(vec![ - entry.at.clone().unwrap_or_else(|| "-".to_string()), + entry + .at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), region_name.to_string(), entry .response_time - .map(|v| format!("{:.3}s", v)) + .map(|v| format!("{:.0}ms", v * 1000.0)) .unwrap_or_else(|| "-".to_string()), ]); } @@ -453,18 +598,6 @@ fn response_times_to_table(resource: &crate::types::ResponseTimesResource) -> Co CommandOutput::Table { headers, rows } } -fn format_duration(seconds: f64) -> String { - if seconds < 60.0 { - format!("{:.0}s", seconds) - } else if seconds < 3600.0 { - format!("{:.1}m", seconds / 60.0) - } else if seconds < 86400.0 { - format!("{:.1}h", seconds / 3600.0) - } else { - format!("{:.1}d", seconds / 86400.0) - } -} - fn default_create_request() -> CreateMonitorRequest { CreateMonitorRequest { url: String::new(), diff --git a/src/commands/status.rs b/src/commands/status.rs new file mode 100644 index 0000000..93bef79 --- /dev/null +++ b/src/commands/status.rs @@ -0,0 +1,232 @@ +use anyhow::Result; + +use crate::context::{AppContext, OutputFormat}; +use crate::output::CommandOutput; +use crate::output::color; +use crate::output::fmt; +use crate::types::{HeartbeatResource, IncidentFilters, IncidentResource, MonitorResource}; + +#[derive(clap::Args)] +pub struct StatusCmd; + +impl StatusCmd { + pub async fn run(&self, ctx: &AppContext) -> Result { + let mon_filters = crate::types::MonitorFilters::default(); + let inc_filters = IncidentFilters::default(); + let (monitors, heartbeats, incidents) = tokio::try_join!( + ctx.uptime.list_monitors(&mon_filters), + ctx.uptime.list_heartbeats(), + ctx.uptime.list_incidents(&inc_filters), + )?; + + if ctx.global.output_format == OutputFormat::Table { + Ok(CommandOutput::Raw(render_status( + &monitors, + &heartbeats, + &incidents, + ))) + } else { + Ok(status_to_table(&monitors, &heartbeats, &incidents)) + } + } +} + +fn render_status( + monitors: &[MonitorResource], + heartbeats: &[HeartbeatResource], + incidents: &[IncidentResource], +) -> String { + let mut out = String::new(); + + // Active incidents (not resolved) + let active: Vec<&IncidentResource> = incidents + .iter() + .filter(|i| i.attributes.resolved_at.is_none()) + .collect(); + + if !active.is_empty() { + out.push_str(&format!("{}\n", color::bold("Active Incidents"))); + for i in &active { + let a = &i.attributes; + let name = a.name.as_deref().unwrap_or("Unnamed"); + let status = if a.acknowledged_at.is_some() { + "acknowledged" + } else { + "started" + }; + let since = a + .started_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()); + out.push_str(&format!( + " {} {} {} {}\n", + color::status(status), + color::bold(name), + color::dim(&format!("#{}", i.id)), + color::dim(&since), + )); + } + out.push('\n'); + } + + // Monitors summary + let mon_up = monitors + .iter() + .filter(|m| m.attributes.status.as_deref() == Some("up")) + .count(); + let mon_down = monitors + .iter() + .filter(|m| m.attributes.status.as_deref() == Some("down")) + .count(); + let mon_paused = monitors + .iter() + .filter(|m| m.attributes.status.as_deref() == Some("paused")) + .count(); + let mon_other = monitors.len() - mon_up - mon_down - mon_paused; + + out.push_str(&format!("{} ", color::bold("Monitors"))); + let mut parts = Vec::new(); + if mon_up > 0 { + parts.push(color::green(&format!("{mon_up} up"))); + } + if mon_down > 0 { + parts.push(color::red(&format!("{mon_down} down"))); + } + if mon_paused > 0 { + parts.push(color::yellow(&format!("{mon_paused} paused"))); + } + if mon_other > 0 { + parts.push(color::dim(&format!("{mon_other} other"))); + } + if parts.is_empty() { + parts.push("none".to_string()); + } + out.push_str(&parts.join(", ")); + out.push('\n'); + + // Show down monitors + for m in monitors + .iter() + .filter(|m| m.attributes.status.as_deref() == Some("down")) + { + let a = &m.attributes; + let name = a.pronounceable_name.as_deref().unwrap_or("-"); + let url = a.url.as_deref().unwrap_or(""); + let last = a + .last_checked_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()); + out.push_str(&format!( + " {} {} {} {}\n", + color::red("●"), + name, + color::dim(url), + color::dim(&last), + )); + } + + // Heartbeats summary + let hb_up = heartbeats + .iter() + .filter(|h| h.attributes.status.as_deref() == Some("up")) + .count(); + let hb_down = heartbeats + .iter() + .filter(|h| h.attributes.status.as_deref() == Some("down")) + .count(); + let hb_paused = heartbeats + .iter() + .filter(|h| h.attributes.status.as_deref() == Some("paused")) + .count(); + let hb_other = heartbeats.len() - hb_up - hb_down - hb_paused; + + out.push_str(&format!("\n{} ", color::bold("Heartbeats"))); + let mut parts = Vec::new(); + if hb_up > 0 { + parts.push(color::green(&format!("{hb_up} up"))); + } + if hb_down > 0 { + parts.push(color::red(&format!("{hb_down} down"))); + } + if hb_paused > 0 { + parts.push(color::yellow(&format!("{hb_paused} paused"))); + } + if hb_other > 0 { + parts.push(color::dim(&format!("{hb_other} other"))); + } + if parts.is_empty() { + parts.push("none".to_string()); + } + out.push_str(&parts.join(", ")); + out.push('\n'); + + // Show down heartbeats + for h in heartbeats + .iter() + .filter(|h| h.attributes.status.as_deref() == Some("down")) + { + let name = h.attributes.name.as_deref().unwrap_or("-"); + out.push_str(&format!(" {} {}\n", color::red("●"), name)); + } + + out.trim_end().to_string() +} + +/// Structured table for JSON/CSV output: one row per resource with type and status. +fn status_to_table( + monitors: &[MonitorResource], + heartbeats: &[HeartbeatResource], + incidents: &[IncidentResource], +) -> CommandOutput { + let headers = vec![ + "Type".to_string(), + "ID".to_string(), + "Name".to_string(), + "Status".to_string(), + ]; + let mut rows: Vec> = Vec::new(); + for m in monitors { + rows.push(vec![ + "monitor".to_string(), + m.id.clone(), + m.attributes + .pronounceable_name + .clone() + .unwrap_or_else(|| "-".to_string()), + m.attributes + .status + .clone() + .unwrap_or_else(|| "-".to_string()), + ]); + } + for h in heartbeats { + rows.push(vec![ + "heartbeat".to_string(), + h.id.clone(), + h.attributes.name.clone().unwrap_or_else(|| "-".to_string()), + h.attributes + .status + .clone() + .unwrap_or_else(|| "-".to_string()), + ]); + } + for i in incidents + .iter() + .filter(|i| i.attributes.resolved_at.is_none()) + { + let status = if i.attributes.acknowledged_at.is_some() { + "acknowledged" + } else { + "started" + }; + rows.push(vec![ + "incident".to_string(), + i.id.clone(), + i.attributes.name.clone().unwrap_or_else(|| "-".to_string()), + status.to_string(), + ]); + } + CommandOutput::Table { headers, rows } +} diff --git a/src/commands/status_pages.rs b/src/commands/status_pages.rs index 9316b6c..edef80d 100644 --- a/src/commands/status_pages.rs +++ b/src/commands/status_pages.rs @@ -1,8 +1,9 @@ use anyhow::Result; -use crate::context::AppContext; +use crate::context::{AppContext, OutputFormat}; use crate::output::CommandOutput; use crate::output::color; +use crate::output::fmt; use crate::types::*; #[derive(clap::Args)] @@ -346,13 +347,19 @@ impl StatusPagesCmd { } StatusPagesSubCmd::Get { id } => { let page = ctx.uptime.get_status_page(id).await?; - let sections = ctx.uptime.list_status_page_sections(id).await.ok(); - let resources = ctx.uptime.list_status_page_resources(id).await.ok(); - Ok(page_detail_rich( - &page, - sections.as_deref(), - resources.as_deref(), - )) + if ctx.global.output_format == OutputFormat::Table { + let sections = ctx.uptime.list_status_page_sections(id).await.ok(); + let resources = ctx.uptime.list_status_page_resources(id).await.ok(); + Ok(page_detail_rich( + &page, + sections.as_deref(), + resources.as_deref(), + )) + } else { + Ok(CommandOutput::Detail { + fields: build_page_fields(&page), + }) + } } StatusPagesSubCmd::Create { name, @@ -552,12 +559,18 @@ async fn run_reports(ctx: &AppContext, cmd: &ReportsCmd) -> Result { let r = ctx.uptime.get_status_report(page_id, report_id).await?; - let updates = ctx - .uptime - .list_status_updates(page_id, report_id) - .await - .ok(); - Ok(report_detail_rich(&r, updates.as_deref())) + if ctx.global.output_format == OutputFormat::Table { + let updates = ctx + .uptime + .list_status_updates(page_id, report_id) + .await + .ok(); + Ok(report_detail_rich(&r, updates.as_deref())) + } else { + Ok(CommandOutput::Detail { + fields: build_report_fields(&r), + }) + } } ReportsSubCmd::Create { page_id, @@ -712,7 +725,10 @@ fn build_page_fields(p: &StatusPageResource) -> Vec<(String, String)> { ), ( "Created".to_string(), - a.created_at.clone().unwrap_or_else(|| "-".to_string()), + a.created_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ), ] } @@ -897,8 +913,14 @@ fn reports_to_table(reports: Vec) -> CommandOutput { a.title.clone().unwrap_or_else(|| "-".to_string()), a.report_type.clone().unwrap_or_else(|| "-".to_string()), a.aggregate_state.clone().unwrap_or_else(|| "-".to_string()), - a.starts_at.clone().unwrap_or_else(|| "-".to_string()), - a.ends_at.clone().unwrap_or_else(|| "-".to_string()), + a.starts_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), + a.ends_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ] }) .collect(); @@ -921,11 +943,17 @@ fn build_report_fields(r: &StatusReportResource) -> Vec<(String, String)> { ("Status".to_string(), color::status(status_raw)), ( "Starts At".to_string(), - a.starts_at.clone().unwrap_or_else(|| "-".to_string()), + a.starts_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ), ( "Ends At".to_string(), - a.ends_at.clone().unwrap_or_else(|| "-".to_string()), + a.ends_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), ), ]; if let Some(resources) = &a.affected_resources { @@ -970,7 +998,11 @@ fn report_detail_rich( out.push_str(&format!("{}\n", color::bold("Updates"))); for (idx, u) in updates.iter().enumerate() { let ua = &u.attributes; - let time = ua.published_at.as_deref().unwrap_or("-"); + let time = ua + .published_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()); let msg = ua.message.as_deref().unwrap_or("-"); let notified = ua .notify_subscribers @@ -981,7 +1013,7 @@ fn report_detail_rich( " {} {} {}{}\n", color::cyan("●"), msg, - color::dim(time), + color::dim(&time), color::dim(notified), )); if idx < updates.len() - 1 { @@ -1007,7 +1039,10 @@ fn updates_to_table(updates: Vec) -> CommandOutput { vec![ u.id.clone(), a.message.clone().unwrap_or_else(|| "-".to_string()), - a.published_at.clone().unwrap_or_else(|| "-".to_string()), + a.published_at + .as_deref() + .map(fmt::relative_time) + .unwrap_or_else(|| "-".to_string()), a.notify_subscribers .map(|v| if v { "yes" } else { "no" }) .unwrap_or("-") diff --git a/src/main.rs b/src/main.rs index 2f84066..d3dd0ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use betterstack_cli::adapters::config::FileConfigStore; use betterstack_cli::adapters::http::HttpClient; use betterstack_cli::commands::{ AuthCmd, HeartbeatGroupsCmd, HeartbeatsCmd, IncidentsCmd, LogsCmd, MonitorGroupsCmd, - MonitorsCmd, OnCallCmd, PoliciesCmd, SeveritiesCmd, SourcesCmd, StatusPagesCmd, + MonitorsCmd, OnCallCmd, PoliciesCmd, SeveritiesCmd, SourcesCmd, StatusCmd, StatusPagesCmd, }; use betterstack_cli::context::{AppContext, GlobalOptions, OutputFormat}; use betterstack_cli::output; @@ -63,6 +63,8 @@ enum Command { Severities(SeveritiesCmd), /// Manage log sources. Sources(SourcesCmd), + /// Quick overview of monitors, heartbeats, and active incidents. + Status(StatusCmd), /// Manage status pages, sections, resources, and reports. StatusPages(StatusPagesCmd), /// Update bs to the latest version. @@ -165,6 +167,7 @@ async fn main() -> Result<()> { Command::Policies(cmd) => cmd.run(&ctx).await, Command::Severities(cmd) => cmd.run(&ctx).await, Command::Sources(cmd) => cmd.run(&ctx).await, + Command::Status(cmd) => cmd.run(&ctx).await, Command::StatusPages(cmd) => cmd.run(&ctx).await, Command::Upgrade => betterstack_cli::commands::upgrade::run().await, Command::Completions { .. } => unreachable!(), diff --git a/src/output/fmt.rs b/src/output/fmt.rs new file mode 100644 index 0000000..fe0fd95 --- /dev/null +++ b/src/output/fmt.rs @@ -0,0 +1,56 @@ +/// Format an ISO 8601 timestamp as a relative time string like "2m ago", "3h ago", "5d ago". +/// Falls back to the raw timestamp if parsing fails. +pub fn relative_time(iso: &str) -> String { + let Ok(then) = chrono::DateTime::parse_from_rfc3339(iso) else { + return iso.to_string(); + }; + let now = chrono::Utc::now(); + let delta = now.signed_duration_since(then); + + if delta.num_seconds() < 0 { + // Future time + let abs = -delta.num_seconds(); + return format_duration_ago(abs, true); + } + + format_duration_ago(delta.num_seconds(), false) +} + +fn format_duration_ago(secs: i64, future: bool) -> String { + let suffix = if future { "from now" } else { "ago" }; + if secs < 60 { + format!("{secs}s {suffix}") + } else if secs < 3600 { + format!("{}m {suffix}", secs / 60) + } else if secs < 86400 { + format!("{}h {suffix}", secs / 3600) + } else { + format!("{}d {suffix}", secs / 86400) + } +} + +/// Format seconds into a human-friendly duration: "30s", "5m", "2h", "1d 12h". +pub fn duration(seconds: u64) -> String { + if seconds == 0 { + return "0s".to_string(); + } + let d = seconds / 86400; + let h = (seconds % 86400) / 3600; + let m = (seconds % 3600) / 60; + let s = seconds % 60; + + let mut parts = Vec::new(); + if d > 0 { + parts.push(format!("{d}d")); + } + if h > 0 { + parts.push(format!("{h}h")); + } + if m > 0 { + parts.push(format!("{m}m")); + } + if s > 0 && d == 0 { + parts.push(format!("{s}s")); + } + parts.join(" ") +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 0daef93..e191449 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,5 +1,6 @@ pub mod color; pub mod csv; +pub mod fmt; pub mod json; pub mod table;