use anyhow::{Context, Result};
use axum::{
extract::{Path, State},
http::{header, Method, StatusCode},
response::{Html, IntoResponse, Json, Response},
routing::get,
Router,
};
use rust_embed::RustEmbed;
use serde::Serialize;
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use super::websocket;
fn canonical_path(path: &std::path::Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[derive(RustEmbed)]
#[folder = "static/"]
struct StaticAssets;
#[derive(Clone, Debug)]
pub struct ProjectInfo {
pub name: String,
pub path: PathBuf,
pub db_path: PathBuf,
}
#[derive(Clone)]
pub struct AppState {
pub known_projects: Arc<RwLock<HashMap<PathBuf, ProjectInfo>>>,
pub active_project_path: Arc<RwLock<PathBuf>>,
pub host_project: super::websocket::ProjectInfo,
pub port: u16,
pub ws_state: super::websocket::WebSocketState,
pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
}
impl AppState {
pub async fn get_db_pool(&self, project_path: &std::path::Path) -> Result<SqlitePool, String> {
let key = canonical_path(project_path);
let projects = self.known_projects.read().await;
if let Some(info) = projects.get(&key) {
let db_url = format!("sqlite://{}", info.db_path.display());
SqlitePool::connect(&db_url)
.await
.map_err(|e| format!("Failed to connect to database: {}", e))
} else {
Err(format!("Project not found: {}", project_path.display()))
}
}
pub async fn get_active_db_pool(&self) -> Result<SqlitePool, String> {
let active_path = self.active_project_path.read().await.clone();
self.get_db_pool(&active_path).await
}
pub async fn add_project(&self, path: PathBuf) -> Result<(), String> {
if !path.exists() {
return Err(format!("Project path does not exist: {}", path.display()));
}
let db_path = path.join(".intent-engine").join("project.db");
if !db_path.exists() {
return Err(format!("Database not found: {}", db_path.display()));
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let canonical = canonical_path(&path);
let db_path = canonical_path(&db_path);
let info = ProjectInfo {
name,
path: canonical.clone(),
db_path,
};
let mut projects = self.known_projects.write().await;
projects.insert(canonical, info);
Ok(())
}
pub async fn get_active_project(&self) -> Option<ProjectInfo> {
let active_path = self.active_project_path.read().await;
let projects = self.known_projects.read().await;
projects.get(&*active_path).cloned()
}
pub async fn switch_active_project(&self, path: PathBuf) -> Result<(), String> {
let canonical = canonical_path(&path);
let projects = self.known_projects.read().await;
if !projects.contains_key(&canonical) {
return Err(format!("Project not registered: {}", path.display()));
}
drop(projects);
let mut active = self.active_project_path.write().await;
*active = canonical;
Ok(())
}
pub async fn remove_project(&self, path: &std::path::Path) -> Result<(), String> {
let canonical = canonical_path(path);
let host_canonical = canonical_path(std::path::Path::new(&self.host_project.path));
if canonical == host_canonical {
return Err("Cannot remove the host project".to_string());
}
let mut projects = self.known_projects.write().await;
projects.remove(&canonical);
let path_str = canonical.to_string_lossy().to_string();
crate::global_projects::remove_project(&path_str);
Ok(())
}
pub async fn get_active_project_context(&self) -> Result<(SqlitePool, String), String> {
let db_pool = self.get_active_db_pool().await?;
let project_path = self
.get_active_project()
.await
.map(|p| p.path.to_string_lossy().to_string())
.unwrap_or_default();
Ok((db_pool, project_path))
}
}
pub struct DashboardServer {
port: u16,
db_path: PathBuf,
project_name: String,
project_path: PathBuf,
}
#[derive(Serialize)]
struct HealthResponse {
status: String,
service: String,
version: String,
}
#[derive(Serialize)]
struct ProjectInfoResponse {
name: String,
path: String,
database: String,
port: u16,
is_online: bool,
mcp_connected: bool,
}
impl DashboardServer {
pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
let project_name = project_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
if !db_path.exists() {
anyhow::bail!(
"Database not found at {}. Is this an Intent-Engine project?",
db_path.display()
);
}
Ok(Self {
port,
db_path,
project_name,
project_path,
})
}
pub async fn run(self) -> Result<()> {
let mut known_projects = HashMap::new();
let host_canonical = canonical_path(&self.project_path);
let host_info = ProjectInfo {
name: self.project_name.clone(),
path: host_canonical.clone(),
db_path: self.db_path.clone(),
};
known_projects.insert(host_canonical.clone(), host_info);
let registry = crate::global_projects::ProjectsRegistry::load();
for entry in registry.projects {
let path = PathBuf::from(&entry.path);
let key = canonical_path(&path);
if known_projects.contains_key(&key) {
continue;
}
let db_path = path.join(".intent-engine").join("project.db");
if db_path.exists() {
let name = entry.name.unwrap_or_else(|| {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
});
known_projects.insert(
key,
ProjectInfo {
name,
path,
db_path,
},
);
}
}
tracing::info!(
"Loaded {} projects from global registry",
known_projects.len()
);
let ws_state = websocket::WebSocketState::new();
let host_project_info = websocket::ProjectInfo {
name: self.project_name.clone(),
path: self.project_path.display().to_string(),
db_path: self.db_path.display().to_string(),
agent: None,
mcp_connected: false, is_online: true, };
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let state = AppState {
known_projects: Arc::new(RwLock::new(known_projects)),
active_project_path: Arc::new(RwLock::new(host_canonical)),
host_project: host_project_info,
port: self.port,
ws_state,
shutdown_tx: Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))),
};
let app = create_router(state);
let addr = format!("0.0.0.0:{}", self.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.with_context(|| format!("Failed to bind to {}", addr))?;
tracing::info!(address = %addr, "Dashboard server listening");
tracing::warn!(
port = self.port,
"⚠️ Dashboard is accessible from external IPs"
);
tracing::info!(project = %self.project_name, "Project loaded");
tracing::info!(db_path = %self.db_path.display(), "Database path");
#[cfg(unix)]
{
unsafe {
libc::signal(libc::SIGHUP, libc::SIG_IGN);
}
}
tracing::info!("Starting server with graceful shutdown support");
axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
tracing::info!("Shutdown signal received, initiating graceful shutdown");
})
.await
.context("Server error")?;
tracing::info!("Dashboard server shut down successfully");
Ok(())
}
}
fn create_router(state: AppState) -> Router {
use super::routes;
let api_routes = Router::new()
.route("/health", get(health_handler))
.route("/info", get(info_handler))
.merge(routes::api_routes());
Router::new()
.route("/", get(serve_index))
.route("/static/*path", get(serve_static))
.route("/assets/*path", get(serve_assets))
.nest("/api", api_routes)
.route("/ws/mcp", get(websocket::handle_mcp_websocket))
.route("/ws/ui", get(websocket::handle_ui_websocket))
.fallback(not_found_handler)
.with_state(state)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_headers(Any),
)
.layer(TraceLayer::new_for_http())
}
async fn serve_index() -> impl IntoResponse {
match StaticAssets::get("index.html") {
Some(content) => {
let body = content.data.to_vec();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.header(header::PRAGMA, "no-cache")
.header(header::EXPIRES, "0")
.body(body.into())
.unwrap()
},
None => (
StatusCode::INTERNAL_SERVER_ERROR,
Html("<h1>Error: index.html not found</h1>".to_string()),
)
.into_response(),
}
}
async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
let path = path.trim_start_matches('/');
match StaticAssets::get(path) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
let body = content.data.to_vec();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.header(header::PRAGMA, "no-cache")
.header(header::EXPIRES, "0")
.body(body.into())
.unwrap()
},
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "File not found",
"code": "NOT_FOUND",
"path": path
})),
)
.into_response(),
}
}
async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
let path = path.trim_start_matches('/');
let full_path = format!("assets/{}", path);
match StaticAssets::get(&full_path) {
Some(content) => {
let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
let body = content.data.to_vec();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::PRAGMA, "no-cache")
.header(header::EXPIRES, "0")
.body(body.into())
.unwrap()
},
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Asset not found",
"code": "NOT_FOUND",
"path": full_path
})),
)
.into_response(),
}
}
async fn health_handler() -> Json<HealthResponse> {
Json(HealthResponse {
status: "healthy".to_string(),
service: "intent-engine-dashboard".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfoResponse> {
let active_project = state.get_active_project().await;
match active_project {
Some(project) => {
let projects = state
.ws_state
.get_online_projects_with_current(
&project.name,
&project.path,
&project.db_path,
&state.host_project,
state.port,
)
.await;
let current_project = projects.first().expect("Current project must exist");
Json(ProjectInfoResponse {
name: current_project.name.clone(),
path: current_project.path.clone(),
database: current_project.db_path.clone(),
port: state.port,
is_online: current_project.is_online,
mcp_connected: current_project.mcp_connected,
})
},
None => Json(ProjectInfoResponse {
name: "unknown".to_string(),
path: "".to_string(),
database: "".to_string(),
port: state.port,
is_online: false,
mcp_connected: false,
}),
}
}
async fn not_found_handler() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"code": "NOT_FOUND"
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_health_response_serialization() {
let response = HealthResponse {
status: "healthy".to_string(),
service: "test".to_string(),
version: "1.0.0".to_string(),
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("healthy"));
assert!(json.contains("test"));
}
#[test]
fn test_project_info_response_serialization() {
let info = ProjectInfoResponse {
name: "test-project".to_string(),
path: "/path/to/project".to_string(),
database: "/path/to/db".to_string(),
port: 11391,
is_online: true,
mcp_connected: false,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("test-project"));
assert!(json.contains("11391"));
}
}