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::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16use tower_http::{
17 cors::{Any, CorsLayer},
18 trace::TraceLayer,
19};
20
21use super::websocket;
22
23#[derive(RustEmbed)]
25#[folder = "static/"]
26struct StaticAssets;
27
28#[derive(Clone, Debug)]
30pub struct ProjectInfo {
31 pub name: String,
32 pub path: PathBuf,
33 pub db_path: PathBuf,
34}
35
36#[derive(Clone)]
38pub struct AppState {
39 pub known_projects: Arc<RwLock<HashMap<PathBuf, ProjectInfo>>>,
41 pub active_project_path: Arc<RwLock<PathBuf>>,
43 pub host_project: super::websocket::ProjectInfo,
45 pub port: u16,
46 pub ws_state: super::websocket::WebSocketState,
48 pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
50}
51
52impl AppState {
53 pub async fn get_db_pool(&self, project_path: &PathBuf) -> Result<SqlitePool, String> {
55 let projects = self.known_projects.read().await;
56 if let Some(info) = projects.get(project_path) {
57 let db_url = format!("sqlite://{}", info.db_path.display());
58 SqlitePool::connect(&db_url)
59 .await
60 .map_err(|e| format!("Failed to connect to database: {}", e))
61 } else {
62 Err(format!("Project not found: {}", project_path.display()))
63 }
64 }
65
66 pub async fn get_active_db_pool(&self) -> Result<SqlitePool, String> {
68 let active_path = self.active_project_path.read().await.clone();
69 self.get_db_pool(&active_path).await
70 }
71
72 pub async fn add_project(&self, path: PathBuf) -> Result<(), String> {
74 if !path.exists() {
75 return Err(format!("Project path does not exist: {}", path.display()));
76 }
77
78 let db_path = path.join(".intent-engine").join("project.db");
79 if !db_path.exists() {
80 return Err(format!("Database not found: {}", db_path.display()));
81 }
82
83 let name = path
84 .file_name()
85 .and_then(|n| n.to_str())
86 .unwrap_or("unknown")
87 .to_string();
88
89 let info = ProjectInfo {
90 name,
91 path: path.clone(),
92 db_path,
93 };
94
95 let mut projects = self.known_projects.write().await;
96 projects.insert(path, info);
97 Ok(())
98 }
99
100 pub async fn get_active_project(&self) -> Option<ProjectInfo> {
102 let active_path = self.active_project_path.read().await;
103 let projects = self.known_projects.read().await;
104 projects.get(&*active_path).cloned()
105 }
106
107 pub async fn switch_active_project(&self, path: PathBuf) -> Result<(), String> {
109 let projects = self.known_projects.read().await;
110 if !projects.contains_key(&path) {
111 return Err(format!("Project not registered: {}", path.display()));
112 }
113 drop(projects);
114
115 let mut active = self.active_project_path.write().await;
116 *active = path;
117 Ok(())
118 }
119
120 pub async fn remove_project(&self, path: &PathBuf) -> Result<(), String> {
122 if path.as_path() == std::path::Path::new(&self.host_project.path) {
124 return Err("Cannot remove the host project".to_string());
125 }
126
127 let mut projects = self.known_projects.write().await;
129 projects.remove(path);
130
131 let path_str = path.to_string_lossy().to_string();
133 crate::global_projects::remove_project(&path_str);
134
135 Ok(())
136 }
137
138 pub async fn get_active_project_context(&self) -> Result<(SqlitePool, String), String> {
141 let db_pool = self.get_active_db_pool().await?;
142 let project_path = self
143 .get_active_project()
144 .await
145 .map(|p| p.path.to_string_lossy().to_string())
146 .unwrap_or_default();
147 Ok((db_pool, project_path))
148 }
149}
150
151pub struct DashboardServer {
153 port: u16,
154 db_path: PathBuf,
155 project_name: String,
156 project_path: PathBuf,
157}
158
159#[derive(Serialize)]
161struct HealthResponse {
162 status: String,
163 service: String,
164 version: String,
165}
166
167#[derive(Serialize)]
169struct ProjectInfoResponse {
170 name: String,
171 path: String,
172 database: String,
173 port: u16,
174 is_online: bool,
175 mcp_connected: bool,
176}
177
178impl DashboardServer {
179 pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
181 let project_name = project_path
183 .file_name()
184 .and_then(|n| n.to_str())
185 .unwrap_or("unknown")
186 .to_string();
187
188 if !db_path.exists() {
189 anyhow::bail!(
190 "Database not found at {}. Is this an Intent-Engine project?",
191 db_path.display()
192 );
193 }
194
195 Ok(Self {
196 port,
197 db_path,
198 project_name,
199 project_path,
200 })
201 }
202
203 pub async fn run(self) -> Result<()> {
205 let mut known_projects = HashMap::new();
207 let host_info = ProjectInfo {
208 name: self.project_name.clone(),
209 path: self.project_path.clone(),
210 db_path: self.db_path.clone(),
211 };
212 let host_key = self
214 .project_path
215 .canonicalize()
216 .unwrap_or_else(|_| self.project_path.clone());
217 known_projects.insert(host_key, host_info);
218
219 let registry = crate::global_projects::ProjectsRegistry::load();
221 for entry in registry.projects {
222 let path = PathBuf::from(&entry.path);
223 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
225 if known_projects.contains_key(&canonical_path) {
227 continue;
228 }
229 let db_path = path.join(".intent-engine").join("project.db");
230 if db_path.exists() {
231 let name = entry.name.unwrap_or_else(|| {
232 path.file_name()
233 .and_then(|n| n.to_str())
234 .unwrap_or("unknown")
235 .to_string()
236 });
237 known_projects.insert(
238 canonical_path,
239 ProjectInfo {
240 name,
241 path,
242 db_path,
243 },
244 );
245 }
246 }
247 tracing::info!(
248 "Loaded {} projects from global registry",
249 known_projects.len()
250 );
251
252 let ws_state = websocket::WebSocketState::new();
254
255 let host_project_info = websocket::ProjectInfo {
256 name: self.project_name.clone(),
257 path: self.project_path.display().to_string(),
258 db_path: self.db_path.display().to_string(),
259 agent: None,
260 mcp_connected: false, is_online: true, };
263
264 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
266
267 let state = AppState {
268 known_projects: Arc::new(RwLock::new(known_projects)),
269 active_project_path: Arc::new(RwLock::new(self.project_path.clone())),
270 host_project: host_project_info,
271 port: self.port,
272 ws_state,
273 shutdown_tx: Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))),
274 };
275
276 let app = create_router(state);
278
279 let addr = format!("0.0.0.0:{}", self.port);
282 let listener = tokio::net::TcpListener::bind(&addr)
283 .await
284 .with_context(|| format!("Failed to bind to {}", addr))?;
285
286 tracing::info!(address = %addr, "Dashboard server listening");
287 tracing::warn!(
288 port = self.port,
289 "⚠️ Dashboard is accessible from external IPs"
290 );
291 tracing::info!(project = %self.project_name, "Project loaded");
292 tracing::info!(db_path = %self.db_path.display(), "Database path");
293
294 #[cfg(unix)]
296 {
297 unsafe {
298 libc::signal(libc::SIGHUP, libc::SIG_IGN);
299 }
300 }
301
302 tracing::info!("Starting server with graceful shutdown support");
304 axum::serve(listener, app)
305 .with_graceful_shutdown(async {
306 shutdown_rx.await.ok();
307 tracing::info!("Shutdown signal received, initiating graceful shutdown");
308 })
309 .await
310 .context("Server error")?;
311
312 tracing::info!("Dashboard server shut down successfully");
313 Ok(())
314 }
315}
316
317fn create_router(state: AppState) -> Router {
319 use super::routes;
320
321 let api_routes = Router::new()
323 .route("/health", get(health_handler))
324 .route("/info", get(info_handler))
325 .merge(routes::api_routes());
326
327 Router::new()
329 .route("/", get(serve_index))
331 .route("/static/*path", get(serve_static))
333 .route("/assets/*path", get(serve_assets))
335 .nest("/api", api_routes)
337 .route("/ws/mcp", get(websocket::handle_mcp_websocket))
339 .route("/ws/ui", get(websocket::handle_ui_websocket))
340 .fallback(not_found_handler)
342 .with_state(state)
344 .layer(
346 CorsLayer::new()
347 .allow_origin(Any)
348 .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
349 .allow_headers(Any),
350 )
351 .layer(TraceLayer::new_for_http())
352}
353
354async fn serve_index() -> impl IntoResponse {
356 match StaticAssets::get("index.html") {
357 Some(content) => {
358 let body = content.data.to_vec();
359 Response::builder()
360 .status(StatusCode::OK)
361 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
362 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
363 .header(header::PRAGMA, "no-cache")
364 .header(header::EXPIRES, "0")
365 .body(body.into())
366 .unwrap()
367 },
368 None => (
369 StatusCode::INTERNAL_SERVER_ERROR,
370 Html("<h1>Error: index.html not found</h1>".to_string()),
371 )
372 .into_response(),
373 }
374}
375
376async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
378 let path = path.trim_start_matches('/');
380
381 match StaticAssets::get(path) {
382 Some(content) => {
383 let mime = mime_guess::from_path(path).first_or_octet_stream();
384 let body = content.data.to_vec();
385 Response::builder()
386 .status(StatusCode::OK)
387 .header(header::CONTENT_TYPE, mime.as_ref())
388 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
389 .header(header::PRAGMA, "no-cache")
390 .header(header::EXPIRES, "0")
391 .body(body.into())
392 .unwrap()
393 },
394 None => (
395 StatusCode::NOT_FOUND,
396 Json(serde_json::json!({
397 "error": "File not found",
398 "code": "NOT_FOUND",
399 "path": path
400 })),
401 )
402 .into_response(),
403 }
404}
405
406async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
408 let path = path.trim_start_matches('/');
410 let full_path = format!("assets/{}", path);
414
415 match StaticAssets::get(&full_path) {
416 Some(content) => {
417 let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
418 let body = content.data.to_vec();
419 Response::builder()
420 .status(StatusCode::OK)
421 .header(header::CONTENT_TYPE, mime.as_ref())
422 .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
423 .header(header::PRAGMA, "no-cache")
424 .header(header::EXPIRES, "0")
425 .body(body.into())
426 .unwrap()
427 },
428 None => (
429 StatusCode::NOT_FOUND,
430 Json(serde_json::json!({
431 "error": "Asset not found",
432 "code": "NOT_FOUND",
433 "path": full_path
434 })),
435 )
436 .into_response(),
437 }
438}
439
440async fn health_handler() -> Json<HealthResponse> {
442 Json(HealthResponse {
443 status: "healthy".to_string(),
444 service: "intent-engine-dashboard".to_string(),
445 version: env!("CARGO_PKG_VERSION").to_string(),
446 })
447}
448
449async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfoResponse> {
452 let active_project = state.get_active_project().await;
453
454 match active_project {
455 Some(project) => {
456 let projects = state
458 .ws_state
459 .get_online_projects_with_current(
460 &project.name,
461 &project.path,
462 &project.db_path,
463 &state.host_project,
464 state.port,
465 )
466 .await;
467
468 let current_project = projects.first().expect("Current project must exist");
470
471 Json(ProjectInfoResponse {
472 name: current_project.name.clone(),
473 path: current_project.path.clone(),
474 database: current_project.db_path.clone(),
475 port: state.port,
476 is_online: current_project.is_online,
477 mcp_connected: current_project.mcp_connected,
478 })
479 },
480 None => Json(ProjectInfoResponse {
481 name: "unknown".to_string(),
482 path: "".to_string(),
483 database: "".to_string(),
484 port: state.port,
485 is_online: false,
486 mcp_connected: false,
487 }),
488 }
489}
490
491async fn not_found_handler() -> impl IntoResponse {
493 (
494 StatusCode::NOT_FOUND,
495 Json(serde_json::json!({
496 "error": "Not found",
497 "code": "NOT_FOUND"
498 })),
499 )
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn test_health_response_serialization() {
508 let response = HealthResponse {
509 status: "healthy".to_string(),
510 service: "test".to_string(),
511 version: "1.0.0".to_string(),
512 };
513
514 let json = serde_json::to_string(&response).unwrap();
515 assert!(json.contains("healthy"));
516 assert!(json.contains("test"));
517 }
518
519 #[test]
520 fn test_project_info_response_serialization() {
521 let info = ProjectInfoResponse {
522 name: "test-project".to_string(),
523 path: "/path/to/project".to_string(),
524 database: "/path/to/db".to_string(),
525 port: 11391,
526 is_online: true,
527 mcp_connected: false,
528 };
529
530 let json = serde_json::to_string(&info).unwrap();
531 assert!(json.contains("test-project"));
532 assert!(json.contains("11391"));
533 }
534}