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