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