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
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
134/// Render stats as a CLI text report.
135pub 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
195/// Render stats as JSON for API consumption.
196pub 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}