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