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 host_project: super::websocket::ProjectInfo,
43 pub port: u16,
44 pub ws_state: super::websocket::WebSocketState,
46}
47
48pub struct DashboardServer {
50 port: u16,
51 db_path: PathBuf,
52 project_name: String,
53 project_path: PathBuf,
54}
55
56#[derive(Serialize)]
58struct HealthResponse {
59 status: String,
60 service: String,
61 version: String,
62}
63
64#[derive(Serialize)]
66struct ProjectInfo {
67 name: String,
68 path: String,
69 database: String,
70 port: u16,
71 is_online: bool,
72 mcp_connected: bool,
73}
74
75impl DashboardServer {
76 pub async fn new(port: u16, project_path: PathBuf, db_path: PathBuf) -> Result<Self> {
78 let project_name = project_path
80 .file_name()
81 .and_then(|n| n.to_str())
82 .unwrap_or("unknown")
83 .to_string();
84
85 if !db_path.exists() {
86 anyhow::bail!(
87 "Database not found at {}. Is this an Intent-Engine project?",
88 db_path.display()
89 );
90 }
91
92 Ok(Self {
93 port,
94 db_path,
95 project_name,
96 project_path,
97 })
98 }
99
100 pub async fn run(self) -> Result<()> {
102 let db_url = format!("sqlite://{}", self.db_path.display());
104 let db_pool = SqlitePool::connect(&db_url)
105 .await
106 .context("Failed to connect to database")?;
107
108 let project_context = ProjectContext {
110 db_pool,
111 project_name: self.project_name.clone(),
112 project_path: self.project_path.clone(),
113 db_path: self.db_path.clone(),
114 };
115
116 let ws_state = websocket::WebSocketState::new();
118
119 let host_project_info = websocket::ProjectInfo {
120 name: self.project_name.clone(),
121 path: self.project_path.display().to_string(),
122 db_path: self.db_path.display().to_string(),
123 agent: None,
124 mcp_connected: false, is_online: true, };
127
128 let state = AppState {
129 current_project: Arc::new(RwLock::new(project_context)),
130 host_project: host_project_info,
131 port: self.port,
132 ws_state,
133 };
134
135 let app = create_router(state);
137
138 let addr = format!("0.0.0.0:{}", self.port);
141 let listener = tokio::net::TcpListener::bind(&addr)
142 .await
143 .with_context(|| format!("Failed to bind to {}", addr))?;
144
145 tracing::info!("Dashboard server listening on {}", addr);
146 tracing::warn!(
147 "⚠️ Dashboard is accessible from external IPs. Access via http://localhost:{} or http://<your-ip>:{}",
148 self.port, self.port
149 );
150 tracing::info!("Project: {}", self.project_name);
151 tracing::info!("Database: {}", self.db_path.display());
152
153 #[cfg(unix)]
155 {
156 unsafe {
157 libc::signal(libc::SIGHUP, libc::SIG_IGN);
158 }
159 }
160
161 axum::serve(listener, app).await.context("Server error")?;
163
164 Ok(())
165 }
166}
167
168fn create_router(state: AppState) -> Router {
170 use super::routes;
171
172 let api_routes = Router::new()
174 .route("/health", get(health_handler))
175 .route("/info", get(info_handler))
176 .merge(routes::api_routes());
177
178 Router::new()
180 .route("/", get(serve_index))
182 .route("/static/*path", get(serve_static))
184 .route("/assets/*path", get(serve_assets))
186 .nest("/api", api_routes)
188 .route("/ws/mcp", get(websocket::handle_mcp_websocket))
190 .route("/ws/ui", get(websocket::handle_ui_websocket))
191 .fallback(not_found_handler)
193 .with_state(state)
195 .layer(
197 CorsLayer::new()
198 .allow_origin(Any)
199 .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
200 .allow_headers(Any),
201 )
202 .layer(TraceLayer::new_for_http())
203}
204
205async fn serve_index() -> impl IntoResponse {
207 match StaticAssets::get("index.html") {
208 Some(content) => {
209 let body = content.data.to_vec();
210 Response::builder()
211 .status(StatusCode::OK)
212 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
213 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
214 .header(header::PRAGMA, "no-cache")
215 .header(header::EXPIRES, "0")
216 .body(body.into())
217 .unwrap()
218 },
219 None => (
220 StatusCode::INTERNAL_SERVER_ERROR,
221 Html("<h1>Error: index.html not found</h1>".to_string()),
222 )
223 .into_response(),
224 }
225}
226
227async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
229 let path = path.trim_start_matches('/');
231
232 match StaticAssets::get(path) {
233 Some(content) => {
234 let mime = mime_guess::from_path(path).first_or_octet_stream();
235 let body = content.data.to_vec();
236 Response::builder()
237 .status(StatusCode::OK)
238 .header(header::CONTENT_TYPE, mime.as_ref())
239 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
240 .header(header::PRAGMA, "no-cache")
241 .header(header::EXPIRES, "0")
242 .body(body.into())
243 .unwrap()
244 },
245 None => (
246 StatusCode::NOT_FOUND,
247 Json(serde_json::json!({
248 "error": "File not found",
249 "code": "NOT_FOUND",
250 "path": path
251 })),
252 )
253 .into_response(),
254 }
255}
256
257async fn serve_assets(Path(path): Path<String>) -> impl IntoResponse {
259 let path = path.trim_start_matches('/');
261 let full_path = format!("assets/{}", path);
265
266 match StaticAssets::get(&full_path) {
267 Some(content) => {
268 let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
269 let body = content.data.to_vec();
270 Response::builder()
271 .status(StatusCode::OK)
272 .header(header::CONTENT_TYPE, mime.as_ref())
273 .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
274 .header(header::PRAGMA, "no-cache")
275 .header(header::EXPIRES, "0")
276 .body(body.into())
277 .unwrap()
278 },
279 None => (
280 StatusCode::NOT_FOUND,
281 Json(serde_json::json!({
282 "error": "Asset not found",
283 "code": "NOT_FOUND",
284 "path": full_path
285 })),
286 )
287 .into_response(),
288 }
289}
290
291async fn health_handler() -> Json<HealthResponse> {
293 Json(HealthResponse {
294 status: "healthy".to_string(),
295 service: "intent-engine-dashboard".to_string(),
296 version: env!("CARGO_PKG_VERSION").to_string(),
297 })
298}
299
300async fn info_handler(State(state): State<AppState>) -> Json<ProjectInfo> {
303 let project = state.current_project.read().await;
304
305 let projects = state
307 .ws_state
308 .get_online_projects_with_current(
309 &project.project_name,
310 &project.project_path,
311 &project.db_path,
312 &state.host_project,
313 state.port,
314 )
315 .await;
316
317 let current_project = projects.first().expect("Current project must exist");
319
320 Json(ProjectInfo {
321 name: current_project.name.clone(),
322 path: current_project.path.clone(),
323 database: current_project.db_path.clone(),
324 port: state.port,
325 is_online: current_project.is_online,
326 mcp_connected: current_project.mcp_connected,
327 })
328}
329
330async fn not_found_handler() -> impl IntoResponse {
332 (
333 StatusCode::NOT_FOUND,
334 Json(serde_json::json!({
335 "error": "Not found",
336 "code": "NOT_FOUND"
337 })),
338 )
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_health_response_serialization() {
347 let response = HealthResponse {
348 status: "healthy".to_string(),
349 service: "test".to_string(),
350 version: "1.0.0".to_string(),
351 };
352
353 let json = serde_json::to_string(&response).unwrap();
354 assert!(json.contains("healthy"));
355 assert!(json.contains("test"));
356 }
357
358 #[test]
359 fn test_project_info_serialization() {
360 let info = ProjectInfo {
361 name: "test-project".to_string(),
362 path: "/path/to/project".to_string(),
363 database: "/path/to/db".to_string(),
364 port: 11391,
365 is_online: true,
366 mcp_connected: false,
367 };
368
369 let json = serde_json::to_string(&info).unwrap();
370 assert!(json.contains("test-project"));
371 assert!(json.contains("11391"));
372 }
373}