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