use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::profile::clauth_dir;
use crate::usage::{iso_to_epoch_secs, now_ms};
const FEED_URL: &str = "https://status.claude.com/api/v2/incidents.json";
const REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const RECV_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_BODY_BYTES: u64 = 2 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Incident {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) link: String,
pub(crate) phase: UpdatePhase,
pub(crate) impact: Impact,
pub(crate) started_ms: u64,
pub(crate) resolved_ms: Option<u64>,
pub(crate) components: Vec<(String, String)>,
pub(crate) updates: Vec<IncidentUpdate>,
}
impl Incident {
pub(crate) fn is_active(&self) -> bool {
!self.phase.is_terminal()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct IncidentUpdate {
pub(crate) phase: UpdatePhase,
pub(crate) at_ms: u64,
pub(crate) text: String,
pub(crate) transitions: Vec<(String, String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum UpdatePhase {
Investigating,
Identified,
Monitoring,
Update,
Resolved,
Scheduled,
InProgress,
Verifying,
Completed,
Other(String),
}
impl UpdatePhase {
pub(crate) fn from_status(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"investigating" => Self::Investigating,
"identified" => Self::Identified,
"monitoring" => Self::Monitoring,
"update" => Self::Update,
"resolved" => Self::Resolved,
"scheduled" => Self::Scheduled,
"in_progress" => Self::InProgress,
"verifying" => Self::Verifying,
"completed" => Self::Completed,
other => Self::Other(other.to_string()),
}
}
pub(crate) fn is_terminal(&self) -> bool {
matches!(self, Self::Resolved | Self::Completed)
}
pub(crate) fn word(&self) -> String {
match self {
Self::Investigating => "investigating".into(),
Self::Identified => "identified".into(),
Self::Monitoring => "monitoring".into(),
Self::Update => "update".into(),
Self::Resolved => "resolved".into(),
Self::Scheduled => "scheduled".into(),
Self::InProgress => "in progress".into(),
Self::Verifying => "verifying".into(),
Self::Completed => "completed".into(),
Self::Other(w) => {
if w.is_empty() {
"update".into()
} else {
w.clone()
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum Impact {
None,
Minor,
Major,
Critical,
Maintenance,
Other(String),
}
impl Impact {
pub(crate) fn from_str(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"none" => Self::None,
"minor" => Self::Minor,
"major" => Self::Major,
"critical" => Self::Critical,
"maintenance" => Self::Maintenance,
other => Self::Other(other.to_string()),
}
}
pub(crate) fn word(&self) -> String {
match self {
Self::None => "none".into(),
Self::Minor => "minor".into(),
Self::Major => "major".into(),
Self::Critical => "critical".into(),
Self::Maintenance => "maintenance".into(),
Self::Other(w) => w.clone(),
}
}
pub(crate) fn severity(&self) -> u8 {
match self {
Self::Critical => 4,
Self::Major => 3,
Self::Minor => 2,
Self::Maintenance => 1,
Self::None | Self::Other(_) => 0,
}
}
}
pub(crate) enum StatusEvent {
Fetched {
incidents: Vec<Incident>,
fetched_at_ms: u64,
},
Cached {
incidents: Vec<Incident>,
fetched_at_ms: u64,
},
Failed(String),
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheFile {
fetched_at_ms: u64,
incidents: Vec<Incident>,
}
fn cache_path() -> Option<std::path::PathBuf> {
clauth_dir().ok().map(|d| d.join("status_cache.json"))
}
fn load_cache(path: &std::path::Path) -> Option<CacheFile> {
let bytes = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&bytes).ok()
}
fn save_cache(path: &std::path::Path, cache: &CacheFile) {
if let Ok(json) = serde_json::to_string_pretty(cache) {
let _ = crate::profile::atomic_write(path, json);
}
}
#[derive(Debug, Deserialize)]
struct IncidentsResponse {
#[serde(default)]
incidents: Vec<IncidentWire>,
}
#[derive(Debug, Deserialize)]
struct IncidentWire {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default)]
status: String,
#[serde(default)]
impact: String,
#[serde(default)]
shortlink: String,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
resolved_at: Option<String>,
#[serde(default)]
incident_updates: Vec<UpdateWire>,
#[serde(default)]
components: Vec<ComponentWire>,
}
#[derive(Debug, Deserialize)]
struct UpdateWire {
#[serde(default)]
status: String,
#[serde(default)]
body: String,
#[serde(default)]
display_at: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
affected_components: Option<Vec<AffectedComponentWire>>,
}
#[derive(Debug, Deserialize)]
struct AffectedComponentWire {
#[serde(default)]
name: String,
#[serde(default)]
old_status: String,
#[serde(default)]
new_status: String,
}
#[derive(Debug, Deserialize)]
struct ComponentWire {
#[serde(default)]
name: String,
#[serde(default)]
status: String,
}
fn strip_parens(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut chars = name.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '(' {
let mut group = String::from('(');
let mut depth = 1usize;
for inner in chars.by_ref() {
group.push(inner);
match inner {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
}
if depth != 0 {
out.push_str(&group);
}
} else {
out.push(ch);
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn parse_incidents(json: &str) -> anyhow::Result<Vec<Incident>> {
let resp: IncidentsResponse = serde_json::from_str(json)?;
Ok(resp
.incidents
.into_iter()
.filter_map(wire_to_incident)
.collect())
}
fn iso_to_ms(s: &str) -> Option<u64> {
let secs = iso_to_epoch_secs(s.trim())?;
Some((secs.max(0) as u64).saturating_mul(1000))
}
fn wire_to_incident(w: IncidentWire) -> Option<Incident> {
let started_ms = w
.started_at
.as_deref()
.and_then(iso_to_ms)
.or_else(|| w.created_at.as_deref().and_then(iso_to_ms))?;
let resolved_ms = w.resolved_at.as_deref().and_then(iso_to_ms);
let updates: Vec<IncidentUpdate> = w
.incident_updates
.into_iter()
.filter_map(wire_to_update)
.collect();
let components = dedup_components(
w.components
.into_iter()
.map(|c| {
let name = strip_parens(&c.name);
let status = first_reported_status(&name, &updates).unwrap_or(c.status);
(name, status)
})
.filter(|(n, _)| !n.is_empty()),
);
Some(Incident {
id: w.id.trim().to_string(),
title: w.name.trim().to_string(),
link: w.shortlink.trim().to_string(),
phase: UpdatePhase::from_status(&w.status),
impact: Impact::from_str(&w.impact),
started_ms,
resolved_ms,
components,
updates,
})
}
fn first_reported_status(name: &str, updates: &[IncidentUpdate]) -> Option<String> {
updates
.iter()
.rev()
.flat_map(|u| u.transitions.iter())
.find(|(tname, _, _)| tname == name)
.map(|(_, _, new)| new.clone())
}
fn status_rank(status: &str) -> u8 {
match status.trim().to_ascii_lowercase().as_str() {
"operational" => 0,
"under_maintenance" => 1,
"degraded_performance" => 2,
"partial_outage" => 3,
"major_outage" => 4,
_ => 1,
}
}
fn dedup_components(it: impl Iterator<Item = (String, String)>) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for (name, status) in it {
if let Some(existing) = out.iter_mut().find(|(n, _)| *n == name) {
if status_rank(&status) > status_rank(&existing.1) {
existing.1 = status;
}
} else {
out.push((name, status));
}
}
out
}
pub(crate) fn shorten_component_status(s: &str) -> String {
match s.trim().to_ascii_lowercase().as_str() {
"operational" => "operational".into(),
"degraded_performance" => "degraded".into(),
"partial_outage" => "partial outage".into(),
"major_outage" => "major outage".into(),
"under_maintenance" => "maintenance".into(),
other => other.replace('_', " "),
}
}
fn wire_to_update(w: UpdateWire) -> Option<IncidentUpdate> {
let at_ms = w
.display_at
.as_deref()
.and_then(iso_to_ms)
.or_else(|| w.created_at.as_deref().and_then(iso_to_ms))?;
let transitions = w
.affected_components
.unwrap_or_default()
.into_iter()
.filter(|c| c.old_status != c.new_status)
.map(|c| (strip_parens(&c.name), c.old_status, c.new_status))
.collect();
Some(IncidentUpdate {
phase: UpdatePhase::from_status(&w.status),
at_ms,
text: w.body.split_whitespace().collect::<Vec<_>>().join(" "),
transitions,
})
}
pub(crate) fn spawn(tx: Sender<StatusEvent>, refresh_rx: Receiver<()>) {
let Some(cache_file) = cache_path() else {
return;
};
std::thread::spawn(move || {
if let Some(cache) = load_cache(&cache_file) {
let _ = tx.send(StatusEvent::Cached {
incidents: cache.incidents,
fetched_at_ms: cache.fetched_at_ms,
});
}
loop {
run_fetch(&tx, &cache_file);
match refresh_rx.recv_timeout(REFRESH_INTERVAL) {
Ok(()) => {
while refresh_rx.try_recv().is_ok() {}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
}
});
}
fn run_fetch(tx: &Sender<StatusEvent>, cache_file: &std::path::Path) {
match fetch_feed() {
Ok(incidents) => {
let fetched_at_ms = now_ms();
save_cache(
cache_file,
&CacheFile {
fetched_at_ms,
incidents: incidents.clone(),
},
);
let _ = tx.send(StatusEvent::Fetched {
incidents,
fetched_at_ms,
});
}
Err(e) => match load_cache(cache_file) {
Some(cache) => {
let _ = tx.send(StatusEvent::Cached {
incidents: cache.incidents,
fetched_at_ms: cache.fetched_at_ms,
});
}
None => {
let _ = tx.send(StatusEvent::Failed(e.to_string()));
}
},
}
}
fn fetch_feed() -> anyhow::Result<Vec<Incident>> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(RECV_TIMEOUT))
.build()
.into();
use std::io::Read as _;
let reader = agent
.get(FEED_URL)
.header("User-Agent", "clauth-status")
.call()
.map_err(crate::ureq_error::into_anyhow)?
.into_body()
.into_reader();
let mut capped = reader.take(MAX_BODY_BYTES + 1);
let mut bytes = Vec::new();
capped
.read_to_end(&mut bytes)
.map_err(crate::ureq_error::into_anyhow)?;
if bytes.len() as u64 > MAX_BODY_BYTES {
anyhow::bail!("status feed exceeded {MAX_BODY_BYTES} byte cap");
}
let json = String::from_utf8(bytes).map_err(crate::ureq_error::into_anyhow)?;
parse_incidents(&json)
}
#[cfg(test)]
#[path = "../tests/inline/status_parse.rs"]
mod status_parse;