diff --git a/remote/cache-service/README.md b/remote/cache-service/README.md index 5065ba6..b8a7ffa 100644 --- a/remote/cache-service/README.md +++ b/remote/cache-service/README.md @@ -11,11 +11,24 @@ If the Pi stops responding to polls (configurable threshold, default 3 misses), | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/status` | — | Current door status (`status`, `since`, `last_checked`) | +| `GET` | `/badge.svg` | — | Live README badge with Noisebridge logo | | `POST` | `/webhook` | Bearer | Inbound webhook from the Pi | | `GET` | `/health` | — | Health check | `since` is the Pi-reported time when the current state began. `last_checked` is when the cache most recently attempted a poll. +## Badge + +`/badge.svg` serves a classic shields.io-style SVG badge with the Noisebridge logo and the current cache status (`open`, `closed`, or `offline`). + +Use it in a GitHub README like this: + +```md +[![Space status](https://your-cache-domain.example.com/badge.svg)](https://your-cache-domain.example.com/status) +``` + +That keeps the badge clickable and sends readers to the live `/status` endpoint. + ## Configuration NixOS options under `services.noisebell-cache`: diff --git a/remote/cache-service/src/api.rs b/remote/cache-service/src/api.rs index 330dd50..72d7352 100644 --- a/remote/cache-service/src/api.rs +++ b/remote/cache-service/src/api.rs @@ -19,6 +19,12 @@ static CLOSED_PNG: &[u8] = include_bytes!("../assets/closed.png"); static OFFLINE_PNG: &[u8] = include_bytes!("../assets/offline.png"); const BADGE_LABEL: &str = "space"; +const BADGE_HEIGHT: usize = 20; +const BADGE_LOGO_WIDTH: usize = 21; +const BADGE_LEFT_PADDING: usize = 6; +const BADGE_RIGHT_PADDING: usize = 10; +const BADGE_FONT_FAMILY: &str = "Verdana,Geneva,DejaVu Sans,sans-serif"; +const BADGE_LOGO_PATH: &str = "M215.863,155.875V65.776l-8.2,5.819l-22.357,15.869h-5.008V56.782c0.002-5.218-2.145-9.984-5.566-13.397c-3.412-3.421-8.177-5.567-13.396-5.565h-25.241c-1.08-6-3.964-11.391-8.092-15.517c-5.284-5.292-12.638-8.58-20.709-8.577c-8.072-0.003-15.427,3.286-20.71,8.579c-4.13,4.124-7.012,9.515-8.09,15.515H53.25c-5.218-0.001-9.983,2.144-13.396,5.565c-3.421,3.413-5.566,8.179-5.565,13.397v15.939L0.498,81.89l39.388,11.699L1.257,105.063l38.629,11.471L1.257,128.007L39.95,139.5l-5.661,1.694v23.675c-0.001,5.22,2.145,9.985,5.565,13.398c3.414,3.42,8.179,5.566,13.397,5.564h35.144v-5.194c0.001-5.234,2.105-9.927,5.533-13.366c3.437-3.429,8.129-5.533,13.365-5.533c5.234,0.004,9.927,2.107,13.362,5.533c3.429,3.439,5.533,8.132,5.536,13.366v5.194h35.143c5.221,0.002,9.985-2.145,13.397-5.564c3.421-3.416,5.566-8.181,5.566-13.398v-30.688h5.006L215.863,155.875z M192.152,126.306V95.344l13.321-9.455v49.872L192.152,126.306z M181.764,123.796h-21.126V97.854h21.126V123.796z M169.908,164.869c-0.002,2.356-0.954,4.476-2.523,6.053c-1.575,1.57-3.696,2.52-6.051,2.521h-25.241c-1.078-6.002-3.962-11.394-8.091-15.517c-5.287-5.29-12.641-8.58-20.71-8.576c-8.072-0.004-15.426,3.285-20.711,8.576c-4.128,4.123-7.012,9.515-8.09,15.517H53.25c-2.354-0.002-4.473-0.952-6.05-2.521c-1.57-1.577-2.521-3.699-2.523-6.053v-15.94l31.632-9.469l-38.564-11.453l38.628-11.473l-38.628-11.471l38.628-11.474L38.504,82.341l6.173-1.674V56.782c0.002-2.354,0.953-4.473,2.523-6.05c1.577-1.57,3.697-2.522,6.05-2.523h35.145v-5.194c0.001-5.236,2.105-9.927,5.533-13.364c3.437-3.428,8.129-5.532,13.364-5.535c5.235,0.001,9.925,2.107,13.361,5.535c3.431,3.438,5.535,8.13,5.535,13.364v5.194h35.145c2.354,0.001,4.474,0.953,6.051,2.523c1.57,1.578,2.522,3.696,2.522,6.05v30.684h-19.66v46.719h19.66v30.685H169.908z"; fn escape_xml(text: &str) -> String { text.replace('&', "&") @@ -37,21 +43,22 @@ fn badge_color(status: DoorStatus) -> &'static str { } fn badge_text_width(text: &str) -> usize { - text.chars().count() * 7 + 10 + text.chars().count() * 7 + BADGE_RIGHT_PADDING } fn render_badge_svg(status: DoorStatus, summary: &str) -> String { let message = status.as_str(); - let label_width = badge_text_width(BADGE_LABEL); + let label_width = BADGE_LEFT_PADDING + BADGE_LOGO_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 label_text_x = BADGE_LEFT_PADDING + BADGE_LOGO_WIDTH; + let label_center = label_text_x + (badge_text_width(BADGE_LABEL) / 2); let message_center = label_width + (message_width / 2); let escaped_summary = escape_xml(summary); format!( concat!( - "", + "", "{summary}", "", "", @@ -59,13 +66,16 @@ fn render_badge_svg(status: DoorStatus, summary: &str) -> String { "", "", "", - "", + "", "", - "", - "", - "", + "", + "", + "", + "", + "", + "", + "", "", - "", "{label}", "{label}", "{message}", @@ -73,12 +83,16 @@ fn render_badge_svg(status: DoorStatus, summary: &str) -> String { "", ), total_width = total_width, + height = BADGE_HEIGHT, label = BADGE_LABEL, message = message, summary = escaped_summary, label_width = label_width, message_width = message_width, color = badge_color(status), + font_family = BADGE_FONT_FAMILY, + logo_x = BADGE_LEFT_PADDING, + logo_path = BADGE_LOGO_PATH, label_center = label_center, message_center = message_center, ) @@ -364,10 +378,11 @@ mod tests { 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("aria-label=\"space status: offline\"")); assert!(svg.contains(">space<")); assert!(svg.contains(">offline<")); assert!(svg.contains("Cache can't reach the space right now.")); assert!(svg.contains("#9f9f9f")); + assert!(svg.contains("#eb2026")); } }