Files
agent-manager/src/web.rs
T
bruno fa51cdb339
release / linux (push) Successful in 14m16s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
2026-08-18 16:40:15 -04:00

358 lines
13 KiB
Rust

//! 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<u8>,
}
impl HttpResult {
fn json<T: Serialize>(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<T: Serialize>(result: Result<T>) -> 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(&params, "agent").as_deref(),
query_get(&params, "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(&params, "agent").as_deref(),
query_get(&params, "project").as_deref(),
query_get(&params, "status").as_deref(),
)),
// Same shape as `am log --json` (optional ?agent=&kind=&since=&limit=).
"/api/events" => {
let limit = query_get(&params, "limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(200)
.min(5000);
HttpResult::api(crate::commands::log_cmd::rows(
app,
query_get(&params, "agent").as_deref(),
query_get(&params, "kind").as_deref(),
query_get(&params, "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<i32> {
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<String> {
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(&params, "agent").as_deref(), Some("claude-code"));
assert_eq!(query_get(&params, "limit").as_deref(), Some("5"));
assert_eq!(query_get(&params, "q").as_deref(), Some("a b"));
assert_eq!(query_get(&params, "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("<!DOCTYPE html>"));
assert!(html.contains("Overview"), "EN labels injected");
assert!(!html.contains("{{"), "no leftover tokens: {html}");
assert!(html.contains("window.I18N"), "status bundle injected");
}
}