feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
This commit is contained in:
+5
-2
@@ -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<br/>clap v4"]
|
||||
REPL["Shell interactif<br/>rustyline"]
|
||||
TUI["Dashboard TUI<br/>ratatui + crossterm"]
|
||||
WEB["Dashboard web<br/>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
|
||||
|
||||
Generated
+32
-1
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -140,6 +140,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
||||
| am plugins [--test <nom>] | 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 <agent> --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 <p>] | 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<Tab> # 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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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<PORT>\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
|
||||
@@ -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
|
||||
|
||||
+13
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<u16>,
|
||||
/// 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 {
|
||||
|
||||
+22
-8
@@ -75,14 +75,14 @@ pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LogRow {
|
||||
ts: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
pid: Option<u32>,
|
||||
exit_code: Option<i32>,
|
||||
duration_s: Option<u64>,
|
||||
session: Option<String>,
|
||||
pub struct LogRow {
|
||||
pub ts: String,
|
||||
pub kind: String,
|
||||
pub agent: Option<String>,
|
||||
pub pid: Option<u32>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub duration_s: Option<u64>,
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<LogRow>> {
|
||||
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()
|
||||
|
||||
@@ -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<i32> {
|
||||
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())
|
||||
|
||||
@@ -7,19 +7,19 @@ use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionRow {
|
||||
id: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
status: String,
|
||||
pid: Option<u32>,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
started_at: String,
|
||||
ended_at: Option<String>,
|
||||
duration_s: Option<u64>,
|
||||
exit_code: Option<i32>,
|
||||
log: Option<String>,
|
||||
pub struct SessionRow {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub agent: Option<String>,
|
||||
pub status: String,
|
||||
pub pid: Option<u32>,
|
||||
pub cwd: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub started_at: String,
|
||||
pub ended_at: Option<String>,
|
||||
pub duration_s: Option<u64>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub log: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<SessionRow>> {
|
||||
let sf = sessions::load(app)?;
|
||||
let mut rows: Vec<SessionRow> = 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<SessionRow> = 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);
|
||||
|
||||
@@ -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 <p>", "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)",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! web: serve the local read-only dashboard (issue #54).
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn run(app: &App, port: Option<u16>, no_open: bool) -> Result<i32> {
|
||||
crate::web::run(app, port.unwrap_or(crate::web::DEFAULT_PORT), !no_open)
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>am — {{Vue d'ensemble}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
|
||||
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
|
||||
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
|
||||
}
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
|
||||
}
|
||||
.layout { display:flex; min-height:100vh; }
|
||||
/* ---------- Sidebar ---------- */
|
||||
.side {
|
||||
width:225px; flex-shrink:0; background:var(--panel2);
|
||||
border-right:1px solid var(--border); padding:18px 12px;
|
||||
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
|
||||
}
|
||||
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
|
||||
.logo-badge {
|
||||
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
|
||||
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
|
||||
}
|
||||
.logo b { font-size:16px; letter-spacing:.5px; }
|
||||
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
|
||||
.nav-item {
|
||||
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
|
||||
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
|
||||
}
|
||||
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
|
||||
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
|
||||
.nav-item .ico { width:18px; text-align:center; }
|
||||
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
|
||||
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; animation:pulse 2.4s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
/* ---------- Main ---------- */
|
||||
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
|
||||
.topbar h1 { font-size:20px; font-weight:700; }
|
||||
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
|
||||
.top-actions { display:flex; align-items:center; gap:10px; }
|
||||
.badge {
|
||||
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
|
||||
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
|
||||
}
|
||||
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
|
||||
.badge.live::before { content:"● "; }
|
||||
.btn {
|
||||
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
|
||||
font-weight:700; font-size:13px; cursor:pointer;
|
||||
}
|
||||
.btn:hover { filter:brightness(1.1); }
|
||||
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
|
||||
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
|
||||
/* ---------- KPI cards ---------- */
|
||||
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
|
||||
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
|
||||
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
|
||||
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
|
||||
.kpi .delta { font-size:11.5px; margin-top:4px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
|
||||
/* ---------- Panels ---------- */
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; margin-bottom:14px; }
|
||||
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
|
||||
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
|
||||
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
|
||||
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); transition:width .5s ease; }
|
||||
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
|
||||
canvas#trend-canvas { width:100%; height:150px; display:block; }
|
||||
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
|
||||
/* ---------- Tables ---------- */
|
||||
table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
|
||||
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
|
||||
tbody tr:hover { background:rgba(61,220,151,.05); }
|
||||
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; white-space:nowrap; }
|
||||
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
|
||||
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
|
||||
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
|
||||
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
|
||||
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
|
||||
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
|
||||
.search:focus { outline:none; border-color:var(--accent); }
|
||||
/* ---------- Timeline ---------- */
|
||||
.tl { list-style:none; }
|
||||
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
|
||||
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
|
||||
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
|
||||
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
|
||||
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
|
||||
.view { display:none; } .view.active { display:block; }
|
||||
.err-bar { background:rgba(248,113,113,.12); border:1px solid rgba(248,113,113,.35); color:var(--err); border-radius:8px; padding:10px 14px; margin-bottom:14px; font-size:12.5px; display:none; }
|
||||
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="logo">
|
||||
<div class="logo-badge">am</div>
|
||||
<div><b>agent-manager</b><small>cockpit des agents</small></div>
|
||||
</div>
|
||||
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">{{Vue d'ensemble}}</span></div>
|
||||
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">{{Statistiques}}</span></div>
|
||||
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
|
||||
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">{{Événements}}</span></div>
|
||||
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">{{Projets}}</span></div>
|
||||
<div class="side-foot">
|
||||
<span><span class="dot"></span>v<span id="side-version">—</span> · <span id="side-lang">{{serveur local}}</span></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 id="view-title">{{Vue d'ensemble}}</h1>
|
||||
<div class="sub"><span>{{Dernière mise à jour}}</span> : <span id="last-update">—</span></div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="badge live">{{serveur local}}</span>
|
||||
<span class="badge">127.0.0.1</span>
|
||||
<button class="btn ghost" onclick="loadAll()">↻ {{Actualiser}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="err-bar" id="err-bar"></div>
|
||||
|
||||
<!-- ===== OVERVIEW ===== -->
|
||||
<section class="view active" id="overview">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="emoji">🤖</span><div class="label">{{Agents installés}}</div><div class="value" id="kpi-installed">—</div><div class="delta" id="kpi-installed-delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">⚡</span><div class="label">{{En cours}}</div><div class="value" id="kpi-running">—</div><div class="delta" id="kpi-running-delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-sessions">—</div><div class="delta" id="kpi-sessions-delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">📜</span><div class="label">{{Événements (24 h)}}</div><div class="value" id="kpi-events">—</div><div class="delta" id="kpi-events-delta"></div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>{{Top agents — sessions (7 j)}}</h3>
|
||||
<div id="top-agents"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>{{Activité — sessions par jour (14 j)}}</h3>
|
||||
<canvas id="trend-canvas"></canvas>
|
||||
<div class="trend-axis"><span id="axis-a"></span><span id="axis-b"></span><span id="axis-c"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="panel">
|
||||
<h3>{{Activité récente}}</h3>
|
||||
<ul class="tl" id="recent-activity"></ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>{{Sessions en cours}}</h3>
|
||||
<table>
|
||||
<thead><tr><th>{{Agent}}</th><th>{{Projet}}</th><th>{{Durée}}</th><th>{{Démarrée}}</th></tr></thead>
|
||||
<tbody id="running-sessions"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="running-empty" style="display:none">{{Aucune session en cours}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== STATS ===== -->
|
||||
<section class="view" id="stats">
|
||||
<div class="panel">
|
||||
<h3>{{Statistiques par agent — période 30 j}}</h3>
|
||||
<table>
|
||||
<thead><tr><th>{{Agent}}</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Durée moyenne}}</th><th>{{Échecs}}</th><th>{{Dernière activité}}</th></tr></thead>
|
||||
<tbody id="stats-body"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="stats-empty" style="display:none">{{Aucune activité enregistrée}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== SESSIONS ===== -->
|
||||
<section class="view" id="sessions">
|
||||
<div class="panel">
|
||||
<h3>Sessions</h3>
|
||||
<input class="search" id="sess-search" placeholder="{{Filtrer par agent, projet ou id…}}" oninput="filterSessions(this.value)">
|
||||
<table>
|
||||
<thead><tr><th>{{Session}}</th><th>{{Agent}}</th><th>{{Projet}}</th><th>{{Statut}}</th><th>{{Durée}}</th><th>{{Démarrée}}</th><th>{{Fin}}</th></tr></thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="sessions-empty" style="display:none">{{Aucune session ne correspond au filtre}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== EVENTS ===== -->
|
||||
<section class="view" id="events">
|
||||
<div class="panel">
|
||||
<h3>{{Journal d'événements — aujourd'hui}}</h3>
|
||||
<ul class="tl" id="events-list"></ul>
|
||||
<div class="empty-note" id="events-empty" style="display:none">{{Aucun événement}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== PROJECTS ===== -->
|
||||
<section class="view" id="projects">
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="emoji">🗂</span><div class="label">{{Projets suivis}}</div><div class="value" id="kpi-projects">—</div><div class="delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-proj-sessions">—</div><div class="delta"></div></div>
|
||||
<div class="kpi"><span class="emoji">🕒</span><div class="label">{{Temps total}}</div><div class="value" id="kpi-proj-time">—</div><div class="delta"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>{{Projets}}</h3>
|
||||
<table>
|
||||
<thead><tr><th>{{Projet}}</th><th>{{Agent}}</th><th>Sessions</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Dernière activité}}</th></tr></thead>
|
||||
<tbody id="projects-body"></tbody>
|
||||
</table>
|
||||
<div class="empty-note" id="projects-empty" style="display:none">{{Aucun projet suivi}}</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Injected by the server: localized status labels (fr/en, gettext keys).
|
||||
window.I18N = {{i18n_bundle}};
|
||||
|
||||
const state = { overview: null, stats: null, sessions: null, events: null, projects: null, health: null };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return String(iso).slice(11, 16);
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
function fmtDate(iso) { return iso ? String(iso).slice(0, 10) : '—'; }
|
||||
function fmtDur(s) {
|
||||
if (s == null) return '—';
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) return h + 'h' + String(m).padStart(2, '0') + 'm';
|
||||
if (m > 0) return m + 'm';
|
||||
return s + 's';
|
||||
}
|
||||
function shortRoot(root) {
|
||||
if (!root) return '—';
|
||||
const parts = String(root).split(/[\\/]/).filter(Boolean);
|
||||
return esc(parts[parts.length - 1] || root);
|
||||
}
|
||||
const STATUS_CLASS = { running: 'run', stopped: 'ok', failed: 'fail', interrupted: 'mut' };
|
||||
function statusPill(s) {
|
||||
const cls = STATUS_CLASS[s] || 'ok';
|
||||
const label = (window.I18N && window.I18N[s]) || s;
|
||||
return '<span class="pill ' + cls + '">' + esc(label) + '</span>';
|
||||
}
|
||||
const KIND_COLORS = {
|
||||
start:'#38bdf8', run:'#38bdf8', restart:'#38bdf8', cost:'#38bdf8',
|
||||
stop:'#34d399', shell:'#34d399', repl:'#34d399',
|
||||
install:'#a78bfa', update:'#a78bfa', uninstall:'#a78bfa', config:'#a78bfa',
|
||||
annotate:'#a78bfa', backup:'#a78bfa', rollback:'#a78bfa', model:'#a78bfa',
|
||||
catalog:'#a78bfa', prune:'#a78bfa', lab:'#a78bfa',
|
||||
doctor:'#f59e0b', alert:'#f87171',
|
||||
};
|
||||
function eventDetail(e) {
|
||||
if (e.args && e.args.length) return esc(e.args.join(' '));
|
||||
if (e.reason) return esc(e.reason);
|
||||
return '';
|
||||
}
|
||||
|
||||
async function jget(url) {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [ov, st, se, ev, pr, h] = await Promise.all([
|
||||
jget('/api/overview'), jget('/api/stats'), jget('/api/sessions'),
|
||||
jget('/api/events?limit=2000'), jget('/api/projects'), jget('/api/health'),
|
||||
]);
|
||||
state.overview = ov; state.stats = st; state.sessions = se;
|
||||
state.events = ev; state.projects = pr; state.health = h;
|
||||
$('err-bar').style.display = 'none';
|
||||
$('side-version').textContent = (h && h.version) || '—';
|
||||
renderAll();
|
||||
const t = new Date();
|
||||
$('last-update').textContent = t.toLocaleTimeString();
|
||||
} catch (e) {
|
||||
const bar = $('err-bar');
|
||||
bar.textContent = '{{erreur de chargement}} : ' + e.message;
|
||||
bar.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderOverview(); renderStats(); renderSessions(); renderEvents(); renderProjects();
|
||||
}
|
||||
|
||||
// ---------- Overview ----------
|
||||
function renderOverview() {
|
||||
const ov = state.overview || {};
|
||||
const counts = ov.counts || {};
|
||||
$('kpi-installed').textContent = counts.installed != null ? counts.installed : '—';
|
||||
$('kpi-running').textContent = counts.running != null ? counts.running : '—';
|
||||
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
const sessions = ov.sessions || [];
|
||||
const s7 = sessions.filter(s => now - new Date(s.started_at).getTime() <= 7 * DAY).length;
|
||||
const ev24 = (state.events || []).filter(e => now - new Date(e.ts).getTime() <= DAY).length;
|
||||
$('kpi-sessions').textContent = s7;
|
||||
$('kpi-events').textContent = ev24;
|
||||
$('kpi-sessions-delta').textContent = sessions.length + ' total';
|
||||
$('kpi-events-delta').textContent = (state.events || []).length + ' total';
|
||||
|
||||
// Top agents by launches (7d from the dashboard snapshot).
|
||||
const stats = (ov.stats || []).slice().sort((a, b) => b.launches - a.launches).slice(0, 6);
|
||||
const max = Math.max(1, ...stats.map(s => s.launches));
|
||||
$('top-agents').innerHTML = stats.length
|
||||
? stats.map(s =>
|
||||
'<div class="bar-row"><span class="name" title="' + esc(s.agent) + '">' + esc(s.agent) + '</span>' +
|
||||
'<div class="track"><div class="fill" style="width:' + Math.round(s.launches / max * 100) + '%"></div></div>' +
|
||||
'<span class="val">' + s.launches + '</span></div>').join('')
|
||||
: '<div class="empty-note">{{Aucune activité enregistrée}}</div>';
|
||||
|
||||
renderTrend();
|
||||
setAxis();
|
||||
|
||||
// Recent activity (newest first, already sorted by the server).
|
||||
const activity = (ov.activity || []).slice(0, 8);
|
||||
$('recent-activity').innerHTML = activity.length
|
||||
? activity.map(a => {
|
||||
const color = KIND_COLORS[a.kind] || '#8b98a5';
|
||||
const who = a.agent ? '<span class="a">' + esc(a.agent) + '</span> ' : '';
|
||||
return '<li><span class="dot" style="background:' + color + '"></span>' +
|
||||
'<span class="t">' + who + esc(a.detail) + '</span>' +
|
||||
'<span class="m">' + fmtTime(a.ts) + '</span></li>';
|
||||
}).join('')
|
||||
: '<li class="empty-note">{{Aucune activité enregistrée}}</li>';
|
||||
|
||||
// Running sessions.
|
||||
const running = sessions.filter(s => s.status === 'running');
|
||||
$('running-sessions').innerHTML = running.length
|
||||
? running.map(s =>
|
||||
'<tr><td>' + esc(s.agent || s.kind) + '</td><td>' + esc(s.project || '—') + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.duration_s) + '</td><td class="mono">' + fmtTime(s.started_at) + '</td></tr>').join('')
|
||||
: '';
|
||||
$('running-empty').style.display = running.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function setAxis() {
|
||||
const d = new Date();
|
||||
const fmt = (off) => { const x = new Date(d); x.setDate(x.getDate() + off); return (x.getMonth() + 1) + '/' + x.getDate(); };
|
||||
$('axis-a').textContent = fmt(-13);
|
||||
$('axis-b').textContent = fmt(-7);
|
||||
$('axis-c').textContent = fmt(0);
|
||||
}
|
||||
|
||||
function renderTrend() {
|
||||
const canvas = $('trend-canvas');
|
||||
const wrap = canvas.parentElement;
|
||||
const W = Math.max(240, wrap.clientWidth - 4) || 480, H = 150;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
days.push({ key, n: 0 });
|
||||
}
|
||||
(state.events || []).forEach(e => {
|
||||
if (e.kind === 'start' || e.kind === 'run') {
|
||||
const k = String(e.ts).slice(0, 10);
|
||||
const day = days.find(d => d.key === k);
|
||||
if (day) day.n++;
|
||||
}
|
||||
});
|
||||
const max = Math.max(1, ...days.map(d => d.n));
|
||||
const pad = 16, base = H - 26, bw = (W - 2 * pad) / days.length;
|
||||
|
||||
// baseline + gridline
|
||||
ctx.strokeStyle = '#1d2733'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(pad, base + 0.5); ctx.lineTo(W - pad, base + 0.5); ctx.stroke();
|
||||
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, base);
|
||||
grad.addColorStop(0, '#3ddc97'); grad.addColorStop(1, '#38bdf8');
|
||||
days.forEach((d, i) => {
|
||||
const x = pad + i * bw, h = Math.round((d.n / max) * (base - 18));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(x + 2, base - h, bw - 4, h);
|
||||
if (d.n > 0) {
|
||||
ctx.fillStyle = '#0b0f14'; ctx.font = '10px ui-monospace, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(d.n), x + bw / 2, base - h - 4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Stats ----------
|
||||
function renderStats() {
|
||||
const rows = state.stats || [];
|
||||
$('stats-body').innerHTML = rows.length
|
||||
? rows.slice().sort((a, b) => b.launches - a.launches).map(s =>
|
||||
'<tr><td>' + esc(s.agent) + '</td><td>' + s.launches + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.total_duration_s) + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.avg_duration_s) + '</td>' +
|
||||
'<td>' + (s.failures > 0 ? '<span class="pill fail">' + s.failures + '</span>' : '<span class="pill ok">0</span>') + '</td>' +
|
||||
'<td class="mono">' + fmtDate(s.last_used) + ' ' + fmtTime(s.last_used) + '</td></tr>').join('')
|
||||
: '';
|
||||
$('stats-empty').style.display = rows.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
function renderSessions() {
|
||||
const rows = state.sessions || [];
|
||||
$('sessions-body').innerHTML = rows.length
|
||||
? rows.map(s =>
|
||||
'<tr><td class="mono">' + esc(s.id) + '</td><td>' + esc(s.agent || s.kind) + '</td>' +
|
||||
'<td>' + esc(s.project || '—') + '</td><td>' + statusPill(s.status) + '</td>' +
|
||||
'<td class="mono">' + fmtDur(s.duration_s) + '</td>' +
|
||||
'<td class="mono">' + fmtDate(s.started_at) + ' ' + fmtTime(s.started_at) + '</td>' +
|
||||
'<td class="mono">' + fmtTime(s.ended_at) + '</td></tr>').join('')
|
||||
: '';
|
||||
filterSessions(($('sess-search') || {}).value || '');
|
||||
$('sessions-empty').style.display = 'none';
|
||||
}
|
||||
|
||||
function filterSessions(q) {
|
||||
q = (q || '').toLowerCase();
|
||||
let shown = 0;
|
||||
document.querySelectorAll('#sessions-body tr').forEach(tr => {
|
||||
const ok = tr.textContent.toLowerCase().includes(q);
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
if (ok) shown++;
|
||||
});
|
||||
$('sessions-empty').style.display = shown ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
function renderEvents() {
|
||||
const rows = state.events || [];
|
||||
$('events-list').innerHTML = rows.length
|
||||
? rows.map(e => {
|
||||
const color = KIND_COLORS[e.kind] || '#8b98a5';
|
||||
const who = e.agent ? '<span class="a">' + esc(e.agent) + '</span> ' : '';
|
||||
return '<li><span class="dot" style="background:' + color + '"></span>' +
|
||||
'<span class="t"><span class="a">' + esc(e.kind) + '</span> ' + who + eventDetail(e) + '</span>' +
|
||||
'<span class="m">' + fmtTime(e.ts) + '</span></li>';
|
||||
}).join('')
|
||||
: '';
|
||||
$('events-empty').style.display = rows.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Projects ----------
|
||||
function renderProjects() {
|
||||
const rows = state.projects || [];
|
||||
$('kpi-projects').textContent = rows.length;
|
||||
$('kpi-proj-sessions').textContent = rows.reduce((a, p) => a + (p.sessions || 0), 0);
|
||||
$('kpi-proj-time').textContent = fmtDur(rows.reduce((a, p) => a + (p.total_duration_s || 0), 0));
|
||||
$('projects-body').innerHTML = rows.length
|
||||
? rows.map(p =>
|
||||
'<tr><td><b>' + shortRoot(p.root) + '</b></td>' +
|
||||
'<td>' + (p.agents || []).map(esc).join(' · ') + '</td>' +
|
||||
'<td>' + (p.sessions || 0) + '</td><td>' + (p.launches || 0) + '</td>' +
|
||||
'<td class="mono">' + fmtDur(p.total_duration_s) + '</td>' +
|
||||
'<td class="mono">' + fmtDate(p.last_activity) + ' ' + fmtTime(p.last_activity) + '</td></tr>').join('')
|
||||
: '';
|
||||
$('projects-empty').style.display = rows.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- Navigation ----------
|
||||
const TITLES = {
|
||||
overview: "{{Vue d'ensemble}}", stats: "{{Statistiques}}", sessions: "Sessions",
|
||||
events: "{{Événements}}", projects: "{{Projets}}",
|
||||
};
|
||||
document.querySelectorAll('.nav-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
const v = el.dataset.view;
|
||||
document.getElementById(v).classList.add('active');
|
||||
$('view-title').textContent = TITLES[v];
|
||||
if (v === 'overview') renderTrend(); // canvas needs the visible size
|
||||
});
|
||||
});
|
||||
window.addEventListener('resize', () => { if (state.overview) renderTrend(); });
|
||||
|
||||
loadAll();
|
||||
setInterval(loadAll, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+19
@@ -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",
|
||||
|
||||
+47
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ pub mod tables;
|
||||
pub mod theme;
|
||||
pub mod toolchain;
|
||||
pub mod version;
|
||||
pub mod web;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
|
||||
+8
-2
@@ -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"
|
||||
|
||||
+357
@@ -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<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");
|
||||
}
|
||||
}
|
||||
@@ -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 <args...>` 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("<!DOCTYPE html>"), "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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user