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    pub port: u16,
42    /// WebSocket state for real-time connections
43    pub ws_state: super::websocket::WebSocketState,
44}
45
46/// Dashboard server instance
47pub struct DashboardServer {
48    port: u16,
49    db_path: PathBuf,
50    project_name: String,
51    project_path: PathBuf,
52}
53
54/// Health check response
55#[derive(Serialize)]
56struct HealthResponse {
57    status: String,
58    service: String,
59    version: String,
60}
61
62/// Project info response
63#[derive(Serialize)]
64struct ProjectInfo {
65    name: String,
66    path: String,
67    database: String,
68    port: u16,
69    is_online: bool,
70    mcp_connected: bool,
71}
72
73impl DashboardServer {
74    /// Create a new Dashboard server instance
75    pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
76        // Determine project name from path
77        let project_name = project_path
78            .file_name()
79            .and_then(|n| n.to_str())
80            .unwrap_or("unknown")
81            .to_string();
82
83        if !db_path.exists() {
84            anyhow::bail!(
85                "Database not found at {}. Is this an Intent-Engine project?",
86                db_path.display()
87            );
88        }
89
90        Ok(Self {
91            port,
92            db_path,
93            project_name,
94            project_path,
95        })
96    }
97
98    /// Run the Dashboard server
99    pub async fn run(self) -> Result<()> {
100        // Create database connection pool
101        let db_url = format!("sqlite://{}", self.db_path.display());
102        let db_pool = SqlitePool::connect(&db_url)
103            .await
104            .context("Failed to connect to database")?;
105
106        // Create project context
107        let project_context = ProjectContext {
108            db_pool,
109            project_name: self.project_name.clone(),
110            project_path: self.project_path.clone(),
111            db_path: self.db_path.clone(),
112        };
113
114        // Create shared state
115        let ws_state = websocket::WebSocketState::new();
116        let state = AppState {
117            current_project: Arc::new(RwLock::new(project_context)),
118            port: self.port,
119            ws_state,
120        };
121
122        // Build router
123        let app = create_router(state);
124
125        // Bind to address
126        // Bind to 0.0.0.0 to allow external access (e.g., from Windows host when running in WSL)
127        let addr = format!("0.0.0.0:{}", self.port);
128        let listener = tokio::net::TcpListener::bind(&addr)
129            .await
130            .with_context(|| format!("Failed to bind to {}", addr))?;
131
132        tracing::info!("Dashboard server listening on {}", addr);
133        tracing::warn!(
134            "⚠️  Dashboard is accessible from external IPs. Access via http://localhost:{} or http://<your-ip>:{}",
135            self.port, self.port
136        );
137        tracing::info!("Project: {}", self.project_name);
138        tracing::info!("Database: {}", self.db_path.display());
139
140        // Ignore SIGHUP signal on Unix systems to prevent termination when terminal closes
141        #[cfg(unix)]
142        {
143            unsafe {
144                libc::signal(libc::SIGHUP, libc::SIG_IGN);
145            }
146        }
147
148        // Run server
149        axum::serve(listener, app).await.context("Server error")?;
150
151        Ok(())
152    }
153}
154
155/// Create the Axum router with all routes and middleware
156fn create_router(state: AppState) -> Router {
157    use super::routes;
158
159    // Combine basic API routes with full API routes
160    let api_routes = Router::new()
161        .route("/health", get(health_handler))
162        .route("/info", get(info_handler))
163        .merge(routes::api_routes());
164
165    // Main router - all routes share the same AppState
166    Router::new()
167        // Root route - serve index.html
168        .route("/", get(serve_index))
169        // Static files under /static prefix (embedded)
170        .route("/static/*path", get(serve_static))
171        // API routes under /api prefix
172        .nest("/api", api_routes)
173        // WebSocket routes (now use full AppState)
174        .route("/ws/mcp", get(websocket::handle_mcp_websocket))
175        .route("/ws/ui", get(websocket::handle_ui_websocket))
176        // Fallback to 404
177        .fallback(not_found_handler)
178        // Add state
179        .with_state(state)
180        // Add middleware
181        .layer(
182            CorsLayer::new()
183                .allow_origin(Any)
184                .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
185                .allow_headers(Any),
186        )
187        .layer(TraceLayer::new_for_http())
188}
189
190/// Serve the main index.html file from embedded assets
191async fn serve_index() -> impl IntoResponse {
192    match StaticAssets::get("index.html") {
193        Some(content) => {
194            let body = content.data.to_vec();
195            Response::builder()
196                .status(StatusCode::OK)
197                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
198                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
199                .header(header::PRAGMA, "no-cache")
200                .header(header::EXPIRES, "0")
201                .body(body.into())
202                .unwrap()
203        },
204        None => (
205            StatusCode::INTERNAL_SERVER_ERROR,
206            Html("<h1>Error: index.html not found</h1>".to_string()),
207        )
208            .into_response(),
209    }
210}
211
212/// Serve static files from embedded assets
213async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
214    // Remove leading slash if present
215    let path = path.trim_start_matches('/');
216
217    match StaticAssets::get(path) {
218        Some(content) => {
219            let mime = mime_guess::from_path(path).first_or_octet_stream();
220            let body = content.data.to_vec();
221            Response::builder()
222                .status(StatusCode::OK)
223                .header(header::CONTENT_TYPE, mime.as_ref())
224                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
225                .header(header::PRAGMA, "no-cache")
226                .header(header::EXPIRES, "0")
227                .body(body.into())
228                .unwrap()
229        },
230        None => (
231            StatusCode::NOT_FOUND,
232            Json(serde_json::json!({
233                "error": "File not found",
234                "code": "NOT_FOUND",
235                "path": path
236            })),
237        )
238            .into_response(),
239    }
240}
241
242/// Legacy root handler - now unused, kept for reference
243#[allow(dead_code)]
244async fn index_handler(State(state): State<AppState>) -> Html<String> {
245    let project = state.current_project.read().await;
246    let html = format!(
247        r#"<!DOCTYPE html>
248<html lang="en">
249<head>
250    <meta charset="UTF-8">
251    <meta name="viewport" content="width=device-width, initial-scale=1.0">
252    <title>Intent-Engine Dashboard - {}</title>
253    <style>
254        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
255        body {{
256            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
257            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
258            min-height: 100vh;
259            display: flex;
260            align-items: center;
261            justify-content: center;
262            padding: 20px;
263        }}
264        .container {{
265            background: white;
266            border-radius: 16px;
267            padding: 48px;
268            max-width: 600px;
269            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
270        }}
271        h1 {{
272            font-size: 2.5em;
273            margin-bottom: 16px;
274            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
275            -webkit-background-clip: text;
276            -webkit-text-fill-color: transparent;
277            background-clip: text;
278        }}
279        .subtitle {{
280            color: #666;
281            font-size: 1.2em;
282            margin-bottom: 32px;
283        }}
284        .info-grid {{
285            display: grid;
286            gap: 16px;
287            margin-bottom: 32px;
288        }}
289        .info-item {{
290            display: flex;
291            align-items: center;
292            padding: 16px;
293            background: #f7f7f7;
294            border-radius: 8px;
295        }}
296        .info-label {{
297            font-weight: 600;
298            color: #667eea;
299            min-width: 100px;
300        }}
301        .info-value {{
302            color: #333;
303            word-break: break-all;
304        }}
305        .status {{
306            display: inline-block;
307            padding: 8px 16px;
308            background: #10b981;
309            color: white;
310            border-radius: 20px;
311            font-weight: 600;
312            font-size: 0.9em;
313        }}
314        .footer {{
315            text-align: center;
316            color: #999;
317            margin-top: 32px;
318            font-size: 0.9em;
319        }}
320        a {{
321            color: #667eea;
322            text-decoration: none;
323        }}
324        a:hover {{
325            text-decoration: underline;
326        }}
327    </style>
328</head>
329<body>
330    <div class="container">
331        <h1>Intent-Engine Dashboard</h1>
332        <div class="subtitle">
333            <span class="status">🟢 Running</span>
334        </div>
335
336        <div class="info-grid">
337            <div class="info-item">
338                <span class="info-label">Project:</span>
339                <span class="info-value">{}</span>
340            </div>
341            <div class="info-item">
342                <span class="info-label">Path:</span>
343                <span class="info-value">{}</span>
344            </div>
345            <div class="info-item">
346                <span class="info-label">Port:</span>
347                <span class="info-value">{}</span>
348            </div>
349        </div>
350
351        <div class="footer">
352            <p>API Endpoints: <a href="/api/health">/api/health</a> • <a href="/api/info">/api/info</a></p>
353            <p style="margin-top: 8px;">Intent-Engine v{} • <a href="https://github.com/wayfind/intent-engine" target="_blank">GitHub</a></p>
354        </div>
355    </div>
356</body>
357</html>
358"#,
359        project.project_name,
360        project.project_name,
361        project.project_path.display(),
362        state.port,
363        env!("CARGO_PKG_VERSION")
364    );
365
366    Html(html)
367}
368
369/// Health check handler
370async fn health_handler() -> Json<HealthResponse> {
371    Json(HealthResponse {
372        status: "healthy".to_string(),
373        service: "intent-engine-dashboard".to_string(),
374        version: env!("CARGO_PKG_VERSION").to_string(),
375    })
376}
377
378/// Project info handler
379/// Returns current Dashboard project info from the single source of truth (WebSocketState)
380async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
381    let project = state.current_project.read().await;
382
383    // Get project info from WebSocketState (single source of truth)
384    let projects = state
385        .ws_state
386        .get_online_projects_with_current(
387            &project.project_name,
388            &project.project_path,
389            &project.db_path,
390            state.port,
391        )
392        .await;
393
394    // Return the first project (which is always the current Dashboard project)
395    let current_project = projects.first().expect("Current project must exist");
396
397    Json(ProjectInfo {
398        name: current_project.name.clone(),
399        path: current_project.path.clone(),
400        database: current_project.db_path.clone(),
401        port: state.port,
402        is_online: current_project.is_online,
403        mcp_connected: current_project.mcp_connected,
404    })
405}
406
407/// 404 Not Found handler
408async fn not_found_handler() -> impl IntoResponse {
409    (
410        StatusCode::NOT_FOUND,
411        Json(serde_json::json!({
412            "error": "Not found",
413            "code": "NOT_FOUND"
414        })),
415    )
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_health_response_serialization() {
424        let response = HealthResponse {
425            status: "healthy".to_string(),
426            service: "test".to_string(),
427            version: "1.0.0".to_string(),
428        };
429
430        let json = serde_json::to_string(&response).unwrap();
431        assert!(json.contains("healthy"));
432        assert!(json.contains("test"));
433    }
434
435    #[test]
436    fn test_project_info_serialization() {
437        let info = ProjectInfo {
438            name: "test-project".to_string(),
439            path: "/path/to/project".to_string(),
440            database: "/path/to/db".to_string(),
441            port: 11391,
442            is_online: true,
443            mcp_connected: false,
444        };
445
446        let json = serde_json::to_string(&info).unwrap();
447        assert!(json.contains("test-project"));
448        assert!(json.contains("11391"));
449    }
450}