use axum::{
Router,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use std::net::SocketAddr;
#[cfg(feature = "ui")]
use axum::{extract::Path, http::header};
#[cfg(feature = "ui")]
use rust_embed::Embed;
#[cfg(feature = "ui")]
#[derive(Embed)]
#[folder = "../openclaw-ui/dist"]
#[prefix = ""]
struct UiAssets;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UiServerConfig {
pub port: u16,
pub bind_address: String,
pub enabled: bool,
}
impl Default for UiServerConfig {
fn default() -> Self {
Self {
port: 3000,
bind_address: "127.0.0.1".to_string(),
enabled: true,
}
}
}
impl UiServerConfig {
#[must_use]
pub fn with_port(port: u16) -> Self {
Self {
port,
..Default::default()
}
}
#[must_use]
pub fn address(&self) -> String {
format!("{}:{}", self.bind_address, self.port)
}
}
#[cfg(feature = "ui")]
async fn serve_index() -> Response {
serve_file("index.html").await
}
#[cfg(feature = "ui")]
async fn serve_static(Path(path): Path<String>) -> Response {
serve_file(&path).await
}
#[cfg(feature = "ui")]
async fn serve_file(path: &str) -> Response {
if let Some(content) = UiAssets::get(path) {
let mime = mime_guess::from_path(path).first_or_octet_stream();
let cache_control = if path.contains(".js")
|| path.contains(".css")
|| path.contains(".woff")
|| path.contains(".woff2")
{
"public, max-age=31536000, immutable"
} else if path == "index.html" {
"no-cache, no-store, must-revalidate"
} else {
"public, max-age=3600"
};
(
StatusCode::OK,
[
(header::CONTENT_TYPE, mime.as_ref()),
(header::CACHE_CONTROL, cache_control),
],
content.data.into_owned(),
)
.into_response()
} else {
if !path.contains('.') {
if let Some(index) = UiAssets::get("index.html") {
return (
StatusCode::OK,
[
(header::CONTENT_TYPE, "text/html"),
(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"),
],
index.data.into_owned(),
)
.into_response();
}
}
(StatusCode::NOT_FOUND, "Not Found").into_response()
}
}
#[cfg(not(feature = "ui"))]
async fn serve_ui_not_available() -> Response {
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/html")],
r#"<!DOCTYPE html>
<html>
<head>
<title>OpenClaw UI</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }
h1 { color: #333; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<h1>OpenClaw UI Not Available</h1>
<p>The web dashboard UI is not included in this build.</p>
<p>To enable the UI, rebuild with the <code>ui</code> feature:</p>
<pre><code>cargo build --features ui</code></pre>
<p>The API gateway is running and accessible at the configured endpoints.</p>
</body>
</html>"#,
)
.into_response()
}
#[cfg(feature = "ui")]
pub fn create_ui_router() -> Router {
Router::new()
.route("/", get(serve_index))
.route("/{*path}", get(serve_static))
}
#[cfg(not(feature = "ui"))]
pub fn create_ui_router() -> Router {
Router::new()
.route("/", get(serve_ui_not_available))
.fallback(get(serve_ui_not_available))
}
pub async fn run_ui_server(config: UiServerConfig) -> Result<(), std::io::Error> {
if !config.enabled {
tracing::info!("UI server is disabled");
return Ok(());
}
let app = create_ui_router();
let addr: SocketAddr = config
.address()
.parse()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
#[cfg(feature = "ui")]
tracing::info!("UI server listening on http://{}", addr);
#[cfg(not(feature = "ui"))]
tracing::info!(
"UI server listening on http://{} (UI feature not enabled)",
addr
);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ui_server_config_default() {
let config = UiServerConfig::default();
assert_eq!(config.port, 3000);
assert_eq!(config.bind_address, "127.0.0.1");
assert!(config.enabled);
}
#[test]
fn test_ui_server_config_with_port() {
let config = UiServerConfig::with_port(8080);
assert_eq!(config.port, 8080);
assert_eq!(config.bind_address, "127.0.0.1");
}
#[test]
fn test_ui_server_config_address() {
let config = UiServerConfig::default();
assert_eq!(config.address(), "127.0.0.1:3000");
}
}