Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1use axum::{
2 extract::{Path as AxumPath, Query, State},
3 http::{header, HeaderMap, StatusCode},
4 response::{IntoResponse, Json, Redirect, Response},
5 routing::{get, post},
6 Router,
7};
8use serde::Deserialize;
9use serde_json::{json, Value};
10use std::collections::HashMap;
11use tower_cookies::Cookies;
12use uuid::Uuid;
13
14use crate::models::{
15 self, count_checks, count_status_codes, count_uptime, ms_to_iso_opt, recent_checks,
16 PropertyContext, PropertyRow,
17};
18use crate::render::{render, render_to_string};
19use crate::routes::auth::is_authenticated;
20use crate::AppState;
21
22pub fn router() -> Router<AppState> {
23 Router::new()
24 .route("/properties/{id}/status", get(property_status))
25 .route("/properties/{id}/recrawl", post(property_recrawl))
26 .route(
27 "/properties/{id}/rerun-lighthouse",
28 post(property_rerun_lighthouse),
29 )
30 // UUID catch-all is the property dashboard. Keep the merge order in
31 // app::router so the named routes above win the match.
32 .route("/{property_id}", get(property))
33}
34
35fn forbidden_json() -> Response {
36 (StatusCode::FORBIDDEN, Json(json!({"error": "forbidden"}))).into_response()
37}
38
39fn not_found_json() -> Response {
40 (StatusCode::NOT_FOUND, Json(json!({"error": "not_found"}))).into_response()
41}
42
43#[derive(Debug, Deserialize)]
44pub struct PropertyQuery {
45 pub report: Option<String>,
46}
47
48/// ASCII-only filename so the Content-Disposition header value always parses;
49/// a property named "Café" must not panic the report download.
50fn ascii_filename(name: &str) -> String {
51 let cleaned: String = name
52 .chars()
53 .map(|c| if c.is_ascii_alphanumeric() || " .-_".contains(c) { c } else { '_' })
54 .collect();
55 let trimmed = cleaned.trim().trim_matches('.');
56 if trimmed.is_empty() { "report".to_string() } else { trimmed.to_string() }
57}
58
59pub async fn property(
60 State(state): State<AppState>,
61 cookies: Cookies,
62 AxumPath(property_id): AxumPath<Uuid>,
63 Query(q): Query<PropertyQuery>,
64) -> Response {
65 let row = match models::get_property(&state.pool, property_id).await {
66 Ok(Some(r)) => r,
67 _ => return Redirect::to("/properties").into_response(),
68 };
69
70 let authed = is_authenticated(&cookies, &state);
71 let public = row.is_public != 0;
72 if !public && !authed {
73 return Redirect::to("/properties").into_response();
74 }
75
76 let ctx = match build_property_context(&state, &row).await {
77 Ok(c) => c,
78 Err(e) => {
79 tracing::error!("property context: {e:#}");
80 return (StatusCode::INTERNAL_SERVER_ERROR, "context error").into_response();
81 }
82 };
83
84 // Per-page graphs (response times, status codes, uptime).
85 let recent = recent_checks(&state.pool, property_id, 31).await.unwrap_or_default();
86 let status_response_times: Vec<Value> = recent
87 .iter()
88 .rev()
89 .map(|c| {
90 json!({
91 "label": chrono::DateTime::<chrono::Utc>::from_timestamp_millis(c.created_at)
92 .map(|d| d.to_rfc3339())
93 .unwrap_or_default(),
94 "total": c.response_ms,
95 "dns": c.dns_ms,
96 "tcp": c.tcp_ms,
97 "tls": c.tls_ms,
98 "ttfb": c.ttfb_ms,
99 })
100 })
101 .collect();
102 let codes = count_status_codes(&state.pool, property_id).await.unwrap_or_default();
103 let status_codes_graph: Vec<Value> = codes
104 .iter()
105 .map(|(code, count)| json!({"label": code, "count": count}))
106 .collect();
107 let (up, down) = count_uptime(&state.pool, property_id).await.unwrap_or((0, 0));
108 let total = up + down;
109 let pct = |n: i64| -> f64 {
110 if total == 0 {
111 0.0
112 } else {
113 (n as f64 / total as f64 * 10000.0).round() / 100.0
114 }
115 };
116 let uptime_graph: Vec<Value> = vec![
117 json!({"label": "Uptime", "count": pct(up)}),
118 json!({"label": "Downtime", "count": pct(down)}),
119 ];
120
121 let title = ctx.name.clone();
122 let description = format!("Status for {}", ctx.name);
123 let property_value = serde_json::to_value(&ctx).unwrap_or(Value::Null);
124 let insights_groups = group_insights_by_type(&ctx.crawler_insights);
125 let path = format!("/{property_id}");
126
127 // Report formats. Operator-only: the report buttons never render for
128 // anonymous visitors, and PDF rendering is CPU-bound Typst, so an
129 // unauthenticated public page must not be a free PDF-generation endpoint.
130 if let Some(fmt) = q.report.as_deref() {
131 if !authed {
132 return Redirect::to(&path).into_response();
133 }
134 let fmt = if fmt.is_empty() { "pdf" } else { fmt };
135 if fmt == "md" {
136 let extra = minijinja::context! {
137 property => &property_value,
138 title => &title,
139 description => &description,
140 };
141 return match render_to_string(
142 &state,
143 "properties/property_report.md",
144 &path,
145 authed,
146 extra,
147 ) {
148 Ok(body) => {
149 let mut h = HeaderMap::new();
150 h.insert(
151 header::CONTENT_TYPE,
152 "text/markdown; charset=utf-8".parse().unwrap(),
153 );
154 h.insert(
155 header::CONTENT_DISPOSITION,
156 format!("inline; filename=\"{}.md\"", ascii_filename(&ctx.name))
157 .parse()
158 .unwrap(),
159 );
160 (StatusCode::OK, h, body).into_response()
161 }
162 Err(resp) => resp,
163 };
164 }
165 if fmt == "pdf" {
166 let extra = minijinja::context! {
167 property => &property_value,
168 insights_groups => &insights_groups,
169 title => &title,
170 description => &description,
171 base_url => &state.config.base_url,
172 generated_at => chrono::Local::now().format("%Y-%m-%d %H:%M %Z").to_string(),
173 };
174 let typst_source = match render_to_string(
175 &state,
176 "properties/property_report.typ",
177 &path,
178 authed,
179 extra,
180 ) {
181 Ok(s) => s,
182 Err(resp) => return resp,
183 };
184 let renderer = state.pdf_renderer.clone();
185 let result =
186 tokio::task::spawn_blocking(move || renderer.render(typst_source)).await;
187 return match result {
188 Ok(Ok(bytes)) => {
189 let mut hh = HeaderMap::new();
190 hh.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());
191 hh.insert(
192 header::CONTENT_DISPOSITION,
193 format!("inline; filename=\"{}.pdf\"", ascii_filename(&ctx.name))
194 .parse()
195 .unwrap(),
196 );
197 (StatusCode::OK, hh, bytes).into_response()
198 }
199 Ok(Err(e)) => {
200 tracing::error!("pdf render: {e:#}");
201 (StatusCode::SERVICE_UNAVAILABLE, "pdf unavailable").into_response()
202 }
203 Err(e) => {
204 tracing::error!("pdf join: {e}");
205 (StatusCode::INTERNAL_SERVER_ERROR, "pdf join error").into_response()
206 }
207 };
208 }
209 }
210
211 let extra = minijinja::context! {
212 page => minijinja::context! { title => &title, description => &description },
213 property => &property_value,
214 status_response_times_graph => &status_response_times,
215 status_codes_graph => &status_codes_graph,
216 uptime_graph => &uptime_graph,
217 insights_groups => &insights_groups,
218 title => &title,
219 description => &description,
220 };
221 render(&state, "properties/property.html", &path, authed, extra)
222}
223
224pub async fn build_property_context(
225 state: &AppState,
226 row: &PropertyRow,
227) -> anyhow::Result<PropertyContext> {
228 let id = row.uuid();
229 let recent = recent_checks(&state.pool, id, 100).await?;
230 let total = count_checks(&state.pool, id).await?;
231 let current_status = recent.first().map(|c| c.status_code).unwrap_or(200);
232 let avg_response_time = if recent.is_empty() {
233 0
234 } else {
235 let n = recent.iter().take(31).count() as i64;
236 let sum: i64 = recent.iter().take(31).map(|c| c.response_ms).sum();
237 if n == 0 {
238 0
239 } else {
240 sum / n
241 }
242 };
243 let recent_uptime_pct = if recent.is_empty() {
244 None
245 } else {
246 let up = recent.iter().filter(|c| c.status_code == 200).count() as f64;
247 Some(((up / recent.len() as f64) * 1000.0).round() / 10.0)
248 };
249 let mut tick: Vec<&'static str> = recent
250 .iter()
251 .rev()
252 .map(|c| if c.status_code == 200 { "up" } else { "down" })
253 .collect();
254 tick.truncate(30);
255
256 let latest_headers: HashMap<String, String> = recent
257 .first()
258 .and_then(|c| serde_json::from_str::<HashMap<String, String>>(&c.headers).ok())
259 .unwrap_or_default();
260 let lower: HashMap<String, String> = latest_headers
261 .into_iter()
262 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
263 .collect();
264
265 let is_https = row.url.starts_with("https://");
266 let invalid_cert = current_status == 526;
267 let has_mime_type = lower.contains_key("content-type");
268 let has_content_sniffing_protection = lower
269 .get("x-content-type-options")
270 .map(|v| v == "nosniff")
271 .unwrap_or(false);
272 let has_clickjack_protection = lower
273 .get("x-frame-options")
274 .map(|v| matches!(v.as_str(), "deny" | "sameorigin" | "allow-from"))
275 .unwrap_or(false);
276 let hides_server_version = !lower.contains_key("server")
277 && !lower.contains_key("x-server")
278 && !lower.contains_key("powered-by")
279 && !lower.contains_key("x-powered-by");
280 let hsts = lower
281 .get("strict-transport-security")
282 .cloned()
283 .unwrap_or_default();
284 let has_hsts = {
285 if hsts.is_empty() {
286 false
287 } else if let Some(re) = regex::Regex::new(r"max-age=(\d+)").ok() {
288 re.captures(&hsts)
289 .and_then(|c| c.get(1))
290 .and_then(|m| m.as_str().parse::<i64>().ok())
291 .map(|m| m >= 31_536_000)
292 .unwrap_or(false)
293 } else {
294 false
295 }
296 };
297 let has_hsts_preload = hsts.to_lowercase().contains("preload");
298 let has_security_issue = !is_https
299 || !has_mime_type
300 || !has_content_sniffing_protection
301 || !has_clickjack_protection
302 || !hides_server_version
303 || !has_hsts
304 || !has_hsts_preload;
305
306 let lighthouse_scores: Value = row
307 .lighthouse_scores
308 .as_ref()
309 .and_then(|s| serde_json::from_str(s).ok())
310 .unwrap_or(Value::Null);
311 let lighthouse_details: Value = row
312 .lighthouse_details
313 .as_ref()
314 .and_then(|s| serde_json::from_str(s).ok())
315 .unwrap_or(Value::Null);
316 let crawler_insights: Value = row
317 .crawler_insights
318 .as_ref()
319 .and_then(|s| serde_json::from_str(s).ok())
320 .unwrap_or(Value::Array(Vec::new()));
321
322 let avg_lighthouse_score: Option<i64> = lighthouse_scores.as_object().and_then(|m| {
323 let scores: Vec<i64> = m.values().filter_map(|v| v.as_i64()).collect();
324 if scores.is_empty() {
325 None
326 } else {
327 Some((scores.iter().sum::<i64>() as f64 / scores.len() as f64).round() as i64)
328 }
329 });
330
331 Ok(PropertyContext {
332 id: row.uuid().to_string(),
333 url: row.url.clone(),
334 name: row.name(),
335 is_public: row.is_public != 0,
336 is_protected: row.is_protected != 0,
337 current_status,
338 avg_response_time,
339 recent_uptime_pct,
340 recent_tick_stream: tick,
341 total_checks: total,
342 crawl_state: row.crawl_state.clone(),
343 crawler_insights,
344 last_crawl_success_at: ms_to_iso_opt(row.last_crawl_success_at),
345 last_crawl_error: row.last_crawl_error.clone(),
346 last_crawl_duration_ms: row.last_crawl_duration_ms,
347 last_crawl_pages_count: row.last_crawl_pages_count,
348 next_run_at_crawler: ms_to_iso_opt(row.next_run_at_crawler),
349 crawl_started_at: ms_to_iso_opt(row.crawl_started_at),
350 lighthouse_state: row.lighthouse_state.clone(),
351 lighthouse_scores,
352 lighthouse_details,
353 last_lighthouse_success_at: ms_to_iso_opt(row.last_lighthouse_success_at),
354 last_lighthouse_error: row.last_lighthouse_error.clone(),
355 last_lighthouse_duration_ms: row.last_lighthouse_duration_ms,
356 next_lighthouse_run_at: ms_to_iso_opt(row.next_lighthouse_run_at),
357 lighthouse_started_at: ms_to_iso_opt(row.lighthouse_started_at),
358 avg_lighthouse_score,
359 alert_state: row.alert_state.clone(),
360 created_at: models::ms_to_iso(row.created_at),
361 updated_at: models::ms_to_iso(row.updated_at),
362 is_https,
363 invalid_cert,
364 has_mime_type,
365 has_content_sniffing_protection,
366 has_clickjack_protection,
367 hides_server_version,
368 has_hsts,
369 has_hsts_preload,
370 has_security_issue,
371 })
372}
373
374fn group_insights_by_type(insights: &Value) -> Vec<Value> {
375 use std::collections::BTreeMap;
376 let arr = match insights.as_array() {
377 Some(a) => a,
378 None => return Vec::new(),
379 };
380 let mut buckets: BTreeMap<String, Vec<Value>> = BTreeMap::new();
381 for item in arr {
382 let t = item
383 .get("type")
384 .and_then(|v| v.as_str())
385 .unwrap_or("other")
386 .to_string();
387 buckets.entry(t).or_default().push(item.clone());
388 }
389 // Sort each bucket so errors come before warnings before info, matching the
390 // old Django dictsort:"severity".
391 let sev_rank = |s: &str| -> u8 {
392 match s {
393 "error" => 0,
394 "warning" => 1,
395 _ => 2,
396 }
397 };
398 for items in buckets.values_mut() {
399 items.sort_by_key(|i| sev_rank(i.get("severity").and_then(|v| v.as_str()).unwrap_or("info")));
400 }
401 buckets
402 .into_iter()
403 .map(|(name, items)| json!({"type": name, "items": items}))
404 .collect()
405}
406
407fn crawl_progress(prop: &PropertyRow) -> f64 {
408 let pages = prop.last_crawl_pages_count.unwrap_or(0);
409 if pages <= 0 {
410 return 0.05; // show *some* movement once we start
411 }
412 let cap = crate::crawler::PAGE_CAP as f64;
413 ((pages as f64) / cap).min(0.9)
414}
415
416fn serialize_status(prop: &PropertyRow) -> Value {
417 let now = chrono::Utc::now().timestamp_millis();
418
419 let insights: Value = prop
420 .crawler_insights
421 .as_ref()
422 .and_then(|s| serde_json::from_str(s).ok())
423 .unwrap_or(Value::Array(Vec::new()));
424 let mut sev = serde_json::Map::new();
425 sev.insert("error".into(), 0.into());
426 sev.insert("warning".into(), 0.into());
427 sev.insert("info".into(), 0.into());
428 let mut total = 0i64;
429 if let Some(arr) = insights.as_array() {
430 for i in arr {
431 total += 1;
432 let s = i.get("severity").and_then(|v| v.as_str()).unwrap_or("info");
433 if let Some(n) = sev.get_mut(s).and_then(|v| v.as_i64()) {
434 sev.insert(s.into(), Value::from(n + 1));
435 }
436 }
437 }
438
439 let crawl_next = prop.next_run_at_crawler;
440 let lh_next = prop.next_lighthouse_run_at;
441
442 json!({
443 "crawler": {
444 "state": prop.crawl_state,
445 "started_at": ms_to_iso_opt(prop.crawl_started_at),
446 "last_attempt_at": ms_to_iso_opt(prop.last_run_at_crawler),
447 "last_success_at": ms_to_iso_opt(prop.last_crawl_success_at),
448 "last_error": prop.last_crawl_error,
449 "last_duration_ms": prop.last_crawl_duration_ms,
450 "pages_count": prop.last_crawl_pages_count,
451 "next_run_at": ms_to_iso_opt(crawl_next),
452 "is_overdue": crawl_next.map(|n| n <= now).unwrap_or(false),
453 "insights_total": total,
454 "insights_by_severity": Value::Object(sev),
455 "progress": if prop.crawl_state == "running" {
456 Value::from(crawl_progress(prop))
457 } else {
458 Value::Null
459 },
460 },
461 "lighthouse": {
462 "state": prop.lighthouse_state,
463 "started_at": ms_to_iso_opt(prop.lighthouse_started_at),
464 "last_attempt_at": ms_to_iso_opt(prop.last_lighthouse_run_at),
465 "last_success_at": ms_to_iso_opt(prop.last_lighthouse_success_at),
466 "last_error": prop.last_lighthouse_error,
467 "last_duration_ms": prop.last_lighthouse_duration_ms,
468 "next_run_at": ms_to_iso_opt(lh_next),
469 "is_overdue": lh_next.map(|n| n <= now).unwrap_or(false),
470 "scores": prop.lighthouse_scores
471 .as_ref()
472 .and_then(|s| serde_json::from_str::<Value>(s).ok())
473 .unwrap_or(Value::Null),
474 },
475 "server_time": chrono::Utc::now().to_rfc3339(),
476 })
477}
478
479pub async fn property_status(
480 State(state): State<AppState>,
481 cookies: Cookies,
482 AxumPath(id): AxumPath<Uuid>,
483) -> Response {
484 let row = match models::get_property(&state.pool, id).await {
485 Ok(Some(r)) => r,
486 _ => return not_found_json(),
487 };
488 let authed = is_authenticated(&cookies, &state);
489 if row.is_public == 0 && !authed {
490 return forbidden_json();
491 }
492 Json(serialize_status(&row)).into_response()
493}
494
495pub async fn property_recrawl(
496 State(state): State<AppState>,
497 cookies: Cookies,
498 AxumPath(id): AxumPath<Uuid>,
499) -> Response {
500 if !is_authenticated(&cookies, &state) {
501 return forbidden_json();
502 }
503 let row = match models::get_property(&state.pool, id).await {
504 Ok(Some(r)) => r,
505 _ => return not_found_json(),
506 };
507 if matches!(row.crawl_state.as_str(), "queued" | "running") {
508 return Json(json!({
509 "ok": false,
510 "reason": "already_running",
511 "crawler": serialize_status(&row).get("crawler"),
512 "lighthouse": serialize_status(&row).get("lighthouse"),
513 "server_time": chrono::Utc::now().to_rfc3339(),
514 }))
515 .into_response();
516 }
517 let now = chrono::Utc::now().timestamp_millis();
518 let _ = sqlx::query(
519 "UPDATE properties SET next_run_at_crawler = ?, last_crawl_error = NULL, updated_at = ? WHERE id = ?",
520 )
521 .bind(now)
522 .bind(now)
523 .bind(row.id.clone())
524 .execute(&state.pool)
525 .await;
526 let updated = models::get_property(&state.pool, id)
527 .await
528 .ok()
529 .flatten()
530 .unwrap_or(row);
531 let mut payload = serialize_status(&updated);
532 if let Some(obj) = payload.as_object_mut() {
533 obj.insert("ok".into(), Value::Bool(true));
534 }
535 Json(payload).into_response()
536}
537
538pub async fn property_rerun_lighthouse(
539 State(state): State<AppState>,
540 cookies: Cookies,
541 AxumPath(id): AxumPath<Uuid>,
542) -> Response {
543 if !is_authenticated(&cookies, &state) {
544 return forbidden_json();
545 }
546 let row = match models::get_property(&state.pool, id).await {
547 Ok(Some(r)) => r,
548 _ => return not_found_json(),
549 };
550 if matches!(row.lighthouse_state.as_str(), "queued" | "running") {
551 return Json(json!({
552 "ok": false,
553 "reason": "already_running",
554 "crawler": serialize_status(&row).get("crawler"),
555 "lighthouse": serialize_status(&row).get("lighthouse"),
556 "server_time": chrono::Utc::now().to_rfc3339(),
557 }))
558 .into_response();
559 }
560 let now = chrono::Utc::now().timestamp_millis();
561 let _ = sqlx::query(
562 "UPDATE properties SET next_lighthouse_run_at = ?, last_lighthouse_error = NULL, updated_at = ? WHERE id = ?",
563 )
564 .bind(now)
565 .bind(now)
566 .bind(row.id.clone())
567 .execute(&state.pool)
568 .await;
569 let updated = models::get_property(&state.pool, id)
570 .await
571 .ok()
572 .flatten()
573 .unwrap_or(row);
574 let mut payload = serialize_status(&updated);
575 if let Some(obj) = payload.as_object_mut() {
576 obj.insert("ok".into(), Value::Bool(true));
577 }
578 Json(payload).into_response()
579}