intent_engine/dashboard/
server.rs

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
23/// Embedded static assets (HTML, CSS, JS)
24#[derive(RustEmbed)]
25#[folder = "static/"]
26struct StaticAssets;
27
28/// Minimal project info (no connection pool - SQLite is fast enough to open on demand)
29#[derive(Clone, Debug)]
30pub struct ProjectInfo {
31    pub name: String,
32    pub path: PathBuf,
33    pub db_path: PathBuf,
34}
35
36/// Dashboard server state shared across handlers
37#[derive(Clone)]
38pub struct AppState {
39    /// Known projects (path -> info). No connection pools - SQLite opens fast.
40    pub known_projects: Arc<RwLock<HashMap<PathBuf, ProjectInfo>>>,
41    /// Currently active project path (for UI display)
42    pub active_project_path: Arc<RwLock<PathBuf>>,
43    /// The project that started the Dashboard (always considered online)
44    pub host_project: super::websocket::ProjectInfo,
45    pub port: u16,
46    /// WebSocket state for real-time connections
47    pub ws_state: super::websocket::WebSocketState,
48    /// Shutdown signal sender (for graceful shutdown via HTTP)
49    pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
50}
51
52impl AppState {
53    /// Get database pool for a project (opens on demand - SQLite is fast)
54    pub async fn get_db_pool(&self, project_path: &PathBuf) -> Result<SqlitePool, String> {
55        let projects = self.known_projects.read().await;
56        if let Some(info) = projects.get(project_path) {
57            let db_url = format!("sqlite://{}", info.db_path.display());
58            SqlitePool::connect(&db_url)
59                .await
60                .map_err(|e| format!("Failed to connect to database: {}", e))
61        } else {
62            Err(format!("Project not found: {}", project_path.display()))
63        }
64    }
65
66    /// Get database pool for the active project
67    pub async fn get_active_db_pool(&self) -> Result<SqlitePool, String> {
68        let active_path = self.active_project_path.read().await.clone();
69        self.get_db_pool(&active_path).await
70    }
71
72    /// Add a new project (or update existing)
73    pub async fn add_project(&self, path: PathBuf) -> Result<(), String> {
74        if !path.exists() {
75            return Err(format!("Project path does not exist: {}", path.display()));
76        }
77
78        let db_path = path.join(".intent-engine").join("project.db");
79        if !db_path.exists() {
80            return Err(format!("Database not found: {}", db_path.display()));
81        }
82
83        let name = path
84            .file_name()
85            .and_then(|n| n.to_str())
86            .unwrap_or("unknown")
87            .to_string();
88
89        let info = ProjectInfo {
90            name,
91            path: path.clone(),
92            db_path,
93        };
94
95        let mut projects = self.known_projects.write().await;
96        projects.insert(path, info);
97        Ok(())
98    }
99
100    /// Get active project info
101    pub async fn get_active_project(&self) -> Option<ProjectInfo> {
102        let active_path = self.active_project_path.read().await;
103        let projects = self.known_projects.read().await;
104        projects.get(&*active_path).cloned()
105    }
106
107    /// Switch active project
108    pub async fn switch_active_project(&self, path: PathBuf) -> Result<(), String> {
109        let projects = self.known_projects.read().await;
110        if !projects.contains_key(&path) {
111            return Err(format!("Project not registered: {}", path.display()));
112        }
113        drop(projects);
114
115        let mut active = self.active_project_path.write().await;
116        *active = path;
117        Ok(())
118    }
119
120    /// Remove a project from known projects and global registry
121    pub async fn remove_project(&self, path: &PathBuf) -> Result<(), String> {
122        // Don't allow removing the host project
123        if path.as_path() == std::path::Path::new(&self.host_project.path) {
124            return Err("Cannot remove the host project".to_string());
125        }
126
127        // Remove from known projects
128        let mut projects = self.known_projects.write().await;
129        projects.remove(path);
130
131        // Remove from global registry
132        let path_str = path.to_string_lossy().to_string();
133        crate::global_projects::remove_project(&path_str);
134
135        Ok(())
136    }
137
138    /// Get active project's db_pool and path (backward compatibility helper)
139    /// Returns (db_pool, project_path_string)
140    pub async fn get_active_project_context(&self) -> Result<(SqlitePool, String), String> {
141        let db_pool = self.get_active_db_pool().await?;
142        let project_path = self
143            .get_active_project()
144            .await
145            .map(|p| p.path.to_string_lossy().to_string())
146            .unwrap_or_default();
147        Ok((db_pool, project_path))
148    }
149}
150
151/// Dashboard server instance
152pub struct DashboardServer {
153    port: u16,
154    db_path: PathBuf,
155    project_name: String,
156    project_path: PathBuf,
157}
158
159/// Health check response
160#[derive(Serialize)]
161struct HealthResponse {
162    status: String,
163    service: String,
164    version: String,
165}
166
167/// Project info response (for API)
168#[derive(Serialize)]
169struct ProjectInfoResponse {
170    name: String,
171    path: String,
172    database: String,
173    port: u16,
174    is_online: bool,
175    mcp_connected: bool,
176}
177
178impl DashboardServer {
179    /// Create a new Dashboard server instance
180    pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
181        // Determine project name from path
182        let project_name = project_path
183            .file_name()
184            .and_then(|n| n.to_str())
185            .unwrap_or("unknown")
186            .to_string();
187
188        if !db_path.exists() {
189            anyhow::bail!(
190                "Database not found at {}. Is this an Intent-Engine project?",
191                db_path.display()
192            );
193        }
194
195        Ok(Self {
196            port,
197            db_path,
198            project_name,
199            project_path,
200        })
201    }
202
203    /// Run the Dashboard server
204    pub async fn run(self) -> Result<()> {
205        // Initialize known projects with the host project
206        let mut known_projects = HashMap::new();
207        let host_info = ProjectInfo {
208            name: self.project_name.clone(),
209            path: self.project_path.clone(),
210            db_path: self.db_path.clone(),
211        };
212        // Use canonical path string as key for consistent comparison on Windows
213        let host_key = self
214            .project_path
215            .canonicalize()
216            .unwrap_or_else(|_| self.project_path.clone());
217        known_projects.insert(host_key, host_info);
218
219        // Load projects from global registry
220        let registry = crate::global_projects::ProjectsRegistry::load();
221        for entry in registry.projects {
222            let path = PathBuf::from(&entry.path);
223            // Use canonical path for comparison to handle Windows path normalization
224            let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
225            // Skip if already added (host project)
226            if known_projects.contains_key(&canonical_path) {
227                continue;
228            }
229            let db_path = path.join(".intent-engine").join("project.db");
230            if db_path.exists() {
231                let name = entry.name.unwrap_or_else(|| {
232                    path.file_name()
233                        .and_then(|n| n.to_str())
234                        .unwrap_or("unknown")
235                        .to_string()
236                });
237                known_projects.insert(
238                    canonical_path,
239                    ProjectInfo {
240                        name,
241                        path,
242                        db_path,
243                    },
244                );
245            }
246        }
247        tracing::info!(
248            "Loaded {} projects from global registry",
249            known_projects.len()
250        );
251
252        // Create shared state
253        let ws_state = websocket::WebSocketState::new();
254
255        let host_project_info = websocket::ProjectInfo {
256            name: self.project_name.clone(),
257            path: self.project_path.display().to_string(),
258            db_path: self.db_path.display().to_string(),
259            agent: None,
260            mcp_connected: false, // Will be updated dynamically
261            is_online: true,      // Host is always online
262        };
263
264        // Create shutdown channel for graceful shutdown
265        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
266
267        let state = AppState {
268            known_projects: Arc::new(RwLock::new(known_projects)),
269            active_project_path: Arc::new(RwLock::new(self.project_path.clone())),
270            host_project: host_project_info,
271            port: self.port,
272            ws_state,
273            shutdown_tx: Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))),
274        };
275
276        // Build router
277        let app = create_router(state);
278
279        // Bind to address
280        // Bind to 0.0.0.0 to allow external access (e.g., from Windows host when running in WSL)
281        let addr = format!("0.0.0.0:{}", self.port);
282        let listener = tokio::net::TcpListener::bind(&addr)
283            .await
284            .with_context(|| format!("Failed to bind to {}", addr))?;
285
286        tracing::info!(address = %addr, "Dashboard server listening");
287        tracing::warn!(
288            port = self.port,
289            "⚠️  Dashboard is accessible from external IPs"
290        );
291        tracing::info!(project = %self.project_name, "Project loaded");
292        tracing::info!(db_path = %self.db_path.display(), "Database path");
293
294        // Ignore SIGHUP signal on Unix systems to prevent termination when terminal closes
295        #[cfg(unix)]
296        {
297            unsafe {
298                libc::signal(libc::SIGHUP, libc::SIG_IGN);
299            }
300        }
301
302        // Run server with graceful shutdown
303        tracing::info!("Starting server with graceful shutdown support");
304        axum::serve(listener, app)
305            .with_graceful_shutdown(async {
306                shutdown_rx.await.ok();
307                tracing::info!("Shutdown signal received, initiating graceful shutdown");
308            })
309            .await
310            .context("Server error")?;
311
312        tracing::info!("Dashboard server shut down successfully");
313        Ok(())
314    }
315}
316
317/// Create the Axum router with all routes and middleware
318fn create_router(state: AppState) -> Router {
319    use super::routes;
320
321    // Combine basic API routes with full API routes
322    let api_routes = Router::new()
323        .route("/health", get(health_handler))
324        .route("/info", get(info_handler))
325        .merge(routes::api_routes());
326
327    // Main router - all routes share the same AppState
328    Router::new()
329        // Root route - serve index.html
330        .route("/", get(serve_index))
331        // Static files under /static prefix (embedded)
332        .route("/static/*path", get(serve_static))
333        // Vite assets under /assets prefix
334        .route("/assets/*path", get(serve_assets))
335        // API routes under /api prefix
336        .nest("/api", api_routes)
337        // WebSocket routes (now use full AppState)
338        .route("/ws/mcp", get(websocket::handle_mcp_websocket))
339        .route("/ws/ui", get(websocket::handle_ui_websocket))
340        // Fallback to 404
341        .fallback(not_found_handler)
342        // Add state
343        .with_state(state)
344        // Add middleware
345        .layer(
346            CorsLayer::new()
347                .allow_origin(Any)
348                .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
349                .allow_headers(Any),
350        )
351        .layer(TraceLayer::new_for_http())
352}
353
354/// Serve the main index.html file from embedded assets
355async fn serve_index() -> impl IntoResponse {
356    match StaticAssets::get("index.html") {
357        Some(content) => {
358            let body = content.data.to_vec();
359            Response::builder()
360                .status(StatusCode::OK)
361                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
362                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
363                .header(header::PRAGMA, "no-cache")
364                .header(header::EXPIRES, "0")
365                .body(body.into())
366                .unwrap()
367        },
368        None => (
369            StatusCode::INTERNAL_SERVER_ERROR,
370            Html("<h1>Error: index.html not found</h1>".to_string()),
371        )
372            .into_response(),
373    }
374}
375
376/// Serve static files from embedded assets
377async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
378    // Remove leading slash if present
379    let path = path.trim_start_matches('/');
380
381    match StaticAssets::get(path) {
382        Some(content) => {
383            let mime = mime_guess::from_path(path).first_or_octet_stream();
384            let body = content.data.to_vec();
385            Response::builder()
386                .status(StatusCode::OK)
387                .header(header::CONTENT_TYPE, mime.as_ref())
388                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
389                .header(header::PRAGMA, "no-cache")
390                .header(header::EXPIRES, "0")
391                .body(body.into())
392                .unwrap()
393        },
394        None => (
395            StatusCode::NOT_FOUND,
396            Json(serde_json::json!({
397                "error": "File not found",
398                "code": "NOT_FOUND",
399                "path": path
400            })),
401        )
402            .into_response(),
403    }
404}
405
406/// Serve assets from embedded assets (for Vite)
407async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
408    // Remove leading slash if present
409    let path = path.trim_start_matches('/');
410    // Prepend "assets/" if not present (though the route is /assets/*path, so path usually won't have it unless we strip it in route)
411    // Actually, the route is /assets/*path. If we request /assets/index.css, path is index.css.
412    // We need to look up "assets/index.css" in StaticAssets.
413    let full_path = format!("assets/{}", path);
414
415    match StaticAssets::get(&full_path) {
416        Some(content) => {
417            let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
418            let body = content.data.to_vec();
419            Response::builder()
420                .status(StatusCode::OK)
421                .header(header::CONTENT_TYPE, mime.as_ref())
422                .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
423                .header(header::PRAGMA, "no-cache")
424                .header(header::EXPIRES, "0")
425                .body(body.into())
426                .unwrap()
427        },
428        None => (
429            StatusCode::NOT_FOUND,
430            Json(serde_json::json!({
431                "error": "Asset not found",
432                "code": "NOT_FOUND",
433                "path": full_path
434            })),
435        )
436            .into_response(),
437    }
438}
439
440/// Health check handler
441async fn health_handler() -> Json<HealthResponse> {
442    Json(HealthResponse {
443        status: "healthy".to_string(),
444        service: "intent-engine-dashboard".to_string(),
445        version: env!("CARGO_PKG_VERSION").to_string(),
446    })
447}
448
449/// Project info handler
450/// Returns current Dashboard project info from the single source of truth (WebSocketState)
451async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfoResponse> {
452    let active_project = state.get_active_project().await;
453
454    match active_project {
455        Some(project) => {
456            // Get project info from WebSocketState (single source of truth)
457            let projects = state
458                .ws_state
459                .get_online_projects_with_current(
460                    &project.name,
461                    &project.path,
462                    &project.db_path,
463                    &state.host_project,
464                    state.port,
465                )
466                .await;
467
468            // Return the first project (which is always the current Dashboard project)
469            let current_project = projects.first().expect("Current project must exist");
470
471            Json(ProjectInfoResponse {
472                name: current_project.name.clone(),
473                path: current_project.path.clone(),
474                database: current_project.db_path.clone(),
475                port: state.port,
476                is_online: current_project.is_online,
477                mcp_connected: current_project.mcp_connected,
478            })
479        },
480        None => Json(ProjectInfoResponse {
481            name: "unknown".to_string(),
482            path: "".to_string(),
483            database: "".to_string(),
484            port: state.port,
485            is_online: false,
486            mcp_connected: false,
487        }),
488    }
489}
490
491/// 404 Not Found handler
492async fn not_found_handler() -> impl IntoResponse {
493    (
494        StatusCode::NOT_FOUND,
495        Json(serde_json::json!({
496            "error": "Not found",
497            "code": "NOT_FOUND"
498        })),
499    )
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_health_response_serialization() {
508        let response = HealthResponse {
509            status: "healthy".to_string(),
510            service: "test".to_string(),
511            version: "1.0.0".to_string(),
512        };
513
514        let json = serde_json::to_string(&response).unwrap();
515        assert!(json.contains("healthy"));
516        assert!(json.contains("test"));
517    }
518
519    #[test]
520    fn test_project_info_response_serialization() {
521        let info = ProjectInfoResponse {
522            name: "test-project".to_string(),
523            path: "/path/to/project".to_string(),
524            database: "/path/to/db".to_string(),
525            port: 11391,
526            is_online: true,
527            mcp_connected: false,
528        };
529
530        let json = serde_json::to_string(&info).unwrap();
531        assert!(json.contains("test-project"));
532        assert!(json.contains("11391"));
533    }
534}