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        // API routes under /api prefix
185        .nest("/api", api_routes)
186        // WebSocket routes (now use full AppState)
187        .route("/ws/mcp", get(websocket::handle_mcp_websocket))
188        .route("/ws/ui", get(websocket::handle_ui_websocket))
189        // Fallback to 404
190        .fallback(not_found_handler)
191        // Add state
192        .with_state(state)
193        // Add middleware
194        .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
203/// Serve the main index.html file from embedded assets
204async 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
225/// Serve static files from embedded assets
226async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
227    // Remove leading slash if present
228    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
255/// Health check handler
256async 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
264/// Project info handler
265/// Returns current Dashboard project info from the single source of truth (WebSocketState)
266async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
267    let project = state.current_project.read().await;
268
269    // Get project info from WebSocketState (single source of truth)
270    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    // Return the first project (which is always the current Dashboard project)
282    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
294/// 404 Not Found handler
295async 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}