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#[derive(RustEmbed)]
24#[folder = "static/"]
25struct StaticAssets;
26
27#[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#[derive(Clone)]
38pub struct AppState {
39 pub current_project: Arc<RwLock<ProjectContext>>,
41 pub port: u16,
42 pub ws_state: super::websocket::WebSocketState,
44}
45
46pub struct DashboardServer {
48 port: u16,
49 db_path: PathBuf,
50 project_name: String,
51 project_path: PathBuf,
52}
53
54#[derive(Serialize)]
56struct HealthResponse {
57 status: String,
58 service: String,
59 version: String,
60}
61
62#[derive(Serialize)]
64struct ProjectInfo {
65 name: String,
66 path: String,
67 database: String,
68 port: u16,
69}
70
71impl DashboardServer {
72 pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
74 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 pub async fn run(self) -> Result<()> {
98 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 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 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 let app = create_router(state);
122
123 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 #[cfg(unix)]
135 {
136 unsafe {
137 libc::signal(libc::SIGHUP, libc::SIG_IGN);
138 }
139 }
140
141 axum::serve(listener, app).await.context("Server error")?;
143
144 Ok(())
145 }
146}
147
148fn create_router(state: AppState) -> Router {
150 use super::routes;
151
152 let api_routes = Router::new()
154 .route("/health", get(health_handler))
155 .route("/info", get(info_handler))
156 .merge(routes::api_routes());
157
158 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 Router::new()
167 .route("/", get(serve_index))
169 .route("/static/*path", get(serve_static))
171 .nest("/api", api_routes)
173 .merge(ws_routes)
175 .fallback(not_found_handler)
177 .with_state(state)
179 .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
189async 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
211async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
213 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#[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
368async 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
377async 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
388async 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}