top + report + log --follow (issues #37 #38) - top 10 par lancements, digest markdown hebdo/mensuel, suivi du journal en direct
This commit is contained in:
@@ -35,6 +35,34 @@ pub fn filter_events(
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Live view: print matching events as they arrive (issue #38).
|
||||
fn follow_loop(app: &App, agent: Option<&str>, kinds: Option<&str>, since: Option<&str>) -> Result<i32> {
|
||||
app.log.info("following the journal (Ctrl-C to stop)");
|
||||
let mut seen: usize = 0;
|
||||
loop {
|
||||
let events = filter_events(app, agent, kinds, since, 200)?;
|
||||
let mut rendered = String::new();
|
||||
for e in events.iter().rev() {
|
||||
rendered.push_str(&e.ts);
|
||||
rendered.push(' ');
|
||||
rendered.push_str(e.kind.as_str());
|
||||
if let Some(a) = &e.agent {
|
||||
rendered.push(' ');
|
||||
rendered.push_str(a);
|
||||
}
|
||||
rendered.push('\n');
|
||||
}
|
||||
let bytes = rendered.as_bytes();
|
||||
if bytes.len() > seen {
|
||||
print!("{}", text_after(&rendered, seen));
|
||||
use std::io::Write;
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
seen = bytes.len();
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
|
||||
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return Ok(t);
|
||||
@@ -71,13 +99,25 @@ impl From<&Event> for LogRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chars of text after byte offset seen (UTF-8 safe).
|
||||
pub fn text_after(text: &str, seen: usize) -> String {
|
||||
text.char_indices()
|
||||
.filter(|(i, _)| *i >= seen)
|
||||
.map(|(_, c)| c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
kinds: Option<&str>,
|
||||
since: Option<&str>,
|
||||
limit: usize,
|
||||
follow: bool,
|
||||
) -> Result<i32> {
|
||||
if follow {
|
||||
return follow_loop(app, agent, kinds, since);
|
||||
}
|
||||
let events = filter_events(app, agent, kinds, since, limit)?;
|
||||
if app.json() {
|
||||
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
|
||||
|
||||
+8
-1
@@ -78,12 +78,19 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
} => run_cmd::restart(app, start, *force, *timeout),
|
||||
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
|
||||
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
|
||||
Command::Top { period } => stats_cmd::run_top(app, period.as_deref()),
|
||||
Command::Report {
|
||||
last_week,
|
||||
last_month,
|
||||
output,
|
||||
} => stats_cmd::run_report(app, *last_week, *last_month, output.as_deref()),
|
||||
Command::Log {
|
||||
agent,
|
||||
kind,
|
||||
since,
|
||||
limit,
|
||||
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit),
|
||||
follow,
|
||||
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit, *follow),
|
||||
Command::Projects { name } => projects_cmd::run(app, name.as_deref()),
|
||||
Command::Timeline {
|
||||
agent,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use crate::events::{self, EventKind};
|
||||
use crate::output::{print_json, Table};
|
||||
use anyhow::Context;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -79,6 +80,61 @@ pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result
|
||||
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() {
|
||||
@@ -114,6 +170,66 @@ pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32>
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user