@@ -51,7 +51,7 @@ static OPENAPI_JSON: LazyLock<String> =
5151 title = "Hora API" ,
5252 description = "Read-only JSON API of a Hora uptime monitor."
5353 ) ,
54- paths( summary_json, latency_json, healthz) ,
54+ paths( summary_json, latency_json, status_badge , uptime_badge , healthz) ,
5555 components( schemas( Summary , MonitorView , DayCell , Point ) )
5656) ]
5757struct ApiDoc ;
@@ -126,6 +126,8 @@ pub fn router(state: AppState) -> Router {
126126 . route ( "/favicon.svg" , get ( favicon) )
127127 . route ( "/assets/CalSans-SemiBold.woff2" , get ( font) )
128128 . route ( "/api/openapi.json" , get ( openapi) )
129+ . route ( "/api/badge/{id}/status" , get ( status_badge) )
130+ . route ( "/api/badge/{id}/uptime" , get ( uptime_badge) )
129131 . merge ( api)
130132 . layer ( SetResponseHeaderLayer :: overriding (
131133 header:: CONTENT_SECURITY_POLICY ,
@@ -188,14 +190,19 @@ async fn openapi() -> impl IntoResponse {
188190 )
189191}
190192
191- async fn page ( State ( state) : State < AppState > ) -> Result < Html < String > , AppError > {
193+ /// Fetch (or build) the cached summary from the request state.
194+ async fn state_summary ( state : AppState ) -> anyhow:: Result < Arc < Summary > > {
192195 let AppState {
193196 pool,
194197 config,
195198 cache,
196199 } = state;
197200 let config = config. borrow ( ) . clone ( ) ;
198- let summary = summary_for ( & pool, & config, & cache) . await ?;
201+ summary_for ( & pool, & config, & cache) . await
202+ }
203+
204+ async fn page ( State ( state) : State < AppState > ) -> Result < Html < String > , AppError > {
205+ let summary = state_summary ( state) . await ?;
199206 let html = StatusTemplate {
200207 summary : summary. as_ref ( ) ,
201208 }
@@ -209,13 +216,7 @@ async fn page(State(state): State<AppState>) -> Result<Html<String>, AppError> {
209216 responses( ( status = 200 , description = "Status of every monitor" , body = Summary ) )
210217) ]
211218async fn summary_json ( State ( state) : State < AppState > ) -> Result < Json < Arc < Summary > > , AppError > {
212- let AppState {
213- pool,
214- config,
215- cache,
216- } = state;
217- let config = config. borrow ( ) . clone ( ) ;
218- Ok ( Json ( summary_for ( & pool, & config, & cache) . await ?) )
219+ Ok ( Json ( state_summary ( state) . await ?) )
219220}
220221
221222#[ derive( Debug , Deserialize ) ]
@@ -255,6 +256,58 @@ async fn latency_json(
255256 Ok ( Json ( points) )
256257}
257258
259+ #[ utoipa:: path(
260+ get,
261+ path = "/api/badge/{id}/status" ,
262+ params( ( "id" = String , Path , description = "Monitor id" ) ) ,
263+ responses(
264+ ( status = 200 , description = "Status badge (SVG)" ) ,
265+ ( status = 404 , description = "Unknown monitor" )
266+ )
267+ ) ]
268+ async fn status_badge (
269+ State ( state) : State < AppState > ,
270+ Path ( id) : Path < String > ,
271+ ) -> Result < impl IntoResponse , AppError > {
272+ let summary = state_summary ( state) . await ?;
273+ let monitor = summary
274+ . monitors
275+ . iter ( )
276+ . find ( |m| m. id == id)
277+ . ok_or_else ( || AppError :: NotFound ( "unknown monitor" ) ) ?;
278+ Ok ( svg_response ( badge (
279+ "status" ,
280+ monitor. status ,
281+ status_color ( monitor. status ) ,
282+ ) ) )
283+ }
284+
285+ #[ utoipa:: path(
286+ get,
287+ path = "/api/badge/{id}/uptime" ,
288+ params( ( "id" = String , Path , description = "Monitor id" ) ) ,
289+ responses(
290+ ( status = 200 , description = "24h uptime badge (SVG)" ) ,
291+ ( status = 404 , description = "Unknown monitor" )
292+ )
293+ ) ]
294+ async fn uptime_badge (
295+ State ( state) : State < AppState > ,
296+ Path ( id) : Path < String > ,
297+ ) -> Result < impl IntoResponse , AppError > {
298+ let summary = state_summary ( state) . await ?;
299+ let monitor = summary
300+ . monitors
301+ . iter ( )
302+ . find ( |m| m. id == id)
303+ . ok_or_else ( || AppError :: NotFound ( "unknown monitor" ) ) ?;
304+ let ( message, color) = match monitor. uptime_permille {
305+ Some ( permille) => ( format_permille ( permille) , uptime_color ( permille) ) ,
306+ None => ( "n/a" . to_owned ( ) , "#9f9f9f" ) ,
307+ } ;
308+ Ok ( svg_response ( badge ( "uptime" , & message, color) ) )
309+ }
310+
258311// --- Summary cache (lock-free read + single-flight build) ----------------
259312
260313/// Return a fresh-enough cached summary, or build exactly one (single-flight)
@@ -515,18 +568,28 @@ fn build_bar(daily: &[DayRow], now: DateTime<Utc>, days: u16) -> Vec<DayCell> {
515568 cells
516569}
517570
571+ /// A day's cell turns red only for a real outage; a brief blip stays amber.
572+ const DAY_OUTAGE_BELOW_PERMILLE : i64 = 990 ; // < 99% availability over the day
573+
518574fn day_cell ( date : String , row : & DayRow ) -> DayCell {
519575 let total = row. up + row. down + row. degraded ;
520- let ( state, title) = if total == 0 {
521- ( "empty" , format ! ( "{date}: no data" ) )
522- } else if row. down == 0 && row. degraded == 0 {
523- ( "up" , format ! ( "{date}: 100%" ) )
524- } else if row. down == 0 {
525- ( "degraded" , format ! ( "{date}: degraded" ) )
576+ if total == 0 {
577+ let title = format ! ( "{date}: no data" ) ;
578+ return DayCell {
579+ date,
580+ state : "empty" ,
581+ title,
582+ } ;
583+ }
584+ let permille = ( row. up + row. degraded ) . saturating_mul ( 1000 ) / total;
585+ let state = if row. down == 0 && row. degraded == 0 {
586+ "up"
587+ } else if permille >= DAY_OUTAGE_BELOW_PERMILLE {
588+ "degraded"
526589 } else {
527- let permille = ( row. up + row. degraded ) . saturating_mul ( 1000 ) / total;
528- ( "down" , format ! ( "{date}: {}" , format_permille( permille) ) )
590+ "down"
529591 } ;
592+ let title = format ! ( "{date}: {}" , format_permille( permille) ) ;
530593 DayCell { date, state, title }
531594}
532595
@@ -590,6 +653,70 @@ fn sparkline(points: &[Point], status: &str) -> String {
590653 )
591654}
592655
656+ // --- SVG status / uptime badges (flat shields style) --------------------
657+
658+ const BADGE_CHAR_W : f64 = 7.0 ;
659+ const BADGE_PAD : f64 = 6.0 ;
660+
661+ fn status_color ( status : & str ) -> & ' static str {
662+ match status {
663+ "up" => "#4c1" ,
664+ "down" => "#e05d44" ,
665+ "degraded" => "#fe7d37" ,
666+ _ => "#9f9f9f" ,
667+ }
668+ }
669+
670+ fn uptime_color ( permille : i64 ) -> & ' static str {
671+ if permille >= 999 {
672+ "#4c1"
673+ } else if permille >= 990 {
674+ "#97ca00"
675+ } else if permille >= 950 {
676+ "#dfb317"
677+ } else if permille >= 900 {
678+ "#fe7d37"
679+ } else {
680+ "#e05d44"
681+ }
682+ }
683+
684+ fn svg_response ( svg : String ) -> impl IntoResponse {
685+ (
686+ [
687+ ( header:: CONTENT_TYPE , "image/svg+xml" ) ,
688+ ( header:: CACHE_CONTROL , "public, max-age=60" ) ,
689+ ] ,
690+ svg,
691+ )
692+ }
693+
694+ /// Render a flat shields-style badge: a grey label and a coloured message.
695+ fn badge ( label : & str , message : & str , color : & str ) -> String {
696+ let label_w = coord_usize ( label. chars ( ) . count ( ) ) * BADGE_CHAR_W + 2.0 * BADGE_PAD ;
697+ let message_w = coord_usize ( message. chars ( ) . count ( ) ) * BADGE_CHAR_W + 2.0 * BADGE_PAD ;
698+ let total_w = label_w + message_w;
699+ let label_x = label_w / 2.0 ;
700+ let message_x = label_w + message_w / 2.0 ;
701+ format ! (
702+ "<svg xmlns=\" http://www.w3.org/2000/svg\" width=\" {total_w:.0}\" height=\" 20\" role=\" img\" aria-label=\" {label}: {message}\" >\
703+ <title>{label}: {message}</title>\
704+ <linearGradient id=\" g\" x2=\" 0\" y2=\" 100%\" ><stop offset=\" 0\" stop-color=\" #bbb\" stop-opacity=\" .1\" /><stop offset=\" 1\" stop-opacity=\" .1\" /></linearGradient>\
705+ <clipPath id=\" r\" ><rect width=\" {total_w:.0}\" height=\" 20\" rx=\" 3\" fill=\" #fff\" /></clipPath>\
706+ <g clip-path=\" url(#r)\" >\
707+ <rect width=\" {label_w:.0}\" height=\" 20\" fill=\" #555\" />\
708+ <rect x=\" {label_w:.0}\" width=\" {message_w:.0}\" height=\" 20\" fill=\" {color}\" />\
709+ <rect width=\" {total_w:.0}\" height=\" 20\" fill=\" url(#g)\" />\
710+ </g>\
711+ <g fill=\" #fff\" text-anchor=\" middle\" font-family=\" Verdana,Geneva,DejaVu Sans,sans-serif\" font-size=\" 11\" >\
712+ <text x=\" {label_x:.0}\" y=\" 15\" fill=\" #010101\" fill-opacity=\" .3\" >{label}</text>\
713+ <text x=\" {label_x:.0}\" y=\" 14\" >{label}</text>\
714+ <text x=\" {message_x:.0}\" y=\" 15\" fill=\" #010101\" fill-opacity=\" .3\" >{message}</text>\
715+ <text x=\" {message_x:.0}\" y=\" 14\" >{message}</text>\
716+ </g></svg>"
717+ )
718+ }
719+
593720#[ cfg( test) ]
594721mod tests {
595722 use super :: * ;
@@ -730,6 +857,19 @@ mod tests {
730857 assert_eq ! ( bar[ 0 ] . state, "empty" ) ;
731858 }
732859
860+ #[ test]
861+ fn day_cell_reds_only_real_outages ( ) {
862+ let row = |up, down| DayRow {
863+ day : "2021-01-01" . to_owned ( ) ,
864+ up,
865+ down,
866+ degraded : 0 ,
867+ } ;
868+ assert_eq ! ( day_cell( "d" . to_owned( ) , & row( 100 , 0 ) ) . state, "up" ) ; // 100%
869+ assert_eq ! ( day_cell( "d" . to_owned( ) , & row( 1439 , 1 ) ) . state, "degraded" ) ; // ~99.9% blip
870+ assert_eq ! ( day_cell( "d" . to_owned( ) , & row( 1400 , 40 ) ) . state, "down" ) ; // ~97% outage
871+ }
872+
733873 #[ test]
734874 fn sparkline_renders_svg_with_status_class ( ) {
735875 assert ! ( sparkline( & [ ] , "up" ) . contains( "no data" ) ) ;
@@ -747,4 +887,40 @@ mod tests {
747887 assert ! ( svg. contains( "class=\" spark degraded\" " ) ) ;
748888 assert ! ( svg. contains( "spark-line" ) ) ;
749889 }
890+
891+ #[ test]
892+ fn badge_has_label_message_and_color ( ) {
893+ let svg = badge ( "status" , "up" , status_color ( "up" ) ) ;
894+ assert ! ( svg. starts_with( "<svg" ) ) ;
895+ assert ! ( svg. contains( ">status<" ) && svg. contains( ">up<" ) ) ;
896+ assert ! ( svg. contains( status_color( "up" ) ) ) ;
897+ }
898+
899+ #[ test]
900+ fn uptime_color_tiers ( ) {
901+ assert_eq ! ( uptime_color( 1000 ) , "#4c1" ) ;
902+ assert_eq ! ( uptime_color( 995 ) , "#97ca00" ) ;
903+ assert_eq ! ( uptime_color( 800 ) , "#e05d44" ) ;
904+ }
905+
906+ #[ tokio:: test]
907+ async fn status_badge_is_svg ( ) {
908+ let res = test_app ( )
909+ . await
910+ . oneshot ( get ( "/api/badge/web/status" ) )
911+ . await
912+ . unwrap ( ) ;
913+ assert_eq ! ( res. status( ) , StatusCode :: OK ) ;
914+ assert_eq ! ( res. headers( ) . get( "content-type" ) . unwrap( ) , "image/svg+xml" ) ;
915+ }
916+
917+ #[ tokio:: test]
918+ async fn unknown_badge_is_404 ( ) {
919+ let res = test_app ( )
920+ . await
921+ . oneshot ( get ( "/api/badge/nope/uptime" ) )
922+ . await
923+ . unwrap ( ) ;
924+ assert_eq ! ( res. status( ) , StatusCode :: NOT_FOUND ) ;
925+ }
750926}
0 commit comments