lean_ctx/dashboard/
mod.rs1use tokio::io::{AsyncReadExt, AsyncWriteExt};
2use tokio::net::TcpListener;
3
4const DEFAULT_PORT: u16 = 3333;
5const DASHBOARD_HTML: &str = include_str!("dashboard.html");
6
7pub async fn start(port: Option<u16>) {
8 let port = port.unwrap_or_else(|| {
9 std::env::var("LEAN_CTX_PORT")
10 .ok()
11 .and_then(|p| p.parse().ok())
12 .unwrap_or(DEFAULT_PORT)
13 });
14
15 let addr = format!("127.0.0.1:{port}");
16
17 let listener = match TcpListener::bind(&addr).await {
18 Ok(l) => l,
19 Err(e) => {
20 eprintln!("Failed to bind to {addr}: {e}");
21 std::process::exit(1);
22 }
23 };
24
25 let stats_path = dirs::home_dir()
26 .map(|h| h.join(".lean-ctx/stats.json"))
27 .map(|p| p.display().to_string())
28 .unwrap_or_else(|| "~/.lean-ctx/stats.json".to_string());
29
30 println!("\n lean-ctx dashboard → http://localhost:{port}");
31 println!(" Stats file: {stats_path}");
32 println!(" Press Ctrl+C to stop\n");
33
34 #[cfg(target_os = "macos")]
35 {
36 let _ = std::process::Command::new("open")
37 .arg(format!("http://localhost:{port}"))
38 .spawn();
39 }
40
41 #[cfg(target_os = "linux")]
42 {
43 let _ = std::process::Command::new("xdg-open")
44 .arg(format!("http://localhost:{port}"))
45 .spawn();
46 }
47
48 #[cfg(target_os = "windows")]
49 {
50 let _ = std::process::Command::new("cmd")
51 .args(["/C", "start", &format!("http://localhost:{port}")])
52 .spawn();
53 }
54
55 loop {
56 if let Ok((stream, _)) = listener.accept().await {
57 tokio::spawn(handle_request(stream));
58 }
59 }
60}
61
62async fn handle_request(mut stream: tokio::net::TcpStream) {
63 let mut buf = vec![0u8; 4096];
64 let n = match stream.read(&mut buf).await {
65 Ok(n) if n > 0 => n,
66 _ => return,
67 };
68
69 let request = String::from_utf8_lossy(&buf[..n]);
70 let path = request
71 .lines()
72 .next()
73 .and_then(|line| line.split_whitespace().nth(1))
74 .unwrap_or("/");
75
76 let (status, content_type, body) = match path {
77 "/api/stats" => {
78 let store = crate::core::stats::load();
79 let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
80 ("200 OK", "application/json", json)
81 }
82 "/api/mcp" => {
83 let mcp_path = dirs::home_dir()
84 .map(|h| h.join(".lean-ctx").join("mcp-live.json"))
85 .unwrap_or_default();
86 let json = std::fs::read_to_string(&mcp_path).unwrap_or_else(|_| "{}".to_string());
87 ("200 OK", "application/json", json)
88 }
89 "/api/agents" => {
90 let registry = crate::core::agents::AgentRegistry::load_or_create();
91 let json = serde_json::to_string(®istry).unwrap_or_else(|_| "{}".to_string());
92 ("200 OK", "application/json", json)
93 }
94 "/api/knowledge" => {
95 let project_root = detect_project_root_for_dashboard();
96 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
97 let json = serde_json::to_string(&knowledge).unwrap_or_else(|_| "{}".to_string());
98 ("200 OK", "application/json", json)
99 }
100 "/" | "/index.html" => (
101 "200 OK",
102 "text/html; charset=utf-8",
103 DASHBOARD_HTML.to_string(),
104 ),
105 "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
106 _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
107 };
108
109 let cache_header = if content_type.starts_with("application/json") {
110 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
111 } else {
112 ""
113 };
114
115 let response = format!(
116 "HTTP/1.1 {status}\r\n\
117 Content-Type: {content_type}\r\n\
118 Content-Length: {}\r\n\
119 {cache_header}\
120 Access-Control-Allow-Origin: *\r\n\
121 Connection: close\r\n\
122 \r\n\
123 {body}",
124 body.len()
125 );
126
127 let _ = stream.write_all(response.as_bytes()).await;
128}
129
130fn detect_project_root_for_dashboard() -> String {
131 let cwd = std::env::current_dir().unwrap_or_default();
132 let mut dir = cwd.as_path();
133 loop {
134 if dir.join(".git").exists() {
135 return dir.to_string_lossy().to_string();
136 }
137 match dir.parent() {
138 Some(parent) => dir = parent,
139 None => break,
140 }
141 }
142 cwd.to_string_lossy().to_string()
143}