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::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/// Embedded static assets (HTML, CSS, JS)
23#[derive(RustEmbed)]
24#[folder = "static/"]
25struct StaticAssets;
26
27/// Project context that can be switched dynamically
28#[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/// Dashboard server state shared across handlers
37#[derive(Clone)]
38pub struct AppState {
39    /// Current active project (wrapped in `Arc<RwLock>` for dynamic switching)
40    pub current_project: Arc<RwLock<ProjectContext>>,
41    /// The project that started the Dashboard (always considered online)
42    pub host_project: super::websocket::ProjectInfo,
43    pub port: u16,
44    /// WebSocket state for real-time connections
45    pub ws_state: super::websocket::WebSocketState,
46}
47
48/// Dashboard server instance
49pub struct DashboardServer {
50    port: u16,
51    db_path: PathBuf,
52    project_name: String,
53    project_path: PathBuf,
54}
55
56/// Health check response
57#[derive(Serialize)]
58struct HealthResponse {
59    status: String,
60    service: String,
61    version: String,
62}
63
64/// Project info response
65#[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    /// Create a new Dashboard server instance
77    pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
78        // Determine project name from path
79        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    /// Run the Dashboard server
101    pub async fn run(self) -> Result<()> {
102        // Create database connection pool
103        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        // Create project context
109        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        // Create shared state
117        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, // Will be updated dynamically
125            is_online: true,      // Host is always online
126        };
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        // Build router
136        let app = create_router(state);
137
138        // Bind to address
139        // Bind to 0.0.0.0 to allow external access (e.g., from Windows host when running in WSL)
140        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        // Ignore SIGHUP signal on Unix systems to prevent termination when terminal closes
154        #[cfg(unix)]
155        {
156            unsafe {
157                libc::signal(libc::SIGHUP, libc::SIG_IGN);
158            }
159        }
160
161        // Run server
162        axum::serve(listener, app).await.context("Server error")?;
163
164        Ok(())
165    }
166}
167
168/// Create the Axum router with all routes and middleware
169fn create_router(state: AppState) -> Router {
170    use super::routes;
171
172    // Combine basic API routes with full API routes
173    let api_routes = Router::new()
174        .route("/health", get(health_handler))
175        .route("/info", get(info_handler))
176        .merge(routes::api_routes());
177
178    // Main router - all routes share the same AppState
179    Router::new()
180        // Root route - serve index.html
181        .route("/", get(serve_index))
182        // Static files under /static prefix (embedded)
183        .route("/static/*path", get(serve_static))
184        // Vite assets under /assets prefix
185        .route("/assets/*path", get(serve_assets))
186        // API routes under /api prefix
187        .nest("/api", api_routes)
188        // WebSocket routes (now use full AppState)
189        .route("/ws/mcp", get(websocket::handle_mcp_websocket))
190        .route("/ws/ui", get(websocket::handle_ui_websocket))
191        // Fallback to 404
192        .fallback(not_found_handler)
193        // Add state
194        .with_state(state)
195        // Add middleware
196        .layer(
197            CorsLayer::new()
198                .allow_origin(Any)
199                .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
200                .allow_headers(Any),
201        )
202        .layer(TraceLayer::new_for_http())
203}
204
205/// Serve the main index.html file from embedded assets
206async fn serve_index() -> impl IntoResponse {
207    match StaticAssets::get("index.html") {
208        Some(content) => {
209            let body = content.data.to_vec();
210            Response::builder()
211                .status(StatusCode::OK)
212                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
213                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
214                .header(header::PRAGMA, "no-cache")
215                .header(header::EXPIRES, "0")
216                .body(body.into())
217                .unwrap()
218        },
219        None => (
220            StatusCode::INTERNAL_SERVER_ERROR,
221            Html("<h1>Error: index.html not found</h1>".to_string()),
222        )
223            .into_response(),
224    }
225}
226
227/// Serve static files from embedded assets
228async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
229    // Remove leading slash if present
230    let path = path.trim_start_matches('/');
231
232    match StaticAssets::get(path) {
233        Some(content) => {
234            let mime = mime_guess::from_path(path).first_or_octet_stream();
235            let body = content.data.to_vec();
236            Response::builder()
237                .status(StatusCode::OK)
238                .header(header::CONTENT_TYPE, mime.as_ref())
239                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
240                .header(header::PRAGMA, "no-cache")
241                .header(header::EXPIRES, "0")
242                .body(body.into())
243                .unwrap()
244        },
245        None => (
246            StatusCode::NOT_FOUND,
247            Json(serde_json::json!({
248                "error": "File not found",
249                "code": "NOT_FOUND",
250                "path": path
251            })),
252        )
253            .into_response(),
254    }
255}
256
257/// Serve assets from embedded assets (for Vite)
258async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
259    // Remove leading slash if present
260    let path = path.trim_start_matches('/');
261    // Prepend "assets/" if not present (though the route is /assets/*path, so path usually won't have it unless we strip it in route)
262    // Actually, the route is /assets/*path. If we request /assets/index.css, path is index.css.
263    // We need to look up "assets/index.css" in StaticAssets.
264    let full_path = format!("assets/{}", path);
265
266    match StaticAssets::get(&full_path) {
267        Some(content) => {
268            let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
269            let body = content.data.to_vec();
270            Response::builder()
271                .status(StatusCode::OK)
272                .header(header::CONTENT_TYPE, mime.as_ref())
273                .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
274                .header(header::PRAGMA, "no-cache")
275                .header(header::EXPIRES, "0")
276                .body(body.into())
277                .unwrap()
278        },
279        None => (
280            StatusCode::NOT_FOUND,
281            Json(serde_json::json!({
282                "error": "Asset not found",
283                "code": "NOT_FOUND",
284                "path": full_path
285            })),
286        )
287            .into_response(),
288    }
289}
290
291/// Health check handler
292async fn health_handler() -> Json<HealthResponse> {
293    Json(HealthResponse {
294        status: "healthy".to_string(),
295        service: "intent-engine-dashboard".to_string(),
296        version: env!("CARGO_PKG_VERSION").to_string(),
297    })
298}
299
300/// Project info handler
301/// Returns current Dashboard project info from the single source of truth (WebSocketState)
302async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
303    let project = state.current_project.read().await;
304
305    // Get project info from WebSocketState (single source of truth)
306    let projects = state
307        .ws_state
308        .get_online_projects_with_current(
309            &project.project_name,
310            &project.project_path,
311            &project.db_path,
312            &state.host_project,
313            state.port,
314        )
315        .await;
316
317    // Return the first project (which is always the current Dashboard project)
318    let current_project = projects.first().expect("Current project must exist");
319
320    Json(ProjectInfo {
321        name: current_project.name.clone(),
322        path: current_project.path.clone(),
323        database: current_project.db_path.clone(),
324        port: state.port,
325        is_online: current_project.is_online,
326        mcp_connected: current_project.mcp_connected,
327    })
328}
329
330/// 404 Not Found handler
331async fn not_found_handler() -> impl IntoResponse {
332    (
333        StatusCode::NOT_FOUND,
334        Json(serde_json::json!({
335            "error": "Not found",
336            "code": "NOT_FOUND"
337        })),
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_health_response_serialization() {
347        let response = HealthResponse {
348            status: "healthy".to_string(),
349            service: "test".to_string(),
350            version: "1.0.0".to_string(),
351        };
352
353        let json = serde_json::to_string(&response).unwrap();
354        assert!(json.contains("healthy"));
355        assert!(json.contains("test"));
356    }
357
358    #[test]
359    fn test_project_info_serialization() {
360        let info = ProjectInfo {
361            name: "test-project".to_string(),
362            path: "/path/to/project".to_string(),
363            database: "/path/to/db".to_string(),
364            port: 11391,
365            is_online: true,
366            mcp_connected: false,
367        };
368
369        let json = serde_json::to_string(&info).unwrap();
370        assert!(json.contains("test-project"));
371        assert!(json.contains("11391"));
372    }
373}