1use anyhow::{Context, Result};
2use axum::{
3 extract::{Path, State},
4 http::{header, Method, StatusCode},
5 response::{Html, IntoResponse, Json, Response},
6 routing::get,
7 Router,
8};
9use rust_embed::RustEmbed;
10use serde::Serialize;
11use sqlx::SqlitePool;
12use std::path::PathBuf;
13use std::sync::Arc;
14use tokio::sync::RwLock;
15use tower_http::{
16 cors::{Any, CorsLayer},
17 trace::TraceLayer,
18};
19
20use super::websocket;
21
22#[derive(RustEmbed)]
24#[folder = "static/"]
25struct StaticAssets;
26
27#[derive(Clone)]
29pub struct ProjectContext {
30 pub db_pool: SqlitePool,
31 pub project_name: String,
32 pub project_path: PathBuf,
33 pub db_path: PathBuf,
34}
35
36#[derive(Clone)]
38pub struct AppState {
39 pub current_project: Arc<RwLock<ProjectContext>>,
41 pub host_project: super::websocket::ProjectInfo,
43 pub port: u16,
44 pub ws_state: super::websocket::WebSocketState,
46}
47
48pub struct DashboardServer {
50 port: u16,
51 db_path: PathBuf,
52 project_name: String,
53 project_path: PathBuf,
54}
55
56#[derive(Serialize)]
58struct HealthResponse {
59 status: String,
60 service: String,
61 version: String,
62}
63
64#[derive(Serialize)]
66struct ProjectInfo {
67 name: String,
68 path: String,
69 database: String,
70 port: u16,
71 is_online: bool,
72 mcp_connected: bool,
73}
74
75impl DashboardServer {
76 pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
78 let project_name = project_path
80 .file_name()
81 .and_then(|n| n.to_str())
82 .unwrap_or("unknown")
83 .to_string();
84
85 if !db_path.exists() {
86 anyhow::bail!(
87 "Database not found at {}. Is this an Intent-Engine project?",
88 db_path.display()
89 );
90 }
91
92 Ok(Self {
93 port,
94 db_path,
95 project_name,
96 project_path,
97 })
98 }
99
100 pub async fn run(self) -> Result<()> {
102 let db_url = format!("sqlite://{}", self.db_path.display());
104 let db_pool = SqlitePool::connect(&db_url)
105 .await
106 .context("Failed to connect to database")?;
107
108 let project_context = ProjectContext {
110 db_pool,
111 project_name: self.project_name.clone(),
112 project_path: self.project_path.clone(),
113 db_path: self.db_path.clone(),
114 };
115
116 let ws_state = websocket::WebSocketState::new();
118
119 let host_project_info = websocket::ProjectInfo {
120 name: self.project_name.clone(),
121 path: self.project_path.display().to_string(),
122 db_path: self.db_path.display().to_string(),
123 agent: None,
124 mcp_connected: false, is_online: true, };
127
128 let state = AppState {
129 current_project: Arc::new(RwLock::new(project_context)),
130 host_project: host_project_info,
131 port: self.port,
132 ws_state,
133 };
134
135 let app = create_router(state);
137
138 let addr = format!("0.0.0.0:{}", self.port);
141 let listener = tokio::net::TcpListener::bind(&addr)
142 .await
143 .with_context(|| format!("Failed to bind to {}", addr))?;
144
145 tracing::info!("Dashboard server listening on {}", addr);
146 tracing::warn!(
147 "⚠️ Dashboard is accessible from external IPs. Access via http://localhost:{} or http://<your-ip>:{}",
148 self.port, self.port
149 );
150 tracing::info!("Project: {}", self.project_name);
151 tracing::info!("Database: {}", self.db_path.display());
152
153 #[cfg(unix)]
155 {
156 unsafe {
157 libc::signal(libc::SIGHUP, libc::SIG_IGN);
158 }
159 }
160
161 axum::serve(listener, app).await.context("Server error")?;
163
164 Ok(())
165 }
166}
167
168fn create_router(state: AppState) -> Router {
170 use super::routes;
171
172 let api_routes = Router::new()
174 .route("/health", get(health_handler))
175 .route("/info", get(info_handler))
176 .merge(routes::api_routes());
177
178 Router::new()
180 .route("/", get(serve_index))
182 .route("/static/*path", get(serve_static))
184 .nest("/api", api_routes)
186 .route("/ws/mcp", get(websocket::handle_mcp_websocket))
188 .route("/ws/ui", get(websocket::handle_ui_websocket))
189 .fallback(not_found_handler)
191 .with_state(state)
193 .layer(
195 CorsLayer::new()
196 .allow_origin(Any)
197 .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
198 .allow_headers(Any),
199 )
200 .layer(TraceLayer::new_for_http())
201}
202
203async fn serve_index() -> impl IntoResponse {
205 match StaticAssets::get("index.html") {
206 Some(content) => {
207 let body = content.data.to_vec();
208 Response::builder()
209 .status(StatusCode::OK)
210 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
211 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
212 .header(header::PRAGMA, "no-cache")
213 .header(header::EXPIRES, "0")
214 .body(body.into())
215 .unwrap()
216 },
217 None => (
218 StatusCode::INTERNAL_SERVER_ERROR,
219 Html("<h1>Error: index.html not found</h1>".to_string()),
220 )
221 .into_response(),
222 }
223}
224
225async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
227 let path = path.trim_start_matches('/');
229
230 match StaticAssets::get(path) {
231 Some(content) => {
232 let mime = mime_guess::from_path(path).first_or_octet_stream();
233 let body = content.data.to_vec();
234 Response::builder()
235 .status(StatusCode::OK)
236 .header(header::CONTENT_TYPE, mime.as_ref())
237 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
238 .header(header::PRAGMA, "no-cache")
239 .header(header::EXPIRES, "0")
240 .body(body.into())
241 .unwrap()
242 },
243 None => (
244 StatusCode::NOT_FOUND,
245 Json(serde_json::json!({
246 "error": "File not found",
247 "code": "NOT_FOUND",
248 "path": path
249 })),
250 )
251 .into_response(),
252 }
253}
254
255async fn health_handler() -> Json<HealthResponse> {
257 Json(HealthResponse {
258 status: "healthy".to_string(),
259 service: "intent-engine-dashboard".to_string(),
260 version: env!("CARGO_PKG_VERSION").to_string(),
261 })
262}
263
264async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
267 let project = state.current_project.read().await;
268
269 let projects = state
271 .ws_state
272 .get_online_projects_with_current(
273 &project.project_name,
274 &project.project_path,
275 &project.db_path,
276 &state.host_project,
277 state.port,
278 )
279 .await;
280
281 let current_project = projects.first().expect("Current project must exist");
283
284 Json(ProjectInfo {
285 name: current_project.name.clone(),
286 path: current_project.path.clone(),
287 database: current_project.db_path.clone(),
288 port: state.port,
289 is_online: current_project.is_online,
290 mcp_connected: current_project.mcp_connected,
291 })
292}
293
294async fn not_found_handler() -> impl IntoResponse {
296 (
297 StatusCode::NOT_FOUND,
298 Json(serde_json::json!({
299 "error": "Not found",
300 "code": "NOT_FOUND"
301 })),
302 )
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_health_response_serialization() {
311 let response = HealthResponse {
312 status: "healthy".to_string(),
313 service: "test".to_string(),
314 version: "1.0.0".to_string(),
315 };
316
317 let json = serde_json::to_string(&response).unwrap();
318 assert!(json.contains("healthy"));
319 assert!(json.contains("test"));
320 }
321
322 #[test]
323 fn test_project_info_serialization() {
324 let info = ProjectInfo {
325 name: "test-project".to_string(),
326 path: "/path/to/project".to_string(),
327 database: "/path/to/db".to_string(),
328 port: 11391,
329 is_online: true,
330 mcp_connected: false,
331 };
332
333 let json = serde_json::to_string(&info).unwrap();
334 assert!(json.contains("test-project"));
335 assert!(json.contains("11391"));
336 }
337}