intent_engine/cli_handlers/
dashboard.rs1use crate::cli::DashboardCommands;
2use crate::error::{IntentError, Result};
3use crate::project::ProjectContext;
4
5const DASHBOARD_PORT: u16 = 11391;
7
8async fn check_dashboard_health(port: u16) -> bool {
9 let health_url = format!("http://127.0.0.1:{}/api/health", port);
10
11 match reqwest::Client::builder()
12 .timeout(std::time::Duration::from_secs(2))
13 .build()
14 {
15 Ok(client) => match client.get(&health_url).send().await {
16 Ok(resp) if resp.status().is_success() => {
17 tracing::debug!("Dashboard health check passed for port {}", port);
18 true
19 },
20 Ok(resp) => {
21 tracing::debug!("Dashboard health check failed: status {}", resp.status());
22 false
23 },
24 Err(e) => {
25 tracing::debug!("Dashboard health check failed: {}", e);
26 false
27 },
28 },
29 Err(e) => {
30 tracing::error!("Failed to create HTTP client: {}", e);
31 false
32 },
33 }
34}
35
36pub async fn check_dashboard_status() -> serde_json::Value {
38 use serde_json::json;
39
40 let dashboard_url = format!("http://127.0.0.1:{}", DASHBOARD_PORT);
41
42 if check_dashboard_health(DASHBOARD_PORT).await {
43 json!({
44 "check": "Dashboard",
45 "status": "✓ PASS",
46 "details": {
47 "url": dashboard_url,
48 "status": "running",
49 "access": format!("Visit {} in your browser", dashboard_url)
50 }
51 })
52 } else {
53 json!({
54 "check": "Dashboard",
55 "status": "⚠ WARNING",
56 "details": {
57 "status": "not running",
58 "message": "Dashboard is not running. Start it with 'ie dashboard start'",
59 "command": "ie dashboard start"
60 }
61 })
62 }
63}
64
65pub async fn check_mcp_connections() -> serde_json::Value {
67 use serde_json::json;
68
69 if !check_dashboard_health(DASHBOARD_PORT).await {
70 return json!({
71 "check": "MCP Connections",
72 "status": "⚠ WARNING",
73 "details": {
74 "count": 0,
75 "message": "Dashboard not running - cannot query connections",
76 "command": "ie dashboard start"
77 }
78 });
79 }
80
81 let url = format!("http://127.0.0.1:{}/api/projects", DASHBOARD_PORT);
83 let client = match reqwest::Client::builder()
84 .timeout(std::time::Duration::from_secs(2))
85 .build()
86 {
87 Ok(c) => c,
88 Err(e) => {
89 return json!({
90 "check": "MCP Connections",
91 "status": "✗ FAIL",
92 "details": {
93 "error": format!("Failed to create HTTP client: {}", e)
94 }
95 });
96 },
97 };
98
99 match client.get(&url).send().await {
100 Ok(resp) if resp.status().is_success() => {
101 if let Ok(data) = resp.json::<serde_json::Value>().await {
102 let empty_vec = vec![];
103 let projects = data["projects"].as_array().unwrap_or(&empty_vec);
104 let mcp_count = projects
105 .iter()
106 .filter(|p| p["mcp_connected"].as_bool().unwrap_or(false))
107 .count();
108
109 json!({
110 "check": "MCP Connections",
111 "status": if mcp_count > 0 { "✓ PASS" } else { "⚠ WARNING" },
112 "details": {
113 "count": mcp_count,
114 "message": if mcp_count > 0 {
115 format!("{} MCP client(s) connected", mcp_count)
116 } else {
117 "No MCP clients connected".to_string()
118 }
119 }
120 })
121 } else {
122 json!({
123 "check": "MCP Connections",
124 "status": "✗ FAIL",
125 "details": {"error": "Failed to parse response"}
126 })
127 }
128 },
129 _ => json!({
130 "check": "MCP Connections",
131 "status": "⚠ WARNING",
132 "details": {"count": 0, "message": "Dashboard not responding"}
133 }),
134 }
135}
136
137pub async fn handle_dashboard_command(dashboard_cmd: DashboardCommands) -> Result<()> {
138 use crate::dashboard::daemon;
139
140 match dashboard_cmd {
141 DashboardCommands::Start {
142 port,
143 foreground,
144 browser,
145 } => {
146 let project_ctx = ProjectContext::load_or_init().await?;
148 let project_path = project_ctx.root.clone();
149 let db_path = project_ctx.db_path.clone();
150 let project_name = project_path
151 .file_name()
152 .and_then(|n| n.to_str())
153 .unwrap_or("unknown")
154 .to_string();
155
156 let allocated_port = port.unwrap_or(11391);
158
159 if let Ok(Some(existing_pid)) = daemon::read_pid_file(allocated_port) {
161 if check_dashboard_health(allocated_port).await {
162 println!("Dashboard already running for this project:");
163 println!(" Port: {}", allocated_port);
164 println!(" PID: {}", existing_pid);
165 println!(" URL: http://127.0.0.1:{}", allocated_port);
166 return Ok(());
167 } else {
168 tracing::info!(
170 "Cleaning up stale Dashboard PID file for port {}",
171 allocated_port
172 );
173 daemon::delete_pid_file(allocated_port).ok();
174 }
175 }
176
177 if std::net::TcpListener::bind(("127.0.0.1", allocated_port)).is_err() {
179 return Err(IntentError::InvalidInput(format!(
180 "Port {} is already in use",
181 allocated_port
182 )));
183 }
184
185 println!("Dashboard starting for project: {}", project_name);
186 println!(" Port: {}", allocated_port);
187 println!(" URL: http://127.0.0.1:{}", allocated_port);
188 println!(
189 " Mode: {}",
190 if foreground { "foreground" } else { "daemon" }
191 );
192
193 if foreground {
194 use crate::dashboard::server::DashboardServer;
196
197 let server =
198 DashboardServer::new(allocated_port, project_path.clone(), db_path.clone())
199 .await?;
200
201 println!(
202 "\n🚀 Dashboard server running at http://127.0.0.1:{}",
203 allocated_port
204 );
205 println!(" Press Ctrl+C to stop\n");
206
207 if browser {
209 let dashboard_url = format!("http://127.0.0.1:{}", allocated_port);
210 tokio::time::sleep(tokio::time::Duration::from_millis(800)).await;
211 println!("🌐 Opening dashboard in browser...");
212 if let Err(e) = open::that(&dashboard_url) {
213 eprintln!("⚠️ Could not open browser automatically: {}", e);
214 eprintln!(" Please manually visit: {}", dashboard_url);
215 }
216 println!();
217 }
218
219 let current_pid = std::process::id();
221 daemon::write_pid_file(allocated_port, current_pid)?;
222
223 let result = server.run().await;
225
226 daemon::delete_pid_file(allocated_port).ok();
228
229 result.map_err(IntentError::OtherError)?;
230 Ok(())
231 } else {
232 println!("\n🚀 Dashboard server starting in background...");
234
235 let current_exe = std::env::current_exe()?;
237
238 #[cfg(unix)]
240 let mut cmd = {
241 let mut cmd = std::process::Command::new("setsid");
242 cmd.arg(current_exe)
243 .arg("dashboard")
244 .arg("start")
245 .arg("--foreground")
246 .arg("--port")
247 .arg(allocated_port.to_string());
248
249 if browser {
251 cmd.arg("--browser");
252 }
253
254 cmd
255 };
256
257 #[cfg(not(unix))]
259 let mut cmd = {
260 let mut cmd = std::process::Command::new(current_exe);
261 cmd.arg("dashboard")
262 .arg("start")
263 .arg("--foreground")
264 .arg("--port")
265 .arg(allocated_port.to_string());
266
267 if browser {
269 cmd.arg("--browser");
270 }
271
272 cmd
273 };
274
275 let child = cmd
276 .current_dir(&project_path)
277 .stdin(std::process::Stdio::null())
278 .stdout(std::process::Stdio::null())
279 .stderr(std::process::Stdio::null())
280 .spawn()?;
281
282 let _setsid_pid = child.id();
285
286 tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
288
289 #[cfg(unix)]
291 let pid = {
292 use std::process::Command;
293
294 let output = Command::new("pgrep")
295 .args([
296 "-f",
297 &format!("ie dashboard start --foreground --port {}", allocated_port),
298 ])
299 .output()
300 .ok()
301 .and_then(|o| String::from_utf8(o.stdout).ok())
302 .and_then(|s| s.trim().parse::<u32>().ok());
303
304 match output {
305 Some(pid) => pid,
306 None => {
307 _setsid_pid
309 },
310 }
311 };
312
313 #[cfg(not(unix))]
314 let pid = _setsid_pid;
315
316 daemon::write_pid_file(allocated_port, pid)?;
318
319 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
321
322 if check_dashboard_health(allocated_port).await {
323 let dashboard_url = format!("http://127.0.0.1:{}", allocated_port);
324 println!("✓ Dashboard server started successfully");
325 println!(" PID: {}", pid);
326 println!(" URL: {}", dashboard_url);
327
328 if browser {
330 println!("\n🌐 Opening dashboard in browser...");
331 if let Err(e) = open::that(&dashboard_url) {
332 eprintln!("⚠️ Could not open browser automatically: {}", e);
333 eprintln!(" Please manually visit: {}", dashboard_url);
334 }
335 }
336
337 println!("\nUse 'ie dashboard stop' to stop the server");
338 } else {
339 daemon::delete_pid_file(allocated_port).ok();
341 return Err(IntentError::InvalidInput(
342 "Failed to start dashboard server".to_string(),
343 ));
344 }
345
346 Ok(())
347 }
348 },
349
350 DashboardCommands::Stop { all } => {
351 let port = 11391;
353
354 if all {
355 println!(
356 "⚠️ Note: Single Dashboard mode - stopping Dashboard on port {}",
357 port
358 );
359 }
360
361 match daemon::read_pid_file(port) {
363 Ok(Some(pid)) => {
364 if check_dashboard_health(port).await {
366 daemon::stop_process(pid)?;
368 println!("✓ Stopped dashboard (PID: {})", pid);
369 } else {
370 println!(
372 "⚠️ Dashboard not responding (stale PID: {}), cleaning up",
373 pid
374 );
375 }
376 daemon::delete_pid_file(port).ok();
377 },
378 Ok(None) => {
379 if check_dashboard_health(port).await {
381 println!(
382 "⚠️ Dashboard running but no PID file found (port {})",
383 port
384 );
385 println!(
386 " Try killing the process manually or use: lsof -ti:{} | xargs kill",
387 port
388 );
389 return Err(IntentError::InvalidInput(
390 "Dashboard running without PID file".to_string(),
391 ));
392 } else {
393 println!("Dashboard not running");
394 }
395 },
396 Err(e) => {
397 tracing::debug!("Error reading PID file: {}", e);
398 println!("Dashboard not running");
399 },
400 }
401
402 Ok(())
403 },
404
405 DashboardCommands::Status { all } => {
406 let port = 11391;
408
409 if all {
410 println!(
411 "⚠️ Note: Single Dashboard mode - showing status for port {}",
412 port
413 );
414 }
415
416 match daemon::read_pid_file(port) {
418 Ok(Some(pid)) => {
419 if check_dashboard_health(port).await {
421 let url = format!("http://127.0.0.1:{}/api/info", port);
423 match reqwest::get(&url).await {
424 Ok(response) if response.status().is_success() => {
425 #[derive(serde::Deserialize)]
426 struct InfoResponse {
427 data: serde_json::Value,
428 }
429 if let Ok(info) = response.json::<InfoResponse>().await {
430 println!("Dashboard status:");
431 println!(" Status: ✓ Running (PID: {})", pid);
432 println!(" Port: {}", port);
433 println!(" URL: http://127.0.0.1:{}", port);
434 if let Some(project_name) = info.data.get("project_name") {
435 println!(" Project: {}", project_name);
436 }
437 if let Some(project_path) = info.data.get("project_path") {
438 println!(" Path: {}", project_path);
439 }
440 } else {
441 println!("Dashboard status:");
442 println!(" Status: ✓ Running (PID: {})", pid);
443 println!(" Port: {}", port);
444 println!(" URL: http://127.0.0.1:{}", port);
445 }
446 },
447 _ => {
448 println!("Dashboard status:");
449 println!(" Status: ✓ Running (PID: {})", pid);
450 println!(" Port: {}", port);
451 println!(" URL: http://127.0.0.1:{}", port);
452 },
453 }
454 } else {
455 println!("Dashboard status:");
456 println!(" Status: ✗ Stopped (stale PID: {})", pid);
457 println!(" Port: {}", port);
458 }
459 },
460 Ok(None) => {
461 println!("Dashboard status:");
462 println!(" Status: ✗ Not running");
463 println!(" Port: {}", port);
464 },
465 Err(e) => {
466 tracing::debug!("Error reading PID file: {}", e);
467 println!("Dashboard status:");
468 println!(" Status: ✗ Not running");
469 println!(" Port: {}", port);
470 },
471 }
472
473 Ok(())
474 },
475
476 DashboardCommands::List => {
477 let port = 11391;
479
480 if !check_dashboard_health(port).await {
482 println!("Dashboard not running");
483 println!("\nUse 'ie dashboard start' to start the Dashboard");
484 return Ok(());
485 }
486
487 let pid = daemon::read_pid_file(port).ok().flatten();
489
490 let url = format!("http://127.0.0.1:{}/api/projects", port);
492 match reqwest::get(&url).await {
493 Ok(response) if response.status().is_success() => {
494 #[derive(serde::Deserialize)]
495 struct ApiResponse {
496 data: Vec<serde_json::Value>,
497 }
498 match response.json::<ApiResponse>().await {
499 Ok(api_response) => {
500 if api_response.data.is_empty() {
501 println!("Dashboard running but no projects registered");
502 if let Some(pid) = pid {
503 println!(" PID: {}", pid);
504 }
505 println!(" Port: {}", port);
506 println!(" URL: http://127.0.0.1:{}", port);
507 return Ok(());
508 }
509
510 println!("Dashboard projects:");
511 println!("{:<30} {:<8} {:<15} MCP", "PROJECT", "PORT", "STATUS");
512 println!("{}", "-".repeat(80));
513
514 for project in api_response.data {
515 let name = project
516 .get("name")
517 .and_then(|v| v.as_str())
518 .unwrap_or("unknown");
519 let mcp_connected = project
520 .get("mcp_connected")
521 .and_then(|v| v.as_bool())
522 .unwrap_or(false);
523 let mcp_status = if mcp_connected {
524 "✓ Connected"
525 } else {
526 "✗ Disconnected"
527 };
528
529 println!(
530 "{:<30} {:<8} {:<15} {}",
531 name, port, "Running", mcp_status
532 );
533
534 if let Some(path) = project.get("path").and_then(|v| v.as_str()) {
535 println!(" Path: {}", path);
536 }
537 }
538 },
539 Err(e) => {
540 eprintln!("Failed to parse projects list: {}", e);
541 println!("Dashboard running on port {}", port);
542 if let Some(pid) = pid {
543 println!(" PID: {}", pid);
544 }
545 },
546 }
547 },
548 Ok(response) => {
549 eprintln!("Failed to get projects list: HTTP {}", response.status());
550 println!("Dashboard running on port {}", port);
551 if let Some(pid) = pid {
552 println!(" PID: {}", pid);
553 }
554 },
555 Err(e) => {
556 eprintln!("Failed to connect to Dashboard API: {}", e);
557 println!("Dashboard may not be running properly on port {}", port);
558 },
559 }
560
561 Ok(())
562 },
563
564 DashboardCommands::Open => {
565 let port = 11391;
567
568 if !check_dashboard_health(port).await {
570 eprintln!("Dashboard is not running");
571 eprintln!("Start it with: ie dashboard start");
572 return Err(IntentError::InvalidInput(
573 "Dashboard not running".to_string(),
574 ));
575 }
576
577 let url = format!("http://127.0.0.1:{}", port);
578 println!("Opening dashboard: {}", url);
579
580 daemon::open_browser(&url)?;
581
582 Ok(())
583 },
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[tokio::test]
594 async fn test_check_dashboard_status_not_running() {
595 let status = check_dashboard_status().await;
598
599 assert_eq!(status["check"], "Dashboard");
601 assert_eq!(status["status"], "⚠ WARNING");
602
603 assert_eq!(status["details"]["status"], "not running");
605 assert!(status["details"]["message"]
606 .as_str()
607 .unwrap()
608 .contains("not running"));
609 assert_eq!(status["details"]["command"], "ie dashboard start");
610 }
611
612 #[tokio::test]
615 async fn test_check_mcp_connections_dashboard_not_running() {
616 let result = check_mcp_connections().await;
617
618 assert_eq!(result["check"], "MCP Connections");
620 assert_eq!(result["status"], "⚠ WARNING");
621
622 assert_eq!(result["details"]["count"], 0);
624 assert!(result["details"]["message"]
625 .as_str()
626 .unwrap()
627 .contains("not running"));
628 assert_eq!(result["details"]["command"], "ie dashboard start");
629 }
630
631 #[test]
633 fn test_dashboard_port_constant() {
634 assert_eq!(DASHBOARD_PORT, 11391);
635 }
636
637 #[tokio::test]
640 async fn test_check_dashboard_health_invalid_port() {
641 let is_healthy = check_dashboard_health(65000).await;
643 assert!(!is_healthy);
644 }
645
646 #[tokio::test]
649 async fn test_check_dashboard_health_default_port_not_running() {
650 let is_healthy = check_dashboard_health(DASHBOARD_PORT).await;
653
654 if !is_healthy {
658 assert!(!is_healthy); }
660 }
661}