1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::audit::{AuditEntry, AuditEvent, AuditLog};
7use crate::error::Result;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UsageStats {
12 pub total_sessions: usize,
13 pub active_sessions: usize,
14 pub failed_sessions: usize,
15 pub sessions_by_provider: HashMap<String, usize>,
16 pub sessions_by_day: Vec<DayStat>,
17 pub top_actions: Vec<ActionStat>,
18 pub top_roles: Vec<RoleStat>,
19 pub avg_ttl_seconds: f64,
20 pub period_start: DateTime<Utc>,
21 pub period_end: DateTime<Utc>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DayStat {
26 pub date: String,
27 pub count: usize,
28 pub failed: usize,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ActionStat {
33 pub action: String,
34 pub count: usize,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RoleStat {
39 pub role: String,
40 pub session_count: usize,
41}
42
43pub fn compute_stats(audit: &AuditLog, days: u32) -> Result<UsageStats> {
45 let now = Utc::now();
46 let cutoff = now - chrono::Duration::days(days as i64);
47
48 let entries = audit.read(None)?;
49 let recent: Vec<&AuditEntry> = entries
50 .iter()
51 .filter(|e| e.timestamp >= cutoff)
52 .collect();
53
54 let mut total = 0usize;
55 let mut failed = 0usize;
56 let mut by_provider: HashMap<String, usize> = HashMap::new();
57 let mut by_day: HashMap<String, (usize, usize)> = HashMap::new();
58 let mut action_counts: HashMap<String, usize> = HashMap::new();
59 let mut role_counts: HashMap<String, usize> = HashMap::new();
60 let mut ttl_sum = 0u64;
61 let mut active_sessions: std::collections::HashSet<String> = std::collections::HashSet::new();
62
63 for entry in &recent {
64 match &entry.event {
65 AuditEvent::SessionCreated {
66 role_arn,
67 ttl_seconds,
68 allowed_actions,
69 ..
70 } => {
71 total += 1;
72 ttl_sum += ttl_seconds;
73 active_sessions.insert(entry.session_id.clone());
74
75 *by_provider.entry(entry.provider.clone()).or_default() += 1;
76
77 let day = entry.timestamp.format("%Y-%m-%d").to_string();
78 let stat = by_day.entry(day).or_insert((0, 0));
79 stat.0 += 1;
80
81 for action in allowed_actions {
82 *action_counts.entry(action.clone()).or_default() += 1;
83 }
84
85 *role_counts.entry(role_arn.clone()).or_default() += 1;
86 }
87 AuditEvent::SessionEnded { status, .. } => {
88 active_sessions.remove(&entry.session_id);
89 if status == "failed" {
90 failed += 1;
91 let day = entry.timestamp.format("%Y-%m-%d").to_string();
92 let stat = by_day.entry(day).or_insert((0, 0));
93 stat.1 += 1;
94 }
95 }
96 _ => {}
97 }
98 }
99
100 let mut sessions_by_day: Vec<DayStat> = by_day
101 .into_iter()
102 .map(|(date, (count, failed))| DayStat { date, count, failed })
103 .collect();
104 sessions_by_day.sort_by(|a, b| a.date.cmp(&b.date));
105
106 let mut top_actions: Vec<ActionStat> = action_counts
107 .into_iter()
108 .map(|(action, count)| ActionStat { action, count })
109 .collect();
110 top_actions.sort_by(|a, b| b.count.cmp(&a.count));
111 top_actions.truncate(20);
112
113 let mut top_roles: Vec<RoleStat> = role_counts
114 .into_iter()
115 .map(|(role, session_count)| RoleStat { role, session_count })
116 .collect();
117 top_roles.sort_by(|a, b| b.session_count.cmp(&a.session_count));
118 top_roles.truncate(10);
119
120 Ok(UsageStats {
121 total_sessions: total,
122 active_sessions: active_sessions.len(),
123 failed_sessions: failed,
124 sessions_by_provider: by_provider,
125 sessions_by_day,
126 top_actions,
127 top_roles,
128 avg_ttl_seconds: if total > 0 { ttl_sum as f64 / total as f64 } else { 0.0 },
129 period_start: cutoff,
130 period_end: now,
131 })
132}
133
134pub fn render_text(stats: &UsageStats) -> String {
136 let mut out = String::new();
137
138 out.push_str(&format!(
139 "Audex Usage Dashboard ({} to {})\n",
140 stats.period_start.format("%Y-%m-%d"),
141 stats.period_end.format("%Y-%m-%d")
142 ));
143 out.push_str(&"─".repeat(60));
144 out.push('\n');
145
146 out.push_str(&format!(
147 " Total sessions: {} (active: {}, failed: {})\n",
148 stats.total_sessions, stats.active_sessions, stats.failed_sessions
149 ));
150 out.push_str(&format!(" Avg TTL: {:.0}s\n", stats.avg_ttl_seconds));
151
152 if !stats.sessions_by_provider.is_empty() {
153 let providers: Vec<String> = stats
154 .sessions_by_provider
155 .iter()
156 .map(|(k, v)| format!("{}: {}", k, v))
157 .collect();
158 out.push_str(&format!(" By provider: {}\n", providers.join(", ")));
159 }
160
161 out.push('\n');
162
163 if !stats.sessions_by_day.is_empty() {
164 out.push_str("Sessions by day:\n");
165 for day in &stats.sessions_by_day {
166 let bar = "█".repeat(day.count.min(40));
167 let fail_note = if day.failed > 0 {
168 format!(" ({} failed)", day.failed)
169 } else {
170 String::new()
171 };
172 out.push_str(&format!(" {} {:>3} {}{}\n", day.date, day.count, bar, fail_note));
173 }
174 out.push('\n');
175 }
176
177 if !stats.top_actions.is_empty() {
178 out.push_str("Top actions:\n");
179 for (i, action) in stats.top_actions.iter().take(10).enumerate() {
180 out.push_str(&format!(" {:>2}. {} ({}x)\n", i + 1, action.action, action.count));
181 }
182 out.push('\n');
183 }
184
185 if !stats.top_roles.is_empty() {
186 out.push_str("Top roles:\n");
187 for role in stats.top_roles.iter().take(5) {
188 out.push_str(&format!(" {} ({} sessions)\n", role.role, role.session_count));
189 }
190 }
191
192 out
193}
194
195pub fn render_json(stats: &UsageStats) -> Result<String> {
197 serde_json::to_string_pretty(stats).map_err(|e| {
198 crate::error::AvError::InvalidPolicy(format!("Failed to serialize stats: {}", e))
199 })
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_usage_stats_serialize() {
208 let stats = UsageStats {
209 total_sessions: 42,
210 active_sessions: 2,
211 failed_sessions: 3,
212 sessions_by_provider: HashMap::from([
213 ("aws".to_string(), 30),
214 ("gcp".to_string(), 12),
215 ]),
216 sessions_by_day: vec![
217 DayStat { date: "2026-04-01".to_string(), count: 10, failed: 1 },
218 DayStat { date: "2026-04-02".to_string(), count: 15, failed: 0 },
219 ],
220 top_actions: vec![
221 ActionStat { action: "s3:GetObject".to_string(), count: 25 },
222 ActionStat { action: "s3:PutObject".to_string(), count: 10 },
223 ],
224 top_roles: vec![
225 RoleStat { role: "arn:aws:iam::123:role/TestRole".to_string(), session_count: 20 },
226 ],
227 avg_ttl_seconds: 900.0,
228 period_start: Utc::now() - chrono::Duration::days(7),
229 period_end: Utc::now(),
230 };
231
232 let json = render_json(&stats).unwrap();
233 assert!(json.contains("total_sessions"));
234 assert!(json.contains("42"));
235
236 let text = render_text(&stats);
237 assert!(text.contains("Total sessions: 42"));
238 assert!(text.contains("s3:GetObject"));
239 }
240
241 #[test]
242 fn test_render_text_empty() {
243 let stats = UsageStats {
244 total_sessions: 0,
245 active_sessions: 0,
246 failed_sessions: 0,
247 sessions_by_provider: HashMap::new(),
248 sessions_by_day: vec![],
249 top_actions: vec![],
250 top_roles: vec![],
251 avg_ttl_seconds: 0.0,
252 period_start: Utc::now(),
253 period_end: Utc::now(),
254 };
255
256 let text = render_text(&stats);
257 assert!(text.contains("Total sessions: 0"));
258 }
259}