329 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|