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