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