Files
zesdex/apps/interfaces/web/src/lib.rs
T
asepharyana da2ed6da25 feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
2026-07-20 09:04:57 +07:00

112 lines
3.6 KiB
Rust

//! Web frontend interface — serves the browser-based UI.
//!
//! The frontend assets (JS/HTML/CSS) are expected to be built into
//! a `dist/` directory at compile time via `include_dir!`, or served
//! from a path at runtime.
//!
//! ## Development
//!
//! During development, point `--web-dir` to the frontend dev server
//! or build directory.
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tracing::{info, warn};
/// Web server state.
pub struct WebState {
/// Directory from which to serve static files.
pub static_dir: PathBuf,
}
/// Build the web frontend router.
pub fn build_router(state: Arc<WebState>) -> Router {
Router::new()
.route("/", get(index_handler))
.route("/{*path}", get(static_handler))
.with_state(state)
}
/// Serve the index.html for root requests.
async fn index_handler(
axum::extract::State(state): axum::extract::State<Arc<WebState>>,
) -> Result<Html<String>, StatusCode> {
let index_path = state.static_dir.join("index.html");
match fs::read_to_string(&index_path).await {
Ok(html) => Ok(Html(html)),
Err(_) => {
// Return a minimal HTML page when no frontend is built
Ok(Html(
r#"<!DOCTYPE html>
<html><head><title>Zesdex Web</title>
<meta charset="utf-8">
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
<body>
<h1>Zesdex Web</h1>
<p>Web interface is ready.</p>
<p>To connect the frontend:</p>
<ol>
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
</ol>
</body></html>"#.to_string(),
))
}
}
}
/// Serve static files from the configured directory.
async fn static_handler(
axum::extract::State(state): axum::extract::State<Arc<WebState>>,
path: axum::extract::Path<String>,
) -> Response {
let file_path = state.static_dir.join(path.0);
// Security: prevent directory traversal
let canonical = match file_path.canonicalize() {
Ok(p) => p,
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
};
if !canonical.starts_with(&state.static_dir) {
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
}
match fs::read(&canonical).await {
Ok(data) => {
let mime = mime_guess::from_path(&canonical).first_or_octet_stream();
Response::builder()
.status(200)
.header("Content-Type", mime.to_string())
.body(axum::body::Body::from(data))
.unwrap()
.into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
}
}
/// Run the web frontend server.
pub async fn run_server(port: u16, static_dir: Option<PathBuf>) -> anyhow::Result<()> {
let dir = static_dir.unwrap_or_else(|| {
let p = PathBuf::from("apps/interfaces/web/dist");
if p.exists() {
p
} else {
warn!("No static dir found at {:?}, using current dir", p);
PathBuf::from(".")
}
});
let state = Arc::new(WebState { static_dir: dir });
let app = build_router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
info!("Web frontend server listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}