Skip to content

Commit 5f6ce5a

Browse files
committed
feat(web): severity-graded uptime bars and SVG badges
- Daily uptime cells turn red only for a real outage (<99% over the day); a brief blip stays amber. - Add embeddable flat SVG badges per monitor: /api/badge/{id}/status and /api/badge/{id}/uptime, reusing the cached summary. - Add a release workflow that publishes a GitHub release from CHANGELOG.md on tag push.
1 parent 41e49ea commit 5f6ce5a

3 files changed

Lines changed: 220 additions & 20 deletions

File tree

.github/workflows/release.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
release:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v6
15+
# Creates the GitHub release for the pushed tag, using the matching
16+
# section of CHANGELOG.md as the release notes. The container image is
17+
# published separately by docker.yml on the same tag.
18+
- uses: taiki-e/create-gh-release-action@v1
19+
with:
20+
changelog: CHANGELOG.md
21+
env:
22+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ Initial release.
1616
- **Monitors** — HTTP and TCP probes with per-monitor interval, timeout, expected
1717
status, a "degraded if slower than" threshold, and custom request headers.
1818
- **Status page** — server-rendered (no JS framework) compact, responsive grid:
19-
daily uptime bars, an inline SVG 24h latency chart, auto-refresh, Cal Sans
20-
branding and an SVG favicon.
19+
daily uptime bars graded by severity, an inline SVG 24h latency chart,
20+
auto-refresh, Cal Sans branding and an SVG favicon.
2121
- **JSON API**`GET /api/summary` and `GET /api/monitors/{id}/latency`, plus a
2222
generated OpenAPI 3.1 document at `/api/openapi.json` (`utoipa`).
23+
- **Badges** — embeddable flat SVG status and uptime badges per monitor at
24+
`/api/badge/{id}/status` and `/api/badge/{id}/uptime`.
2325
- **TLS certificate expiry monitoring** with advance warnings.
2426
- **Notifications** — a pluggable `Notifier` trait with a built-in Telegram
2527
channel; alerts fire only after _N_ consecutive failures (anti-flapping),

crates/hora-web/src/lib.rs

Lines changed: 194 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
)]
5757
struct 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
)]
211218
async 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+
518574
fn 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)]
594721
mod 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

Comments
 (0)