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!(
+ "",
+ ),
+ 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))