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 is_online: bool,
70 mcp_connected: bool,
71}
72
73impl DashboardServer {
74 pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
76 let project_name = project_path
78 .file_name()
79 .and_then(|n| n.to_str())
80 .unwrap_or("unknown")
81 .to_string();
82
83 if !db_path.exists() {
84 anyhow::bail!(
85 "Database not found at {}. Is this an Intent-Engine project?",
86 db_path.display()
87 );
88 }
89
90 Ok(Self {
91 port,
92 db_path,
93 project_name,
94 project_path,
95 })
96 }
97
98 pub async fn run(self) -> Result<()> {
100 let db_url = format!("sqlite://{}", self.db_path.display());
102 let db_pool = SqlitePool::connect(&db_url)
103 .await
104 .context("Failed to connect to database")?;
105
106 let project_context = ProjectContext {
108 db_pool,
109 project_name: self.project_name.clone(),
110 project_path: self.project_path.clone(),
111 db_path: self.db_path.clone(),
112 };
113
114 let ws_state = websocket::WebSocketState::new();
116 let state = AppState {
117 current_project: Arc::new(RwLock::new(project_context)),
118 port: self.port,
119 ws_state,
120 };
121
122 let app = create_router(state);
124
125 let addr = format!("127.0.0.1:{}", self.port);
127 let listener = tokio::net::TcpListener::bind(&addr)
128 .await
129 .with_context(|| format!("Failed to bind to {}", addr))?;
130
131 tracing::info!("Dashboard server listening on {}", addr);
132 tracing::info!("Project: {}", self.project_name);
133 tracing::info!("Database: {}", self.db_path.display());
134
135 #[cfg(unix)]
137 {
138 unsafe {
139 libc::signal(libc::SIGHUP, libc::SIG_IGN);
140 }
141 }
142
143 axum::serve(listener, app).await.context("Server error")?;
145
146 Ok(())
147 }
148}
149
150fn create_router(state: AppState) -> Router {
152 use super::routes;
153
154 let api_routes = Router::new()
156 .route("/health", get(health_handler))
157 .route("/info", get(info_handler))
158 .merge(routes::api_routes());
159
160 Router::new()
162 .route("/", get(serve_index))
164 .route("/static/*path", get(serve_static))
166 .nest("/api", api_routes)
168 .route("/ws/mcp", get(websocket::handle_mcp_websocket))
170 .route("/ws/ui", get(websocket::handle_ui_websocket))
171 .fallback(not_found_handler)
173 .with_state(state)
175 .layer(
177 CorsLayer::new()
178 .allow_origin(Any)
179 .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
180 .allow_headers(Any),
181 )
182 .layer(TraceLayer::new_for_http())
183}
184
185async fn serve_index() -> impl IntoResponse {
187 match StaticAssets::get("index.html") {
188 Some(content) => {
189 let body = content.data.to_vec();
190 Response::builder()
191 .status(StatusCode::OK)
192 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
193 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
194 .header(header::PRAGMA, "no-cache")
195 .header(header::EXPIRES, "0")
196 .body(body.into())
197 .unwrap()
198 },
199 None => (
200 StatusCode::INTERNAL_SERVER_ERROR,
201 Html("<h1>Error: index.html not found</h1>".to_string()),
202 )
203 .into_response(),
204 }
205}
206
207async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
209 let path = path.trim_start_matches('/');
211
212 match StaticAssets::get(path) {
213 Some(content) => {
214 let mime = mime_guess::from_path(path).first_or_octet_stream();
215 let body = content.data.to_vec();
216 Response::builder()
217 .status(StatusCode::OK)
218 .header(header::CONTENT_TYPE, mime.as_ref())
219 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
220 .header(header::PRAGMA, "no-cache")
221 .header(header::EXPIRES, "0")
222 .body(body.into())
223 .unwrap()
224 },
225 None => (
226 StatusCode::NOT_FOUND,
227 Json(serde_json::json!({
228 "error": "File not found",
229 "code": "NOT_FOUND",
230 "path": path
231 })),
232 )
233 .into_response(),
234 }
235}
236
237#[allow(dead_code)]
239async fn index_handler(State(state): State<AppState>) -> Html<String> {
240 let project = state.current_project.read().await;
241 let html = format!(
242 r#"<!DOCTYPE html>
243<html lang="en">
244<head>
245 <meta charset="UTF-8">
246 <meta name="viewport" content="width=device-width, initial-scale=1.0">
247 <title>Intent-Engine Dashboard - {}</title>
248 <style>
249 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
250 body {{
251 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
252 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
253 min-height: 100vh;
254 display: flex;
255 align-items: center;
256 justify-content: center;
257 padding: 20px;
258 }}
259 .container {{
260 background: white;
261 border-radius: 16px;
262 padding: 48px;
263 max-width: 600px;
264 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
265 }}
266 h1 {{
267 font-size: 2.5em;
268 margin-bottom: 16px;
269 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
270 -webkit-background-clip: text;
271 -webkit-text-fill-color: transparent;
272 background-clip: text;
273 }}
274 .subtitle {{
275 color: #666;
276 font-size: 1.2em;
277 margin-bottom: 32px;
278 }}
279 .info-grid {{
280 display: grid;
281 gap: 16px;
282 margin-bottom: 32px;
283 }}
284 .info-item {{
285 display: flex;
286 align-items: center;
287 padding: 16px;
288 background: #f7f7f7;
289 border-radius: 8px;
290 }}
291 .info-label {{
292 font-weight: 600;
293 color: #667eea;
294 min-width: 100px;
295 }}
296 .info-value {{
297 color: #333;
298 word-break: break-all;
299 }}
300 .status {{
301 display: inline-block;
302 padding: 8px 16px;
303 background: #10b981;
304 color: white;
305 border-radius: 20px;
306 font-weight: 600;
307 font-size: 0.9em;
308 }}
309 .footer {{
310 text-align: center;
311 color: #999;
312 margin-top: 32px;
313 font-size: 0.9em;
314 }}
315 a {{
316 color: #667eea;
317 text-decoration: none;
318 }}
319 a:hover {{
320 text-decoration: underline;
321 }}
322 </style>
323</head>
324<body>
325 <div class="container">
326 <h1>Intent-Engine Dashboard</h1>
327 <div class="subtitle">
328 <span class="status">🟢 Running</span>
329 </div>
330
331 <div class="info-grid">
332 <div class="info-item">
333 <span class="info-label">Project:</span>
334 <span class="info-value">{}</span>
335 </div>
336 <div class="info-item">
337 <span class="info-label">Path:</span>
338 <span class="info-value">{}</span>
339 </div>
340 <div class="info-item">
341 <span class="info-label">Port:</span>
342 <span class="info-value">{}</span>
343 </div>
344 </div>
345
346 <div class="footer">
347 <p>API Endpoints: <a href="/api/health">/api/health</a> • <a href="/api/info">/api/info</a></p>
348 <p style="margin-top: 8px;">Intent-Engine v{} • <a href="https://github.com/wayfind/intent-engine" target="_blank">GitHub</a></p>
349 </div>
350 </div>
351</body>
352</html>
353"#,
354 project.project_name,
355 project.project_name,
356 project.project_path.display(),
357 state.port,
358 env!("CARGO_PKG_VERSION")
359 );
360
361 Html(html)
362}
363
364async fn health_handler() -> Json<HealthResponse> {
366 Json(HealthResponse {
367 status: "healthy".to_string(),
368 service: "intent-engine-dashboard".to_string(),
369 version: env!("CARGO_PKG_VERSION").to_string(),
370 })
371}
372
373async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
376 let project = state.current_project.read().await;
377
378 let projects = state
380 .ws_state
381 .get_online_projects_with_current(
382 &project.project_name,
383 &project.project_path,
384 &project.db_path,
385 state.port,
386 )
387 .await;
388
389 let current_project = projects.first().expect("Current project must exist");
391
392 Json(ProjectInfo {
393 name: current_project.name.clone(),
394 path: current_project.path.clone(),
395 database: current_project.db_path.clone(),
396 port: state.port,
397 is_online: current_project.is_online,
398 mcp_connected: current_project.mcp_connected,
399 })
400}
401
402async fn not_found_handler() -> impl IntoResponse {
404 (
405 StatusCode::NOT_FOUND,
406 Json(serde_json::json!({
407 "error": "Not found",
408 "code": "NOT_FOUND"
409 })),
410 )
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_health_response_serialization() {
419 let response = HealthResponse {
420 status: "healthy".to_string(),
421 service: "test".to_string(),
422 version: "1.0.0".to_string(),
423 };
424
425 let json = serde_json::to_string(&response).unwrap();
426 assert!(json.contains("healthy"));
427 assert!(json.contains("test"));
428 }
429
430 #[test]
431 fn test_project_info_serialization() {
432 let info = ProjectInfo {
433 name: "test-project".to_string(),
434 path: "/path/to/project".to_string(),
435 database: "/path/to/db".to_string(),
436 port: 11391,
437 is_online: true,
438 mcp_connected: false,
439 };
440
441 let json = serde_json::to_string(&info).unwrap();
442 assert!(json.contains("test-project"));
443 assert!(json.contains("11391"));
444 }
445}