#![cfg(feature = "admin-ui")]
use std::sync::Arc;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{StatusCode, header};
use axum::response::Response;
use include_dir::{Dir, include_dir};
use sha2::{Digest, Sha256};
pub static ADMIN_UI_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/admin-ui/dist");
#[derive(Debug, Clone)]
pub struct AdminUiInfo {
pub index_sha256: Arc<String>,
pub file_count: u32,
pub mode: Arc<String>,
}
impl AdminUiInfo {
pub fn from_embedded(mode: &str) -> Self {
let index_bytes = ADMIN_UI_DIR
.get_file("index.html")
.map(|f| f.contents())
.unwrap_or_default();
let index_sha256 = hex::encode(Sha256::digest(index_bytes));
let file_count = count_files(&ADMIN_UI_DIR);
Self {
index_sha256: Arc::new(index_sha256),
file_count,
mode: Arc::new(mode.to_string()),
}
}
}
fn count_files(dir: &Dir<'_>) -> u32 {
let mut total: u32 = 0;
for f in dir.files() {
let _ = f;
total = total.saturating_add(1);
}
for sub in dir.dirs() {
total = total.saturating_add(count_files(sub));
}
total
}
pub fn lookup(rel_path: &str) -> Option<&'static [u8]> {
let trimmed = rel_path.trim_start_matches('/');
ADMIN_UI_DIR.get_file(trimmed).map(|f| f.contents())
}
pub async fn serve(req: Request<Body>) -> Response {
let rel = req.uri().path().trim_start_matches("/admin");
let rel = if rel.is_empty() || rel == "/" {
"/index.html"
} else {
rel
};
let (bytes, mime) = match lookup(rel) {
Some(b) => (
b,
mime_guess::from_path(rel)
.first_or_octet_stream()
.to_string(),
),
None => match lookup("/index.html") {
Some(b) => (b, "text/html; charset=utf-8".to_string()),
None => {
return (StatusCode::NOT_FOUND, "admin UX not embedded").into_response();
}
},
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, "public, max-age=300")
.body(Body::from(bytes))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build").into_response())
}
use axum::response::IntoResponse;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_dir_has_index() {
assert!(
ADMIN_UI_DIR.get_file("index.html").is_some(),
"index.html missing from embedded admin-ui — did the source dir get deleted?"
);
}
#[test]
fn lookup_returns_bytes_for_known_files() {
let bytes = lookup("/index.html").expect("index.html");
let body = std::str::from_utf8(bytes).unwrap();
assert!(
body.contains("<title>VTC Admin</title>"),
"index.html title drifted: {body}"
);
assert!(
body.contains("id=\"root\""),
"index.html missing React mount point: {body}"
);
}
#[test]
fn lookup_returns_none_for_unknown() {
assert!(lookup("/missing.html").is_none());
}
#[test]
fn assets_dir_is_embedded() {
let assets = ADMIN_UI_DIR
.get_dir("assets")
.expect("assets/ missing from dist — Vite build did not emit chunks");
let js_present = assets
.files()
.any(|f| f.path().extension().is_some_and(|e| e == "js"));
let css_present = assets
.files()
.any(|f| f.path().extension().is_some_and(|e| e == "css"));
assert!(js_present, "no .js asset in dist/assets/");
assert!(css_present, "no .css asset in dist/assets/");
}
#[test]
fn info_carries_sha_of_index_html() {
let info = AdminUiInfo::from_embedded("embedded");
assert_eq!(info.index_sha256.len(), 64, "hex sha256 = 64 chars");
assert!(
info.file_count >= 3,
"expect index + at least one js + one css"
);
assert_eq!(info.mode.as_str(), "embedded");
}
}