diff --git a/remote/cache-service/src/api.rs b/remote/cache-service/src/api.rs index 71b65e9..330dd50 100644 --- a/remote/cache-service/src/api.rs +++ b/remote/cache-service/src/api.rs @@ -18,6 +18,72 @@ static OPEN_PNG: &[u8] = include_bytes!("../assets/open.png"); static CLOSED_PNG: &[u8] = include_bytes!("../assets/closed.png"); static OFFLINE_PNG: &[u8] = include_bytes!("../assets/offline.png"); +const BADGE_LABEL: &str = "space"; + +fn escape_xml(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn badge_color(status: DoorStatus) -> &'static str { + match status { + DoorStatus::Open => "#34a853", + DoorStatus::Closed => "#e05d44", + DoorStatus::Offline => "#9f9f9f", + } +} + +fn badge_text_width(text: &str) -> usize { + text.chars().count() * 7 + 10 +} + +fn render_badge_svg(status: DoorStatus, summary: &str) -> String { + let message = status.as_str(); + let label_width = badge_text_width(BADGE_LABEL); + let message_width = badge_text_width(message); + let total_width = label_width + message_width; + let label_center = label_width / 2; + let message_center = label_width + (message_width / 2); + let escaped_summary = escape_xml(summary); + + format!( + concat!( + "", + "{summary}", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "{label}", + "{label}", + "{message}", + "{message}", + "", + ), + total_width = total_width, + label = BADGE_LABEL, + message = message, + summary = escaped_summary, + label_width = label_width, + message_width = message_width, + color = badge_color(status), + label_center = label_center, + message_center = message_center, + ) +} + pub struct AppState { pub db: Arc>, pub client: reqwest::Client, @@ -244,6 +310,35 @@ pub async fn get_image(State(state): State>) -> Response { .into_response() } +pub async fn get_badge(State(state): State>) -> Response { + let db = state.db.clone(); + let status = match tokio::task::spawn_blocking(move || { + let conn = db.blocking_lock(); + db::get_status(&conn) + }) + .await + .expect("db task panicked") + { + Ok(status) => status, + Err(e) => { + error!(error = %e, "failed to get status for badge"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let summary = status_summary(status.status, status.since, status.last_checked, unix_now()); + let badge = render_badge_svg(status.status, &summary); + + ( + [ + (header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), + (header::CACHE_CONTROL, "public, max-age=5"), + ], + badge, + ) + .into_response() +} + #[cfg(test)] mod tests { use super::*; @@ -264,4 +359,15 @@ mod tests { assert!(summary.contains("Last checked")); assert!(summary.contains("55 seconds ago")); } + + #[test] + fn render_badge_svg_includes_status_and_summary() { + let svg = render_badge_svg(DoorStatus::Offline, "Cache can't reach the space right now."); + + assert!(svg.contains("aria-label=\"space: offline\"")); + assert!(svg.contains(">space<")); + assert!(svg.contains(">offline<")); + assert!(svg.contains("Cache can't reach the space right now.")); + assert!(svg.contains("#9f9f9f")); + } } diff --git a/remote/cache-service/src/main.rs b/remote/cache-service/src/main.rs index dae7927..af4e143 100644 --- a/remote/cache-service/src/main.rs +++ b/remote/cache-service/src/main.rs @@ -127,6 +127,7 @@ async fn main() -> Result<()> { .route("/health", get(api::health)) .route("/webhook", post(api::post_webhook)) .route("/status", get(api::get_status)) + .route("/badge.svg", get(api::get_badge)) .route("/image", get(api::get_image)) .route("/image/open.png", get(api::get_image_open)) .route("/image/closed.png", get(api::get_image_closed))