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                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
198                .header(header::PRAGMA, "no-cache")
199                .header(header::EXPIRES, "0")
200                .body(body.into())
201                .unwrap()
202        },
203        None => (
204            StatusCode::INTERNAL_SERVER_ERROR,
205            Html("<h1>Error: index.html not found</h1>".to_string()),
206        )
207            .into_response(),
208    }
209}
210
211/// Serve static files from embedded assets
212async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
213    // Remove leading slash if present
214    let path = path.trim_start_matches('/');
215
216    match StaticAssets::get(path) {
217        Some(content) => {
218            let mime = mime_guess::from_path(path).first_or_octet_stream();
219            let body = content.data.to_vec();
220            Response::builder()
221                .status(StatusCode::OK)
222                .header(header::CONTENT_TYPE, mime.as_ref())
223                .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
224                .header(header::PRAGMA, "no-cache")
225                .header(header::EXPIRES, "0")
226                .body(body.into())
227                .unwrap()
228        },
229        None => (
230            StatusCode::NOT_FOUND,
231            Json(serde_json::json!({
232                "error": "File not found",
233                "code": "NOT_FOUND",
234                "path": path
235            })),
236        )
237            .into_response(),
238    }
239}
240
241/// Legacy root handler - now unused, kept for reference
242#[allow(dead_code)]
243async fn index_handler(State(state): State<AppState>) -> Html<String> {
244    let project = state.current_project.read().await;
245    let html = format!(
246        r#"<!DOCTYPE html>
247<html lang="en">
248<head>
249    <meta charset="UTF-8">
250    <meta name="viewport" content="width=device-width, initial-scale=1.0">
251    <title>Intent-Engine Dashboard - {}</title>
252    <style>
253        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
254        body {{
255            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
256            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
257            min-height: 100vh;
258            display: flex;
259            align-items: center;
260            justify-content: center;
261            padding: 20px;
262        }}
263        .container {{
264            background: white;
265            border-radius: 16px;
266            padding: 48px;
267            max-width: 600px;
268            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
269        }}
270        h1 {{
271            font-size: 2.5em;
272            margin-bottom: 16px;
273            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
274            -webkit-background-clip: text;
275            -webkit-text-fill-color: transparent;
276            background-clip: text;
277        }}
278        .subtitle {{
279            color: #666;
280            font-size: 1.2em;
281            margin-bottom: 32px;
282        }}
283        .info-grid {{
284            display: grid;
285            gap: 16px;
286            margin-bottom: 32px;
287        }}
288        .info-item {{
289            display: flex;
290            align-items: center;
291            padding: 16px;
292            background: #f7f7f7;
293            border-radius: 8px;
294        }}
295        .info-label {{
296            font-weight: 600;
297            color: #667eea;
298            min-width: 100px;
299        }}
300        .info-value {{
301            color: #333;
302            word-break: break-all;
303        }}
304        .status {{
305            display: inline-block;
306            padding: 8px 16px;
307            background: #10b981;
308            color: white;
309            border-radius: 20px;
310            font-weight: 600;
311            font-size: 0.9em;
312        }}
313        .footer {{
314            text-align: center;
315            color: #999;
316            margin-top: 32px;
317            font-size: 0.9em;
318        }}
319        a {{
320            color: #667eea;
321            text-decoration: none;
322        }}
323        a:hover {{
324            text-decoration: underline;
325        }}
326    </style>
327</head>
328<body>
329    <div class="container">
330        <h1>Intent-Engine Dashboard</h1>
331        <div class="subtitle">
332            <span class="status">🟢 Running</span>
333        </div>
334
335        <div class="info-grid">
336            <div class="info-item">
337                <span class="info-label">Project:</span>
338                <span class="info-value">{}</span>
339            </div>
340            <div class="info-item">
341                <span class="info-label">Path:</span>
342                <span class="info-value">{}</span>
343            </div>
344            <div class="info-item">
345                <span class="info-label">Port:</span>
346                <span class="info-value">{}</span>
347            </div>
348        </div>
349
350        <div class="footer">
351            <p>API Endpoints: <a href="/api/health">/api/health</a> • <a href="/api/info">/api/info</a></p>
352            <p style="margin-top: 8px;">Intent-Engine v{} • <a href="https://github.com/wayfind/intent-engine" target="_blank">GitHub</a></p>
353        </div>
354    </div>
355</body>
356</html>
357"#,
358        project.project_name,
359        project.project_name,
360        project.project_path.display(),
361        state.port,
362        env!("CARGO_PKG_VERSION")
363    );
364
365    Html(html)
366}
367
368/// Health check handler
369async fn health_handler() -> Json<HealthResponse> {
370    Json(HealthResponse {
371        status: "healthy".to_string(),
372        service: "intent-engine-dashboard".to_string(),
373        version: env!("CARGO_PKG_VERSION").to_string(),
374    })
375}
376
377/// Project info handler
378async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
379    let project = state.current_project.read().await;
380    Json(ProjectInfo {
381        name: project.project_name.clone(),
382        path: project.project_path.display().to_string(),
383        database: project.db_path.display().to_string(),
384        port: state.port,
385    })
386}
387
388/// 404 Not Found handler
389async fn not_found_handler() -> impl IntoResponse {
390    (
391        StatusCode::NOT_FOUND,
392        Json(serde_json::json!({
393            "error": "Not found",
394            "code": "NOT_FOUND"
395        })),
396    )
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_health_response_serialization() {
405        let response = HealthResponse {
406            status: "healthy".to_string(),
407            service: "test".to_string(),
408            version: "1.0.0".to_string(),
409        };
410
411        let json = serde_json::to_string(&response).unwrap();
412        assert!(json.contains("healthy"));
413        assert!(json.contains("test"));
414    }
415
416    #[test]
417    fn test_project_info_serialization() {
418        let info = ProjectInfo {
419            name: "test-project".to_string(),
420            path: "/path/to/project".to_string(),
421            database: "/path/to/db".to_string(),
422            port: 11391,
423        };
424
425        let json = serde_json::to_string(&info).unwrap();
426        assert!(json.contains("test-project"));
427        assert!(json.contains("11391"));
428    }
429}