Skip to main content

lean_ctx/dashboard/
mod.rs

1use 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(&registry).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}