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::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16use tower_http::{
17 cors::{Any, CorsLayer},
18 trace::TraceLayer,
19};
20
21use super::websocket;
22
23fn canonical_path(path: &std::path::Path) -> PathBuf {
32 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
33}
34
35#[derive(RustEmbed)]
37#[folder = "static/"]
38struct StaticAssets;
39
40#[derive(Clone, Debug)]
42pub struct ProjectInfo {
43 pub name: String,
44 pub path: PathBuf,
45 pub db_path: PathBuf,
46}
47
48#[derive(Clone)]
50pub struct AppState {
51 pub known_projects: Arc<RwLock<HashMap<PathBuf, ProjectInfo>>>,
53 pub active_project_path: Arc<RwLock<PathBuf>>,
55 pub host_project: super::websocket::ProjectInfo,
57 pub port: u16,
58 pub ws_state: super::websocket::WebSocketState,
60 pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
62}
63
64impl AppState {
65 pub async fn get_db_pool(&self, project_path: &std::path::Path) -> Result<SqlitePool, String> {
67 let key = canonical_path(project_path);
69 let projects = self.known_projects.read().await;
70 if let Some(info) = projects.get(&key) {
71 let db_url = format!("sqlite://{}", info.db_path.display());
72 SqlitePool::connect(&db_url)
73 .await
74 .map_err(|e| format!("Failed to connect to database: {}", e))
75 } else {
76 Err(format!("Project not found: {}", project_path.display()))
77 }
78 }
79
80 pub async fn get_active_db_pool(&self) -> Result<SqlitePool, String> {
82 let active_path = self.active_project_path.read().await.clone();
83 self.get_db_pool(&active_path).await
84 }
85
86 pub async fn add_project(&self, path: PathBuf) -> Result<(), String> {
88 if !path.exists() {
89 return Err(format!("Project path does not exist: {}", path.display()));
90 }
91
92 let db_path = path.join(".intent-engine").join("project.db");
93 if !db_path.exists() {
94 return Err(format!("Database not found: {}", db_path.display()));
95 }
96
97 let name = path
98 .file_name()
99 .and_then(|n| n.to_str())
100 .unwrap_or("unknown")
101 .to_string();
102
103 let canonical = canonical_path(&path);
107 let db_path = canonical_path(&db_path);
108 let info = ProjectInfo {
109 name,
110 path: canonical.clone(),
111 db_path,
112 };
113
114 let mut projects = self.known_projects.write().await;
115 projects.insert(canonical, info);
116 Ok(())
117 }
118
119 pub async fn get_active_project(&self) -> Option<ProjectInfo> {
121 let active_path = self.active_project_path.read().await;
122 let projects = self.known_projects.read().await;
123 projects.get(&*active_path).cloned()
124 }
125
126 pub async fn switch_active_project(&self, path: PathBuf) -> Result<(), String> {
128 let canonical = canonical_path(&path);
129 let projects = self.known_projects.read().await;
130 if !projects.contains_key(&canonical) {
131 return Err(format!("Project not registered: {}", path.display()));
132 }
133 drop(projects);
134
135 let mut active = self.active_project_path.write().await;
136 *active = canonical;
137 Ok(())
138 }
139
140 pub async fn remove_project(&self, path: &std::path::Path) -> Result<(), String> {
142 let canonical = canonical_path(path);
143
144 let host_canonical = canonical_path(std::path::Path::new(&self.host_project.path));
147 if canonical == host_canonical {
148 return Err("Cannot remove the host project".to_string());
149 }
150
151 let mut projects = self.known_projects.write().await;
154 projects.remove(&canonical);
155
156 let path_str = canonical.to_string_lossy().to_string();
160 crate::global_projects::remove_project(&path_str);
161
162 Ok(())
163 }
164
165 pub async fn get_active_project_context(&self) -> Result<(SqlitePool, String), String> {
168 let db_pool = self.get_active_db_pool().await?;
169 let project_path = self
170 .get_active_project()
171 .await
172 .map(|p| p.path.to_string_lossy().to_string())
173 .unwrap_or_default();
174 Ok((db_pool, project_path))
175 }
176}
177
178pub struct DashboardServer {
180 port: u16,
181 db_path: PathBuf,
182 project_name: String,
183 project_path: PathBuf,
184}
185
186#[derive(Serialize)]
188struct HealthResponse {
189 status: String,
190 service: String,
191 version: String,
192}
193
194#[derive(Serialize)]
196struct ProjectInfoResponse {
197 name: String,
198 path: String,
199 database: String,
200 port: u16,
201 is_online: bool,
202 mcp_connected: bool,
203}
204
205impl DashboardServer {
206 pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
208 let project_name = project_path
210 .file_name()
211 .and_then(|n| n.to_str())
212 .unwrap_or("unknown")
213 .to_string();
214
215 if !db_path.exists() {
216 anyhow::bail!(
217 "Database not found at {}. Is this an Intent-Engine project?",
218 db_path.display()
219 );
220 }
221
222 Ok(Self {
223 port,
224 db_path,
225 project_name,
226 project_path,
227 })
228 }
229
230 pub async fn run(self) -> Result<()> {
232 let mut known_projects = HashMap::new();
236 let host_canonical = canonical_path(&self.project_path);
237 let host_info = ProjectInfo {
238 name: self.project_name.clone(),
239 path: host_canonical.clone(),
240 db_path: self.db_path.clone(),
241 };
242 known_projects.insert(host_canonical.clone(), host_info);
243
244 let registry = crate::global_projects::ProjectsRegistry::load();
246 for entry in registry.projects {
247 let path = PathBuf::from(&entry.path);
248 let key = canonical_path(&path);
249 if known_projects.contains_key(&key) {
251 continue;
252 }
253 let db_path = path.join(".intent-engine").join("project.db");
254 if db_path.exists() {
255 let name = entry.name.unwrap_or_else(|| {
256 path.file_name()
257 .and_then(|n| n.to_str())
258 .unwrap_or("unknown")
259 .to_string()
260 });
261 known_projects.insert(
262 key,
263 ProjectInfo {
264 name,
265 path,
266 db_path,
267 },
268 );
269 }
270 }
271 tracing::info!(
272 "Loaded {} projects from global registry",
273 known_projects.len()
274 );
275
276 let ws_state = websocket::WebSocketState::new();
278
279 let host_project_info = websocket::ProjectInfo {
280 name: self.project_name.clone(),
281 path: self.project_path.display().to_string(),
282 db_path: self.db_path.display().to_string(),
283 agent: None,
284 mcp_connected: false, is_online: true, };
287
288 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
290
291 let state = AppState {
292 known_projects: Arc::new(RwLock::new(known_projects)),
293 active_project_path: Arc::new(RwLock::new(host_canonical)),
295 host_project: host_project_info,
296 port: self.port,
297 ws_state,
298 shutdown_tx: Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))),
299 };
300
301 let app = create_router(state);
303
304 let addr = format!("0.0.0.0:{}", self.port);
307 let listener = tokio::net::TcpListener::bind(&addr)
308 .await
309 .with_context(|| format!("Failed to bind to {}", addr))?;
310
311 tracing::info!(address = %addr, "Dashboard server listening");
312 tracing::warn!(
313 port = self.port,
314 "⚠️ Dashboard is accessible from external IPs"
315 );
316 tracing::info!(project = %self.project_name, "Project loaded");
317 tracing::info!(db_path = %self.db_path.display(), "Database path");
318
319 #[cfg(unix)]
321 {
322 unsafe {
323 libc::signal(libc::SIGHUP, libc::SIG_IGN);
324 }
325 }
326
327 tracing::info!("Starting server with graceful shutdown support");
329 axum::serve(listener, app)
330 .with_graceful_shutdown(async {
331 shutdown_rx.await.ok();
332 tracing::info!("Shutdown signal received, initiating graceful shutdown");
333 })
334 .await
335 .context("Server error")?;
336
337 tracing::info!("Dashboard server shut down successfully");
338 Ok(())
339 }
340}
341
342fn create_router(state: AppState) -> Router {
344 use super::routes;
345
346 let api_routes = Router::new()
348 .route("/health", get(health_handler))
349 .route("/info", get(info_handler))
350 .merge(routes::api_routes());
351
352 Router::new()
354 .route("/", get(serve_index))
356 .route("/static/*path", get(serve_static))
358 .route("/assets/*path", get(serve_assets))
360 .nest("/api", api_routes)
362 .route("/ws/mcp", get(websocket::handle_mcp_websocket))
364 .route("/ws/ui", get(websocket::handle_ui_websocket))
365 .fallback(not_found_handler)
367 .with_state(state)
369 .layer(
371 CorsLayer::new()
372 .allow_origin(Any)
373 .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
374 .allow_headers(Any),
375 )
376 .layer(TraceLayer::new_for_http())
377}
378
379async fn serve_index() -> impl IntoResponse {
381 match StaticAssets::get("index.html") {
382 Some(content) => {
383 let body = content.data.to_vec();
384 Response::builder()
385 .status(StatusCode::OK)
386 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
387 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
388 .header(header::PRAGMA, "no-cache")
389 .header(header::EXPIRES, "0")
390 .body(body.into())
391 .unwrap()
392 },
393 None => (
394 StatusCode::INTERNAL_SERVER_ERROR,
395 Html("<h1>Error: index.html not found</h1>".to_string()),
396 )
397 .into_response(),
398 }
399}
400
401async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
403 let path = path.trim_start_matches('/');
405
406 match StaticAssets::get(path) {
407 Some(content) => {
408 let mime = mime_guess::from_path(path).first_or_octet_stream();
409 let body = content.data.to_vec();
410 Response::builder()
411 .status(StatusCode::OK)
412 .header(header::CONTENT_TYPE, mime.as_ref())
413 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
414 .header(header::PRAGMA, "no-cache")
415 .header(header::EXPIRES, "0")
416 .body(body.into())
417 .unwrap()
418 },
419 None => (
420 StatusCode::NOT_FOUND,
421 Json(serde_json::json!({
422 "error": "File not found",
423 "code": "NOT_FOUND",
424 "path": path
425 })),
426 )
427 .into_response(),
428 }
429}
430
431async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
433 let path = path.trim_start_matches('/');
435 let full_path = format!("assets/{}", path);
439
440 match StaticAssets::get(&full_path) {
441 Some(content) => {
442 let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
443 let body = content.data.to_vec();
444 Response::builder()
445 .status(StatusCode::OK)
446 .header(header::CONTENT_TYPE, mime.as_ref())
447 .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
448 .header(header::PRAGMA, "no-cache")
449 .header(header::EXPIRES, "0")
450 .body(body.into())
451 .unwrap()
452 },
453 None => (
454 StatusCode::NOT_FOUND,
455 Json(serde_json::json!({
456 "error": "Asset not found",
457 "code": "NOT_FOUND",
458 "path": full_path
459 })),
460 )
461 .into_response(),
462 }
463}
464
465async fn health_handler() -> Json<HealthResponse> {
467 Json(HealthResponse {
468 status: "healthy".to_string(),
469 service: "intent-engine-dashboard".to_string(),
470 version: env!("CARGO_PKG_VERSION").to_string(),
471 })
472}
473
474async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfoResponse> {
477 let active_project = state.get_active_project().await;
478
479 match active_project {
480 Some(project) => {
481 let projects = state
483 .ws_state
484 .get_online_projects_with_current(
485 &project.name,
486 &project.path,
487 &project.db_path,
488 &state.host_project,
489 state.port,
490 )
491 .await;
492
493 let current_project = projects.first().expect("Current project must exist");
495
496 Json(ProjectInfoResponse {
497 name: current_project.name.clone(),
498 path: current_project.path.clone(),
499 database: current_project.db_path.clone(),
500 port: state.port,
501 is_online: current_project.is_online,
502 mcp_connected: current_project.mcp_connected,
503 })
504 },
505 None => Json(ProjectInfoResponse {
506 name: "unknown".to_string(),
507 path: "".to_string(),
508 database: "".to_string(),
509 port: state.port,
510 is_online: false,
511 mcp_connected: false,
512 }),
513 }
514}
515
516async fn not_found_handler() -> impl IntoResponse {
518 (
519 StatusCode::NOT_FOUND,
520 Json(serde_json::json!({
521 "error": "Not found",
522 "code": "NOT_FOUND"
523 })),
524 )
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_health_response_serialization() {
533 let response = HealthResponse {
534 status: "healthy".to_string(),
535 service: "test".to_string(),
536 version: "1.0.0".to_string(),
537 };
538
539 let json = serde_json::to_string(&response).unwrap();
540 assert!(json.contains("healthy"));
541 assert!(json.contains("test"));
542 }
543
544 #[test]
545 fn test_project_info_response_serialization() {
546 let info = ProjectInfoResponse {
547 name: "test-project".to_string(),
548 path: "/path/to/project".to_string(),
549 database: "/path/to/db".to_string(),
550 port: 11391,
551 is_online: true,
552 mcp_connected: false,
553 };
554
555 let json = serde_json::to_string(&info).unwrap();
556 assert!(json.contains("test-project"));
557 assert!(json.contains("11391"));
558 }
559}