From fa51cdb339057a18d87d1bbfdcff17e0ddafd8c5 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 18 Aug 2026 16:40:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20am=20web=20:=20?= =?UTF-8?q?dashboard=20web=20local=20+=20API=20JSON=20(contrats=20--json,?= =?UTF-8?q?=20127.0.0.1,=20tiny=5Fhttp,=20frontend=20embarqu=C3=A9)=20(clo?= =?UTF-8?q?ses=20#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 7 +- Cargo.lock | 33 ++- Cargo.toml | 3 +- README.md | 3 + ROADMAP.md | 2 +- man/am-web.1 | 19 ++ man/am.1 | 7 +- src/cli.rs | 13 + src/commands/log_cmd.rs | 30 ++- src/commands/mod.rs | 2 + src/commands/sessions_cmd.rs | 58 ++-- src/commands/tip_cmd.rs | 9 + src/commands/web_cmd.rs | 7 + src/frontend/index.html | 504 +++++++++++++++++++++++++++++++++++ src/help.rs | 19 ++ src/i18n.rs | 47 ++++ src/lib.rs | 1 + src/repl.rs | 10 +- src/web.rs | 357 +++++++++++++++++++++++++ tests/web_test.rs | 301 +++++++++++++++++++++ 20 files changed, 1392 insertions(+), 40 deletions(-) create mode 100644 man/am-web.1 create mode 100644 src/commands/web_cmd.rs create mode 100644 src/frontend/index.html create mode 100644 src/web.rs create mode 100644 tests/web_test.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90b9327..7e62b34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ > Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux. > -> 📦 Version : `0.4.4` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS +> 📦 Version : `0.6.0` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS --- @@ -28,6 +28,7 @@ flowchart TB CLI["Ligne de commande
clap v4"] REPL["Shell interactif
rustyline"] TUI["Dashboard TUI
ratatui + crossterm"] + WEB["Dashboard web
tiny_http (127.0.0.1)"] end subgraph CORE["⚙️ Noyau applicatif"] @@ -146,7 +147,9 @@ src/ ├── help.rs ❓ Aide Nushell-style ├── output.rs 🖨️ Logger et rendu ├── tables.rs 📋 Rendu tabulaire -├── theme.rs 🎨 Thèmes de couleur +├── theme.rs 🎨 Thèmes de couleur (REPL et tableaux) +├── web.rs 🌐 Serveur HTTP local (127.0.0.1) + API JSON + frontend embarqué +├── frontend/ 📄 index.html — dashboard web (HTML5 + CSS + JS vanilla, include_str!) ├── history.rs ⏪ Historique des commandes ├── sessions.rs 📅 Registre des sessions ├── projects.rs 🗂️️ Agrégation par projet diff --git a/Cargo.lock b/Cargo.lock index d03837d..40bbb1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.5.5" +version = "0.6.0" dependencies = [ "anyhow", "chrono", @@ -43,6 +43,7 @@ dependencies = [ "shell-words", "tar", "tempfile", + "tiny_http", "ureq", "wait-timeout", "which", @@ -139,6 +140,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "atomic" version = "0.6.1" @@ -296,6 +303,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -839,6 +852,12 @@ dependencies = [ "digest", ] +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2260,6 +2279,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index f2fd315..e00ead4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.5.5" +version = "0.6.0" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" @@ -31,6 +31,7 @@ sha2 = "0.10" shell-words = "1" tar = "0.4" tempfile = "3" +tiny_http = "0.12" ureq = { version = "2", default-features = false, features = ["tls"] } wait-timeout = "0.2" keyring = "3" diff --git a/README.md b/README.md index 968e9f0..ecea7d3 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) | am plugins [--test ] | scripts d'extension sur les événements (on_install/on_start/on_stop/on_update), contrat JSON stdin/stdout, timeout — exemples dans examples/plugins/ | | am logs --follow | tail du log d'un agent, en direct | | am dashboard | tableau de bord TUI temps réel : vue d'ensemble, activité, stats, sessions, projets (Tab/←/→ : onglets, j/k : défilement, q : quitter) | +| am web [--port

] | dashboard web local en lecture seule (127.0.0.1) : vue d'ensemble, graphiques, sessions, événements, projets — API JSON /api/* avec les mêmes contrats que les sorties --json | ### 🎯 Personnaliser @@ -223,6 +224,7 @@ Disponibles avant **ou après** la sous-commande. am start cc --profile dev # profil d'environnement : env + args injectés am dashboard # TUI temps réel : toute l'activité d'un coup d'œil + am web # dashboard web local (ouvre le navigateur) : http://127.0.0.1:7878/ am favorite cc && am tag cc python # personnalisez votre parc am search --tag python # retrouvez les agents que vous avez tagués @@ -230,6 +232,7 @@ Disponibles avant **ou après** la sous-commande. ❯ inst # complétion Tab : commandes, agents installés, groupes, options ❯ install jcode ❯ dashboard # le dashboard se lance aussi depuis le REPL + ❯ web # ... et le dashboard web aussi (am web --port pour changer le port) ❯ exit # historique persistant (flèches haut/bas) ### 🐚 Passerelle système diff --git a/ROADMAP.md b/ROADMAP.md index c88cc00..ace5406 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -435,7 +435,7 @@ moins de 2 secondes. | [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | ✅ Playbooks — am history --save + am playbook | L | | [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M | | [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M | -| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | am web — serveur local + API JSON + graphiques | XL | +| [#54](https://git.dracodev.net/Projets/agent-manager/issues/54) | ✅ am web — serveur local + API JSON + graphiques (dashboard embarqué, contrats --json, 127.0.0.1) | XL | | [#55](https://git.dracodev.net/Projets/agent-manager/issues/55) | Services système — am service install (systemd/launchd/tâches Windows) ✅ | L | | [#56](https://git.dracodev.net/Projets/agent-manager/issues/56) | Planification — am schedule (add --at / list / remove / run) ✅ | M | | [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) ✅ | L | diff --git a/man/am-web.1 b/man/am-web.1 new file mode 100644 index 0000000..08c2fc4 --- /dev/null +++ b/man/am-web.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-web 1 "web " +.SH NAME +web \- Local read\-only web dashboard with charts (issue #54) +.SH SYNOPSIS +\fBweb\fR [\fB\-\-port\fR] [\fB\-\-no\-open\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Local read\-only web dashboard with charts (issue #54) +.SH OPTIONS +.TP +\fB\-\-port\fR \fI\fR +Port to listen on (default 7878) +.TP +\fB\-\-no\-open\fR +Do not open the browser automatically +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/am.1 b/man/am.1 index 7f948f1..b6480b2 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.5.5" +.TH am 1 "am 0.6.0" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -145,6 +145,9 @@ Show usage statistics computed from the event journal am\-monitor(1) Real\-time monitor of the managed processes (issue #50) .TP +am\-web(1) +Local read\-only web dashboard with charts (issue #54) +.TP am\-sync(1) Push the state into the configured git repository (issue #66) .TP @@ -229,4 +232,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.5.5 +v0.6.0 diff --git a/src/cli.rs b/src/cli.rs index e83286b..ba03624 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -273,6 +273,8 @@ pub enum Command { #[arg(long, action = ArgAction::SetTrue)] json: bool, }, + /// Local read-only web dashboard with charts (issue #54) + Web(WebArgs), /// Push the state into the configured git repository (issue #66) Sync { /// Commit message (default: "am sync — state update") @@ -606,6 +608,17 @@ pub struct PluginsArgs { pub test: Option, } +/// Arguments of the web command (issue #54). +#[derive(Args, Debug, Clone, Default)] +pub struct WebArgs { + /// Port to listen on (default 7878) + #[arg(long, value_name = "PORT")] + pub port: Option, + /// Do not open the browser automatically + #[arg(long, action = ArgAction::SetTrue)] + pub no_open: bool, +} + /// Remote catalog subcommands (issues #60 #67). #[derive(Subcommand, Debug, Clone)] pub enum CatalogCmd { diff --git a/src/commands/log_cmd.rs b/src/commands/log_cmd.rs index d4fc91c..8282d39 100644 --- a/src/commands/log_cmd.rs +++ b/src/commands/log_cmd.rs @@ -75,14 +75,14 @@ pub fn parse_since(s: &str) -> Result> { } #[derive(Serialize)] -struct LogRow { - ts: String, - kind: String, - agent: Option, - pid: Option, - exit_code: Option, - duration_s: Option, - session: Option, +pub struct LogRow { + pub ts: String, + pub kind: String, + pub agent: Option, + pub pid: Option, + pub exit_code: Option, + pub duration_s: Option, + pub session: Option, } impl From<&Event> for LogRow { @@ -99,6 +99,20 @@ impl From<&Event> for LogRow { } } +/// Rows for the web API (issue #54): same shape as `am log --json`. +pub fn rows( + app: &App, + agent: Option<&str>, + kinds: Option<&str>, + since: Option<&str>, + limit: usize, +) -> Result> { + Ok(filter_events(app, agent, kinds, since, limit)? + .iter() + .map(LogRow::from) + .collect()) +} + /// Chars of text after byte offset seen (UTF-8 safe). pub fn text_after(text: &str, seen: usize) -> String { text.char_indices() diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2164d9f..9750662 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -47,6 +47,7 @@ pub mod uninstall_cmd; pub mod update_cmd; pub mod version_cmd; pub mod watch_cmd; +pub mod web_cmd; use crate::app::App; use crate::cli::Command; @@ -117,6 +118,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Monitor { interval, json } => { monitor_cmd::run(app, *interval, *json) } + Command::Web(args) => web_cmd::run(app, args.port, args.no_open), Command::Sync { message } => sync_cmd::run(app, message.as_deref()), Command::Migrate { export, output, bundle } => { migrate_cmd::run(app, *export, output.as_deref(), bundle.as_deref()) diff --git a/src/commands/sessions_cmd.rs b/src/commands/sessions_cmd.rs index 32b7aed..78874e0 100644 --- a/src/commands/sessions_cmd.rs +++ b/src/commands/sessions_cmd.rs @@ -7,19 +7,19 @@ use serde::Serialize; use std::path::PathBuf; #[derive(Serialize)] -struct SessionRow { - id: String, - kind: String, - agent: Option, - status: String, - pid: Option, - cwd: Option, - project: Option, - started_at: String, - ended_at: Option, - duration_s: Option, - exit_code: Option, - log: Option, +pub struct SessionRow { + pub id: String, + pub kind: String, + pub agent: Option, + pub status: String, + pub pid: Option, + pub cwd: Option, + pub project: Option, + pub started_at: String, + pub ended_at: Option, + pub duration_s: Option, + pub exit_code: Option, + pub log: Option, } fn row_of(r: &SessionRecord) -> SessionRow { @@ -39,6 +39,27 @@ fn row_of(r: &SessionRecord) -> SessionRow { } } +/// Rows for the web API (issue #54): same shape as `am sessions --json`. +/// Unlike `run`, it never reconciles the registry (the web is read-only). +pub fn list_rows( + app: &App, + agent: Option<&str>, + project: Option<&str>, + status: Option<&str>, +) -> Result> { + let sf = sessions::load(app)?; + let mut rows: Vec = sf + .sessions + .values() + .filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a))) + .filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p))) + .filter(|r| status.map_or(true, |s| r.status == s)) + .map(row_of) + .collect(); + rows.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + Ok(rows) +} + pub fn run( app: &App, agent: Option<&str>, @@ -63,16 +84,7 @@ pub fn run( return resume_session(app, id); } sessions::reconcile(app)?; - let sf = sessions::load(app)?; - let mut rows: Vec = sf - .sessions - .values() - .filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a))) - .filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p))) - .filter(|r| status.map_or(true, |s| r.status == s)) - .map(row_of) - .collect(); - rows.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + let rows = list_rows(app, agent, project, status)?; if app.json() { print_json(&rows); return Ok(0); diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index 7c43a4b..c309d4c 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -242,6 +242,15 @@ pub static SECTIONS: &[TipSection] = &[ ], example: "monitor --interval 5", }, + TipEntry { + usage: "web", + about: "dashboard web local en lecture seule : vue d'ensemble, graphiques, sessions, événements, projets (issue #54)", + options: &[ + ("--port

", "port d'écoute (défaut 7878)"), + ("--no-open", "ne pas ouvrir le navigateur automatiquement"), + ], + example: "web --port 9090", + }, TipEntry { usage: "sync", about: "pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", diff --git a/src/commands/web_cmd.rs b/src/commands/web_cmd.rs new file mode 100644 index 0000000..39718ff --- /dev/null +++ b/src/commands/web_cmd.rs @@ -0,0 +1,7 @@ +//! web: serve the local read-only dashboard (issue #54). + +use super::*; + +pub fn run(app: &App, port: Option, no_open: bool) -> Result { + crate::web::run(app, port.unwrap_or(crate::web::DEFAULT_PORT), !no_open) +} diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..2feb889 --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,504 @@ + + + + + +am — {{Vue d'ensemble}} + + + +

