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