Skip to main content

tryaudex_core/
dashboard.rs

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/// Usage statistics for the dashboard.
10#[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
43/// Generate usage statistics from the audit log.
44pub 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
142/// Render stats as a CLI text report.
143pub 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
217/// Render stats as JSON for API consumption.
218pub 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}