+ + +
+
+
+

{{Vue d'ensemble}}

+
{{Dernière mise à jour}} : —
+
+
+ {{serveur local}} + 127.0.0.1 + +
+
+
+ + +
+
+
🤖
{{Agents installés}}
—
+
⚡
{{En cours}}
—
+
⏱
{{Sessions (7 j)}}
—
+
📜
{{Événements (24 h)}}
—
+
+
+
+

{{Top agents — sessions (7 j)}}

+
+
+
+

{{Activité — sessions par jour (14 j)}}

+ +
+
+
+
+
+

{{Activité récente}}

+
    +
    +
    +

    {{Sessions en cours}}

    + + + +
    {{Agent}}{{Projet}}{{Durée}}{{Démarrée}}
    + +
    +
    +
    + + +
    +
    +

    {{Statistiques par agent — période 30 j}}

    + + + +
    {{Agent}}{{Lancements}}{{Durée}}{{Durée moyenne}}{{Échecs}}{{Dernière activité}}
    + +
    +
    + + +
    +
    +

    Sessions

    + + + + +
    {{Session}}{{Agent}}{{Projet}}{{Statut}}{{Durée}}{{Démarrée}}{{Fin}}
    + +
    +
    + + +
    +
    +

    {{Journal d'événements — aujourd'hui}}

    +
      + +
      +
      + + +
      +
      +
      🗂
      {{Projets suivis}}
      —
      +
      ⏱
      {{Sessions (7 j)}}
      —
      +
      🕒
      {{Temps total}}
      —
      +
      +
      +

      {{Projets}}

      + + + +
      {{Projet}}{{Agent}}Sessions{{Lancements}}{{Durée}}{{Dernière activité}}
      + +
      +
      +
      +
      + + + + diff --git a/src/help.rs b/src/help.rs index 177fec6..3030e6f 100644 --- a/src/help.rs +++ b/src/help.rs @@ -299,6 +299,25 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "JSON stream for scripts.", code: "monitor --json --interval 10" }, ], }, + HelpSpec { + name: "web", + category: "Commands", + usage: "web {flags}", + about: "Local read-only web dashboard: overview, charts, sessions, events, projects (issue #54). Serves an embedded page and a JSON API with the same stable contracts as the CLI --json outputs; bound to 127.0.0.1 only.", + search_terms: &["web", "dashboard", "html", "http", "api", "graph", "chart", "navigateur", "browser", "port"], + flags: &[ + HelpFlag { short: "", long: "--port", value: "PORT", desc: "Port to listen on (default 7878)" }, + HelpFlag { short: "", long: "--no-open", value: "", desc: "Do not open the browser automatically" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Open the dashboard in the browser.", code: "web" }, + HelpExample { desc: "Serve on another port, no browser.", code: "web --port 9090 --no-open" }, + HelpExample { desc: "API routes: /api/health, /api/overview, /api/stats, /api/sessions, /api/events, /api/projects.", code: "curl http://127.0.0.1:7878/api/health" }, + ], + }, HelpSpec { name: "history", category: "Commands", diff --git a/src/i18n.rs b/src/i18n.rs index 84ae3c7..cadaf7c 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -294,6 +294,53 @@ pub const CATALOG: &[(&str, &str)] = &[ ("'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.", "'theme ocean', 'theme dracula'… the palette of the am shell, the tables and this page changes instantly."), ("doctor répare", "doctor repairs"), ("'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).", "'am doctor --fix' checks the environment and fixes common problems (missing folders, corrupted state…)."), + // ---- web (issue #54) --------------------------------------------------- + ("am web — {} (Ctrl-C pour arrêter)", "am web — {} (Ctrl-C to stop)"), + ("ouverture du navigateur…", "opening the browser…"), + ("Vue d'ensemble", "Overview"), + ("Statistiques", "Statistics"), + ("Événements", "Events"), + ("Projets", "Projects"), + ("Agents installés", "Installed agents"), + ("En cours", "Running"), + ("Sessions (7 j)", "Sessions (7d)"), + ("Événements (24 h)", "Events (24h)"), + ("Top agents — sessions (7 j)", "Top agents — sessions (7d)"), + ("Activité — sessions par jour (14 j)", "Activity — sessions per day (14d)"), + ("Activité récente", "Recent activity"), + ("Sessions en cours", "Running sessions"), + ("Statistiques par agent — période 30 j", "Per-agent statistics — last 30 days"), + ("Session", "Session"), + ("Agent", "Agent"), + ("Projet", "Project"), + ("Statut", "Status"), + ("Durée", "Duration"), + ("Heure", "Time"), + ("Type", "Type"), + ("Détail", "Details"), + ("Dernière activité", "Last activity"), + ("Journal d'événements — aujourd'hui", "Event journal — today"), + ("Dernière mise à jour", "Last updated"), + ("Filtrer par agent, projet ou id…", "Filter by agent, project or id…"), + ("Aucune session ne correspond au filtre", "No sessions match the filter"), + ("Aucune session en cours", "No running sessions"), + ("Aucun événement", "No events"), + ("Aucune activité enregistrée", "No activity recorded yet"), + ("Aucun projet suivi", "No tracked projects"), + ("Actualiser", "Refresh"), + ("serveur local", "local server"), + ("Temps total", "Total time"), + ("Projets suivis", "Tracked projects"), + ("Terminée", "Finished"), + ("Échec", "Failed"), + ("Interrompue", "Interrupted"), + ("chargement…", "loading…"), + ("erreur de chargement", "load error"), + ("Lancements", "Launches"), + ("Durée moyenne", "Avg duration"), + ("Échecs", "Failures"), + ("Démarrée", "Started"), + ("Fin", "End"), ]; /// Look up the English translation of a French message. Returns None when the diff --git a/src/lib.rs b/src/lib.rs index 6c73abb..0c70bc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ pub mod tables; pub mod theme; pub mod toolchain; pub mod version; +pub mod web; use clap::Parser; diff --git a/src/repl.rs b/src/repl.rs index 2dfcfb5..3b5be6f 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -117,6 +117,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("migrate", "export/import a machine transfer bundle (config + state)"), ("playbook", "replay a saved history sequence step by step (am history --save)"), ("lab", "benchmark the same task on several agents (duration, cost, --json)"), + ("web", "local read-only web dashboard with charts (issue #54)"), ("plugins", "list the event plugins and test one (JSON contract on stdin/stdout)"), ("shell", "show or switch the system shell"), ("theme", "show or switch the color theme"), @@ -233,7 +234,7 @@ impl AmCompleter { "self-update", "self-uninstall", "export", "import", "shell", "theme", "lang", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "profile", "man", "models", "catalog", "suggest", "audit", - "service", "schedule", "monitor", "sync", "migrate", "playbook", "lab", "plugins", + "service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], @@ -777,7 +778,7 @@ pub fn banner_box( " lifecycle install · uninstall · update · start · stop · restart · run".to_string(), )); rows.push(inner( - " manage open · watch · dashboard · favorite · unfavorite · note · tag · untag · tags" + " manage open · watch · dashboard · web · favorite · unfavorite · note · tag · untag · tags" .to_string(), )); rows.push(inner( @@ -1575,6 +1576,10 @@ fn handle_line( interval: opt_value("--interval").and_then(|v| v.parse().ok()), json: flag("--json"), }, + "web" => Command::Web(crate::cli::WebArgs { + port: opt_value("--port").and_then(|v| v.parse().ok()), + no_open: flag("--no-open"), + }), "sync" => Command::Sync { message: opt_value("--message"), }, @@ -1894,6 +1899,7 @@ pub fn is_am_command(word: &str) -> bool { | "service" | "schedule" | "monitor" + | "web" | "sync" | "migrate" | "playbook" diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..dcefc60 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,357 @@ +//! 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"); + } +} diff --git a/tests/web_test.rs b/tests/web_test.rs new file mode 100644 index 0000000..ae1c5df --- /dev/null +++ b/tests/web_test.rs @@ -0,0 +1,301 @@ +//! Integration tests for `am web` (issue #54): spawn the real binary, +//! hit the HTTP routes, and verify the JSON API matches the CLI --json +//! contracts byte-for-byte (after JSON parsing). + +use std::io::Read; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +/// Serialize the network tests: each one binds its own ephemeral port and +/// spawns its own server process. A panicking test must not poison the +/// lock for the others, so the poison is recovered. +static WEB_LOCK: Mutex<()> = Mutex::new(()); + +fn web_lock() -> std::sync::MutexGuard<'static, ()> { + WEB_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Kill the server child even when the test panics. +struct ServerGuard(Child); +impl Drop for ServerGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +struct TestEnv { + dir: PathBuf, + cfg: PathBuf, +} + +fn fresh_dir(tag: &str) -> TestEnv { + let dir = std::env::temp_dir().join(format!( + "am-web-test-{tag}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n", + ) + .unwrap(); + TestEnv { dir, cfg } +} + +/// Seed a journal + one session so every route has deterministic data. +fn seed(env: &TestEnv) { + let month = chrono::Utc::now().format("%Y%m"); + let journal = env.dir.join(format!("events-{month}.jsonl")); + std::fs::write( + &journal, + concat!( + "{\"ts\":\"2026-08-01T10:00:00Z\",\"kind\":\"start\",\"agent\":\"claude-code\",\"project\":\"C:/repo\",\"session\":\"s1\",\"duration_s\":3600,\"exit_code\":0}\n", + "{\"ts\":\"2026-08-01T11:00:00Z\",\"kind\":\"stop\",\"agent\":\"claude-code\",\"project\":\"C:/repo\",\"session\":\"s1\",\"duration_s\":3600}\n", + "{\"ts\":\"2026-08-02T10:00:00Z\",\"kind\":\"run\",\"agent\":\"aider\",\"project\":\"C:/repo2\",\"session\":\"s2\",\"duration_s\":600,\"exit_code\":1}\n", + "{\"ts\":\"2026-08-03T10:00:00Z\",\"kind\":\"install\",\"agent\":\"jcode\"}\n", + ), + ) + .unwrap(); + let sessions = env.dir.join("sessions.json"); + std::fs::write( + &sessions, + concat!( + "{\"version\":1,\"sessions\":{", + "\"s1\":{\"id\":\"s1\",\"kind\":\"agent\",\"agent\":\"claude-code\",\"project\":\"C:/repo\",", + "\"started_at\":\"2026-08-01T10:00:00Z\",\"ended_at\":\"2026-08-01T11:00:00Z\",\"status\":\"stopped\"},", + "\"s2\":{\"id\":\"s2\",\"kind\":\"agent\",\"agent\":\"aider\",\"project\":\"C:/repo2\",", + "\"started_at\":\"2026-08-02T10:00:00Z\",\"ended_at\":\"2026-08-02T10:10:00Z\",", + "\"exit_code\":1,\"status\":\"failed\"}", + "}}\n", + ), + ) + .unwrap(); +} + +fn am_bin() -> &'static str { + env!("CARGO_BIN_EXE_am") +} + +/// Run `am ` against the seeded env; returns (exit, stdout, stderr). +fn am_run(env: &TestEnv, args: &[&str]) -> (i32, String, String) { + let out = Command::new(am_bin()) + .arg("--config") + .arg(&env.cfg) + .args(args) + .env("AGENT_MANAGER_STATE", &env.dir) + .env("AGENT_MANAGER_DATA", &env.dir) + .output() + .expect("am binary should run"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Pick a free port, spawn `am web --no-open` on it, wait for readiness. +fn spawn_web(env: &TestEnv) -> (u16, ServerGuard) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let child = Command::new(am_bin()) + .arg("--config") + .arg(&env.cfg) + .args(["web", "--port", &port.to_string(), "--no-open", "--lang", "fr"]) + .env("AGENT_MANAGER_STATE", &env.dir) + .env("AGENT_MANAGER_DATA", &env.dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("am web should spawn"); + let guard = ServerGuard(child); + // Wait until the server accepts connections. + let deadline = std::time::Instant::now() + Duration::from_secs(15); + loop { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "server did not come up on port {port}" + ); + std::thread::sleep(Duration::from_millis(100)); + } + (port, guard) +} + +fn get_json(port: u16, path: &str) -> serde_json::Value { + let resp = ureq::get(&format!("http://127.0.0.1:{port}{path}")) + .call() + .unwrap_or_else(|e| panic!("GET {path}: {e}")); + assert_eq!(resp.status(), 200, "GET {path}"); + let mut body = String::new(); + resp.into_reader().read_to_string(&mut body).unwrap(); + serde_json::from_str(&body).unwrap_or_else(|e| panic!("invalid JSON for {path}: {e}")) +} + +fn get_status(port: u16, path: &str) -> u16 { + match ureq::get(&format!("http://127.0.0.1:{port}{path}")).call() { + Ok(r) => r.status(), + Err(ureq::Error::Status(code, _)) => code, + Err(e) => panic!("GET {path}: {e}"), + } +} + +#[test] +fn web_serves_the_dashboard_and_health() { + let _lock = web_lock(); + let env = fresh_dir("health"); + seed(&env); + let (port, _guard) = spawn_web(&env); + + // Frontend page, localized in French (forced via --lang fr). + let page = ureq::get(&format!("http://127.0.0.1:{port}/")) + .call() + .unwrap(); + assert_eq!(page.status(), 200); + let mut body = String::new(); + page.into_reader().read_to_string(&mut body).unwrap(); + assert!(body.contains(""), "HTML5 doctype"); + assert!(body.contains("Vue d'ensemble"), "FR label injected"); + assert!(!body.contains("{{"), "no leftover tokens"); + assert!(body.contains("window.I18N"), "status bundle injected"); + + let health = get_json(port, "/api/health"); + assert_eq!(health["status"], "ok"); + assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); +} + +#[test] +fn web_api_matches_cli_json_contracts() { + let _lock = web_lock(); + let env = fresh_dir("contracts"); + seed(&env); + let (port, _guard) = spawn_web(&env); + + // stats + let web_stats = get_json(port, "/api/stats"); + let (_, cli_stats, _) = am_run(&env, &["stats", "--json"]); + assert_eq!( + web_stats, + serde_json::from_str::(&cli_stats).unwrap(), + "/api/stats must equal 'am stats --json'" + ); + + // sessions + let web_sessions = get_json(port, "/api/sessions"); + let (_, cli_sessions, _) = am_run(&env, &["sessions", "--json"]); + assert_eq!( + web_sessions, + serde_json::from_str::(&cli_sessions).unwrap(), + "/api/sessions must equal 'am sessions --json'" + ); + let arr = web_sessions.as_array().unwrap(); + assert_eq!(arr.len(), 2, "two seeded sessions"); + // Newest first: s2 (aider, 2026-08-02) then s1 (claude-code, 08-01). + assert_eq!(arr[0]["agent"], "aider"); + + // events (log) + let web_events = get_json(port, "/api/events"); + let (_, cli_events, _) = am_run(&env, &["log", "--json"]); + assert_eq!( + web_events, + serde_json::from_str::(&cli_events).unwrap(), + "/api/events must equal 'am log --json'" + ); + assert_eq!(web_events.as_array().unwrap().len(), 4, "four seeded events"); + + // projects + let web_projects = get_json(port, "/api/projects"); + let (_, cli_projects, _) = am_run(&env, &["projects", "--json"]); + assert_eq!( + web_projects, + serde_json::from_str::(&cli_projects).unwrap(), + "/api/projects must equal 'am projects --json'" + ); + + // overview (dashboard) + let web_overview = get_json(port, "/api/overview"); + let (_, cli_overview, _) = am_run(&env, &["dashboard", "--json"]); + assert_eq!( + web_overview, + serde_json::from_str::(&cli_overview).unwrap(), + "/api/overview must equal 'am dashboard --json'" + ); + assert_eq!(web_overview["counts"]["installed"], 0); + assert!(web_overview["sessions"].as_array().unwrap().len() >= 2); +} + +#[test] +fn web_events_honors_query_filters() { + let _lock = web_lock(); + let env = fresh_dir("filters"); + seed(&env); + let (port, _guard) = spawn_web(&env); + + let two = get_json(port, "/api/events?limit=2"); + assert_eq!(two.as_array().unwrap().len(), 2, "?limit=2"); + + let agent = get_json(port, "/api/events?agent=aider"); + let arr = agent.as_array().unwrap(); + assert_eq!(arr.len(), 1, "one aider event"); + assert_eq!(arr[0]["agent"], "aider"); + + let kind = get_json(port, "/api/events?kind=install"); + let arr = kind.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["kind"], "install"); + + let stats = get_json(port, "/api/stats?agent=claude-code&period=all"); + let arr = stats.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["agent"], "claude-code"); + assert!(arr[0]["launches"].as_u64().unwrap_or(0) >= 1); +} + +#[test] +fn web_rejects_unknown_routes_and_non_get() { + let _lock = web_lock(); + let env = fresh_dir("errors"); + seed(&env); + let (port, _guard) = spawn_web(&env); + + assert_eq!(get_status(port, "/api/nope"), 404); + assert_eq!(get_status(port, "/api/stats?weird=1"), 200, "unknown params ignored"); + + // POST is refused (read-only API). + match ureq::post(&format!("http://127.0.0.1:{port}/api/stats")).call() { + Err(ureq::Error::Status(405, _)) => {} + other => panic!("expected 405, got {other:?}"), + } +} + +#[test] +fn web_reports_a_busy_port_cleanly() { + let _lock = web_lock(); + let env = fresh_dir("busy"); + seed(&env); + let holder = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = holder.local_addr().unwrap().port(); + + let out = Command::new(am_bin()) + .arg("--config") + .arg(&env.cfg) + .args(["web", "--port", &port.to_string(), "--no-open"]) + .env("AGENT_MANAGER_STATE", &env.dir) + .env("AGENT_MANAGER_DATA", &env.dir) + .output() + .expect("am web should run"); + assert_ne!(out.status.code().unwrap_or(0), 0, "busy port must fail"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("cannot bind"), + "clear error expected, got: {stderr}" + ); +}