use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::Json;
use axum::extract::Request;
use axum::http::header::{ACCEPT, ALLOW, CACHE_CONTROL};
use axum::http::{HeaderValue, Method, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use minijinja::context;
use minijinja::value::Serde;
use sea_orm::DatabaseConnection;
use serde_json::json;
use crate::AdminState;
use crate::error::AdminError;
const PING_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub struct HomeConfig {
enabled: bool,
title: Option<String>,
tagline: Option<String>,
}
impl Default for HomeConfig {
fn default() -> Self {
Self {
enabled: true,
title: None,
tagline: None,
}
}
}
impl HomeConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn from_env() -> Result<Self, AdminError> {
Self::from_lookup(|key| std::env::var(key).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, AdminError> {
let set = |key: &str| lookup(key).filter(|v| !v.trim().is_empty());
let mut config = Self::new();
if let Some(value) = set("HOME_PAGE_ENABLED") {
config.enabled = match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
_ => {
return Err(AdminError::Config(
"HOME_PAGE_ENABLED must be true or false".into(),
));
}
};
}
config.title = set("HOME_TITLE");
config.tagline = set("HOME_TAGLINE");
Ok(config)
}
#[must_use]
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn tagline(mut self, tagline: impl Into<String>) -> Self {
self.tagline = Some(tagline.into());
self
}
pub(crate) fn is_enabled(&self) -> bool {
self.enabled
}
}
pub(crate) struct Home {
config: HomeConfig,
state: Arc<AdminState>,
db: DatabaseConnection,
started: Instant,
}
impl Home {
pub(crate) fn new(config: HomeConfig, state: Arc<AdminState>, db: DatabaseConnection) -> Self {
Self {
config,
state,
db,
started: Instant::now(),
}
}
}
pub(crate) async fn fallback(home: Arc<Home>, request: Request) -> Response {
if request.uri().path() != "/" {
return StatusCode::NOT_FOUND.into_response();
}
if !matches!(*request.method(), Method::GET | Method::HEAD) {
let mut response = StatusCode::METHOD_NOT_ALLOWED.into_response();
response
.headers_mut()
.insert(ALLOW, HeaderValue::from_static("GET, HEAD"));
return response;
}
let wants_json = request
.headers()
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|accept| accept.contains("application/json") && !accept.contains("text/html"));
let mut response = respond(&home, wants_json).await;
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
crate::server::protect(&mut response);
response
}
async fn respond(home: &Home, wants_json: bool) -> Response {
let begun = Instant::now();
let reachable = matches!(
tokio::time::timeout(PING_TIMEOUT, home.db.ping()).await,
Ok(Ok(()))
);
let latency_ms = (begun.elapsed().as_secs_f64() * 10_000.0).round() / 10.0;
let uptime_secs = home.started.elapsed().as_secs();
let checked_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let (status, code) = if reachable {
("operational", StatusCode::OK)
} else {
tracing::warn!("home page: the database did not answer");
("degraded", StatusCode::SERVICE_UNAVAILABLE)
};
if wants_json {
let database = if reachable {
json!({ "status": "connected", "latency_ms": latency_ms })
} else {
json!({ "status": "unreachable" })
};
let body = json!({
"status": status,
"database": database,
"uptime_secs": uptime_secs,
"checked_at": checked_at,
});
return (code, Json(body)).into_response();
}
let settings = &home.state.settings;
let title = home
.config
.title
.clone()
.unwrap_or_else(|| settings.site_title.clone());
let page = home
.state
.templates
.get_template("home.html")
.and_then(|t| {
t.render(context! {
site => Serde(settings),
title,
tagline => &home.config.tagline,
operational => reachable,
latency_ms,
uptime_secs,
uptime => human_duration(uptime_secs),
checked_at,
})
});
match page {
Ok(html) => (code, Html(html)).into_response(),
Err(error) => AdminError::from(error).into_response(),
}
}
fn human_duration(secs: u64) -> String {
let (days, hours, minutes) = (secs / 86_400, secs / 3_600 % 24, secs / 60 % 60);
match (days, hours, minutes) {
(0, 0, 0) => format!("{secs}s"),
(0, 0, m) => format!("{m}m {}s", secs % 60),
(0, h, m) => format!("{h}h {m}m"),
(d, h, _) => format!("{d}d {h}h"),
}
}
#[cfg(test)]
mod tests {
use super::human_duration;
#[test]
fn durations_read_naturally() {
assert_eq!(human_duration(5), "5s");
assert_eq!(human_duration(125), "2m 5s");
assert_eq!(human_duration(3_700), "1h 1m");
assert_eq!(human_duration(93_784), "1d 2h");
}
}