intent_engine/dashboard/
server.rs

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