Files
agent-manager/src/commands/stats_cmd.rs
T

329 lines
11 KiB
Rust

//! stats: usage statistics computed from the event journal.
use super::*;
use crate::events::{self, EventKind};
use crate::output::{print_json, Table};
use anyhow::Context;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, Serialize)]
pub struct AgentStats {
pub agent: String,
pub launches: u64,
pub total_duration_s: u64,
pub avg_duration_s: u64,
pub failures: u64,
pub installs: u64,
pub updates: u64,
pub last_used: Option<String>,
}
/// Cutoff instant for a period: 7d / 30d / 90d, or None for everything.
pub fn cutoff(period: Option<&str>) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
let days = match period {
None | Some("all") => return Ok(None),
Some("7d") => 7,
Some("30d") => 30,
Some("90d") => 90,
Some(other) => bail!("invalid period '{other}' — expected 7d, 30d, 90d or all"),
};
Ok(Some(chrono::Utc::now() - chrono::Duration::days(days)))
}
/// Aggregate journal events into per-agent statistics.
pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<Vec<AgentStats>> {
let cut = cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, AgentStats> = BTreeMap::new();
let mut dur_count: BTreeMap<String, u64> = BTreeMap::new();
for ev in events {
if let Some(c) = cut {
let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) else {
continue;
};
if ts.with_timezone(&chrono::Utc) < c {
continue;
}
}
let Some(name) = ev.agent else {
continue;
};
if let Some(a) = agent {
if name != a {
continue;
}
}
let s = map.entry(name.clone()).or_default();
s.agent = name.clone();
match ev.kind {
EventKind::Start | EventKind::Run => s.launches += 1,
EventKind::Install => s.installs += 1,
EventKind::Update => s.updates += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
s.total_duration_s += d;
*dur_count.entry(name.clone()).or_default() += 1;
}
if let Some(code) = ev.exit_code {
if code != 0 {
s.failures += 1;
}
}
s.last_used = Some(ev.ts);
}
for (name, s) in map.iter_mut() {
let n = dur_count.get(name).copied().unwrap_or(0);
s.avg_duration_s = if n > 0 { s.total_duration_s / n } else { 0 };
}
Ok(map.into_values().collect())
}
/// Top 10 agents by launch count (issue #37).
pub fn top(app: &App, period: Option<&str>) -> Result<Vec<AgentStats>> {
let mut rows = aggregate(app, None, period)?;
rows.sort_by(|a, b| b.launches.cmp(&a.launches).then(b.total_duration_s.cmp(&a.total_duration_s)));
rows.truncate(10);
Ok(rows)
}
/// Markdown weekly/monthly report over the last N days (issue #37).
pub fn report_md(app: &App, days: u32) -> String {
let cut = chrono::Utc::now() - chrono::Duration::days(days as i64);
let events = events::read_events(&app.events_dir(), 0);
let mut total_launches = 0u64;
let mut failures = 0u64;
let mut changes = 0u64;
let mut duration = 0u64;
for ev in events {
let ok = chrono::DateTime::parse_from_rfc3339(&ev.ts)
.map(|t| t.with_timezone(&chrono::Utc) >= cut)
.unwrap_or(false);
if !ok {
continue;
}
match ev.kind {
EventKind::Start | EventKind::Run => total_launches += 1,
EventKind::Install | EventKind::Update | EventKind::Uninstall => changes += 1,
_ => {}
}
if let Some(d) = ev.duration_s {
duration += d;
}
if let Some(c) = ev.exit_code {
if c != 0 {
failures += 1;
}
}
}
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
let top_rows = top(app, None).unwrap_or_default();
let mut md = format!(
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
duration
);
if !top_rows.is_empty() {
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
for r in &top_rows {
md.push_str(&format!(
"| {} | {} | {}s | {} |\n",
r.agent, r.launches, r.total_duration_s, r.failures
));
}
}
md
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
let rows = aggregate(app, agent, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
if rows.is_empty() {
app.log.info("no usage recorded yet — run or start an agent first");
return Ok(0);
}
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "AVG", "FAIL", "LAST"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
format_duration(r.total_duration_s),
format_duration(r.avg_duration_s),
if r.failures > 0 {
crate::output::status_style("error", theme, color)
} else {
r.failures.to_string()
},
r.last_used
.as_deref()
.map(|t| t[..t.len().min(10)].to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
Ok(0)
}
/// am top : the most used agents (issue #37).
pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
let rows = top(app, period)?;
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
if rows.is_empty() {
app.log.info("no usage recorded yet");
return Ok(0);
}
let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1);
let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "FAIL"]);
for r in &rows {
table.row(vec![
r.agent.clone(),
format!("{} {}", r.launches, bar(r.launches, max_launches, 8)),
format_duration(r.total_duration_s),
if r.failures > 0 {
crate::output::status_style("error", theme, color)
} else {
r.failures.to_string()
},
]);
}
print!("{}", table.render());
Ok(0)
}
/// am report : write the markdown digest (issue #37).
pub fn run_report(
app: &App,
last_week: bool,
last_month: bool,
output: Option<&std::path::Path>,
) -> Result<i32> {
let days: u32 = if last_week { 7 } else { 30 };
let md = report_md(app, days);
let path = match output {
Some(p) => p.to_path_buf(),
None => {
let stamp = chrono::Local::now().format("%Y%m%d");
app.events_dir().join(format!("report-{stamp}.md"))
}
};
if app.dry_run() {
app.log.dry(format!("would write {}", path.display()));
return Ok(0);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&path, md)
.with_context(|| format!("cannot write {}", path.display()))?;
app.log.success(&format!("report written to {}", path.display()));
Ok(0)
}
/// ASCII bar: relative weight of v against max.
fn bar(v: u64, max: u64, width: usize) -> String {
if max == 0 {
return String::new();
}
let n = ((v * width as u64) / max).max(if v > 0 { 1 } else { 0 }) as usize;
format!("{}{}", "█".repeat(n), "░".repeat(width.saturating_sub(n)))
}
fn format_duration(secs: u64) -> String {
if secs == 0 {
return "—".to_string();
}
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with_events(events_jsonl: &str) -> crate::app::App {
// The TempDir guard would delete the directory when this helper
// returns; leak it on purpose so the App keeps a live events dir.
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,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
"agents: []\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;
std::fs::write(dir.join("events-test.jsonl"), events_jsonl).unwrap();
// Rename into the right month file so read_events picks it up.
let month = chrono::Utc::now().format("%Y%m");
std::fs::rename(
dir.join("events-test.jsonl"),
dir.join(format!("events-{month}.jsonl")),
)
.unwrap();
app
}
#[test]
fn aggregates_counts_durations_and_failures() {
let lines = [
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#,
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#,
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#,
r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#,
];
let app = app_with_events(&lines.join("\n"));
let rows = aggregate(&app, None, None).unwrap();
let a = rows.iter().find(|r| r.agent == "a").unwrap();
assert_eq!(a.launches, 2);
assert_eq!(a.total_duration_s, 40);
assert_eq!(a.avg_duration_s, 20);
assert_eq!(a.failures, 0);
assert_eq!(a.installs, 1);
let b = rows.iter().find(|r| r.agent == "b").unwrap();
assert_eq!(b.failures, 1);
assert_eq!(b.launches, 1);
}
#[test]
fn period_cuts_old_events() {
let app = app_with_events(
r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#,
);
assert!(aggregate(&app, None, Some("30d")).unwrap().is_empty());
assert_eq!(aggregate(&app, None, None).unwrap().len(), 1);
assert!(cutoff(Some("bad")).is_err());
}
}