//! Local web dashboard (issue #54): a read-only HTTP server bound to //! 127.0.0.1, serving the embedded frontend and a JSON API that mirrors //! the CLI's stable `--json` contracts (same serde structs). //! //! Security model: localhost bind only, GET-only routes, no mutation //! endpoints. Remote access with a token is issue #80 (phase 3). //! //! The frontend is a single self-contained HTML file embedded at compile //! time (`include_str!`): no external assets, no CDN, works offline. use crate::app::App; use anyhow::{Context, Result}; use serde::Serialize; use tiny_http::{Header, Method, Response, Server, StatusCode}; /// Default listening port (overridable with `am web --port`). pub const DEFAULT_PORT: u16 = 7878; /// The embedded single-file frontend (HTML + CSS + JS, dark cockpit). const INDEX_HTML: &str = include_str!("frontend/index.html"); /// One HTTP exchange result, decoupled from tiny_http so the routing /// logic stays a pure function (unit-testable without sockets). pub struct HttpResult { pub status: u16, pub content_type: &'static str, pub body: Vec, } impl HttpResult { fn json(value: &T) -> Self { let body = serde_json::to_vec(value) .unwrap_or_else(|_| b"{\"error\":\"serialization failed\"}".to_vec()); Self { status: 200, content_type: "application/json; charset=utf-8", body, } } /// Serialize a `Result`: Ok → 200 JSON, Err → 500 JSON with the error. fn api(result: Result) -> Self { match result { Ok(v) => Self::json(&v), Err(e) => Self { status: 500, content_type: "application/json; charset=utf-8", body: serde_json::json!({ "error": format!("{e:#}") }) .to_string() .into_bytes(), }, } } fn text(status: u16, content_type: &'static str, body: String) -> Self { Self { status, content_type, body: body.into_bytes(), } } } /// Route one request. Pure function: the only I/O is the app's own data /// files (journal, sessions, state) — never writes anything. pub fn handle(app: &App, method: &Method, target: &str) -> HttpResult { if *method != Method::Get { return HttpResult::text( 405, "text/plain; charset=utf-8", "method not allowed — this API is read-only".to_string(), ); } let (path, params) = split_query(target); match path { "/" | "/index.html" => index_page(app), "/api/health" => HttpResult::json(&serde_json::json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION"), })), // Same shape as `am dashboard --json`. "/api/overview" => HttpResult::json(&crate::dashboard::collect(app)), // Same shape as `am stats --json` (optional ?agent=&period=). "/api/stats" => HttpResult::api(crate::commands::stats_cmd::aggregate( app, query_get(¶ms, "agent").as_deref(), query_get(¶ms, "period").as_deref(), )), // Same shape as `am sessions --json` (optional ?agent=&project=&status=). "/api/sessions" => HttpResult::api(crate::commands::sessions_cmd::list_rows( app, query_get(¶ms, "agent").as_deref(), query_get(¶ms, "project").as_deref(), query_get(¶ms, "status").as_deref(), )), // Same shape as `am log --json` (optional ?agent=&kind=&since=&limit=). "/api/events" => { let limit = query_get(¶ms, "limit") .and_then(|v| v.parse::().ok()) .unwrap_or(200) .min(5000); HttpResult::api(crate::commands::log_cmd::rows( app, query_get(¶ms, "agent").as_deref(), query_get(¶ms, "kind").as_deref(), query_get(¶ms, "since").as_deref(), limit, )) } // Same shape as `am projects --json`. "/api/projects" => HttpResult::json(&crate::projects::aggregate(app)), _ => HttpResult::text( 404, "text/plain; charset=utf-8", format!("not found: {path}"), ), } } /// The embedded page, with every `{{label}}` token replaced by the /// localized label (`tr_in`), and the status-label bundle injected for /// the client-side rendering. fn index_page(app: &App) -> HttpResult { let lang = app.lang(); // The status-label bundle first: it contains raw JSON (quotes, braces) // that the {{...}} loop below must not touch. let bundle = serde_json::json!({ "running": crate::i18n::tr_in(lang, "En cours"), "stopped": crate::i18n::tr_in(lang, "Terminée"), "failed": crate::i18n::tr_in(lang, "Échec"), "interrupted": crate::i18n::tr_in(lang, "Interrompue"), }) .to_string(); let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle); // Then every {{label}} token: replaced by the localized label (the // French text is both the key and the fallback, so nothing survives). while let Some(start) = html.find("{{") { let Some(rel_end) = html[start + 2..].find("}}") else { break; }; let end = start + 2 + rel_end; let key = html[start + 2..end].to_string(); let translated = crate::i18n::tr_in(lang, &key); html.replace_range(start..end + 2, translated); } HttpResult { status: 200, content_type: "text/html; charset=utf-8", body: html.into_bytes(), } } /// Serve the dashboard until the process is interrupted (Ctrl-C). pub fn run(app: &App, port: u16, open_browser: bool) -> Result { let addr = format!("127.0.0.1:{port}"); let server = Server::http(&addr) .map_err(|e| anyhow::anyhow!("cannot bind {addr}: {e}"))?; let url = format!("http://{addr}/"); println!( "{}", crate::tr_fmt!("am web — {} (Ctrl-C pour arrêter)", url) ); if open_browser { println!("{}", crate::i18n::tr("ouverture du navigateur…")); open_in_browser(&url); } for request in server.incoming_requests() { let method = request.method().clone(); let url = request.url().to_string(); let result = handle(app, &method, &url); let response = Response::from_data(result.body) .with_status_code(StatusCode(result.status)) .with_header( Header::from_bytes(b"Content-Type", result.content_type.as_bytes()) .expect("valid content-type"), ) .with_header(Header::from_bytes(b"Cache-Control", b"no-store").expect("valid header")); let _ = request.respond(response); } Ok(0) } /// Open the dashboard in the default browser (best effort, per platform). pub fn open_in_browser(url: &str) { #[cfg(target_os = "windows")] { // 'start' is a cmd builtin; the empty quoted argument protects // URLs containing '&'. let _ = std::process::Command::new("cmd") .args(["/C", "start", "", url]) .spawn(); } #[cfg(target_os = "macos")] { let _ = std::process::Command::new("open").arg(url).spawn(); } #[cfg(all(unix, not(target_os = "macos")))] { let _ = std::process::Command::new("xdg-open").arg(url).spawn(); } } // --------------------------------------------------------------------------- // Query parsing (tiny, standard library only) // --------------------------------------------------------------------------- /// Split `path?query` into the path and the decoded query parameters. fn split_query(target: &str) -> (&str, Vec<(String, String)>) { match target.split_once('?') { Some((p, q)) => (p, parse_query(q)), None => (target, Vec::new()), } } fn parse_query(query: &str) -> Vec<(String, String)> { query .split('&') .filter_map(|kv| { let (k, v) = kv.split_once('=')?; Some((percent_decode(k), percent_decode(v))) }) .collect() } fn query_get(params: &[(String, String)], key: &str) -> Option { params .iter() .find(|(k, _)| k == key) .map(|(_, v)| v.clone()) } /// Decode `+` as space and `%XX` as a byte (best effort; unknown escapes /// are kept verbatim). fn percent_decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { match bytes[i] { b'+' => out.push(b' '), b'%' if i + 2 < bytes.len() => { let hex = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%'); out.push(hex); i += 2; } b => out.push(b), } i += 1; } String::from_utf8_lossy(&out).into_owned() } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn test_app() -> App { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write( &cfg, "version: \"1.0\"\nsettings:\n auto_install_deps: false\nagents: []\n", ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let mut app = crate::app::App::from_cli(cli).unwrap(); let mut p = app.paths.clone(); p.install_dir = dir.join("agents"); p.bin_dir = dir.join("agents").join("bin"); p.log_dir = dir.join("logs"); p.state_file = dir.join("state.json"); p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; app.state = crate::state::StateStore::new(dir.join("state.json")); app } #[test] fn percent_decode_handles_plus_and_hex() { assert_eq!(percent_decode("a+b%20c%41"), "a b cA"); assert_eq!(percent_decode("plain"), "plain"); assert_eq!(percent_decode("100%"), "100%"); // truncated escape kept } #[test] fn parse_query_splits_and_decodes() { let (path, params) = split_query("/api/events?agent=claude-code&limit=5&q=a+b"); assert_eq!(path, "/api/events"); assert_eq!(params.len(), 3); assert_eq!(query_get(¶ms, "agent").as_deref(), Some("claude-code")); assert_eq!(query_get(¶ms, "limit").as_deref(), Some("5")); assert_eq!(query_get(¶ms, "q").as_deref(), Some("a b")); assert_eq!(query_get(¶ms, "missing"), None); } #[test] fn only_get_is_allowed() { let app = test_app(); let r = handle(&app, &Method::Post, "/api/stats"); assert_eq!(r.status, 405); let r = handle(&app, &Method::Get, "/api/stats"); assert_eq!(r.status, 200); } #[test] fn unknown_route_is_404() { let app = test_app(); let r = handle(&app, &Method::Get, "/api/nope"); assert_eq!(r.status, 404); } #[test] fn health_reports_ok() { let app = test_app(); let r = handle(&app, &Method::Get, "/api/health"); assert_eq!(r.status, 200); let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); assert_eq!(v["status"], "ok"); assert!(v["version"].is_string()); } #[test] fn api_contracts_serialize_as_json_arrays() { let app = test_app(); for route in [ "/api/overview", "/api/stats", "/api/sessions", "/api/events", "/api/projects", ] { let r = handle(&app, &Method::Get, route); assert_eq!(r.status, 200, "{route}"); let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap_or_else(|e| { panic!("{route}: invalid JSON ({e}): {}", String::from_utf8_lossy(&r.body)) }); assert!(v.is_object() || v.is_array(), "{route} must be JSON data"); } } #[test] fn index_is_served_with_localized_labels() { let app = test_app(); app.set_lang(crate::i18n::Lang::En); let r = handle(&app, &Method::Get, "/"); assert_eq!(r.status, 200); let html = String::from_utf8_lossy(&r.body).into_owned(); assert!(html.contains("")); assert!(html.contains("Overview"), "EN labels injected"); assert!(!html.contains("{{"), "no leftover tokens: {html}"); assert!(html.contains("window.I18N"), "status bundle injected"); } }