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