358 lines
13 KiB
Rust
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(¶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::<usize>().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<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(¶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("<!DOCTYPE html>"));
|
|
assert!(html.contains("Overview"), "EN labels injected");
|
|
assert!(!html.contains("{{"), "no leftover tokens: {html}");
|
|
assert!(html.contains("window.I18N"), "status bundle injected");
|
|
}
|
|
}
|