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