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