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