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