1use ratatui::{
15 Frame,
16 layout::{Constraint, Direction, Layout, Rect},
17 style::{Color, Modifier, Style, Stylize},
18 text::{Line, Span},
19 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
20};
21
22use crate::audit::{AuditCategory, AuditEntry, AuditOutcome, try_audit_log};
23
24const SNAPSHOT_LIMIT: usize = 500;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum AuditFilter {
30 Subagents,
32 All,
34}
35
36impl AuditFilter {
37 fn matches(self, e: &AuditEntry) -> bool {
38 match self {
39 Self::All => true,
40 Self::Subagents => matches!(
41 e.category,
42 AuditCategory::Swarm | AuditCategory::ToolExecution | AuditCategory::Cognition
43 ),
44 }
45 }
46
47 pub fn label(self) -> &'static str {
49 match self {
50 Self::Subagents => "Subagents",
51 Self::All => "All",
52 }
53 }
54}
55
56#[derive(Debug)]
58pub struct AuditViewState {
59 pub entries: Vec<AuditEntry>,
60 pub selected: usize,
61 pub filter: AuditFilter,
62 pub refresh_counter: u64,
65}
66
67impl Default for AuditViewState {
68 fn default() -> Self {
69 Self {
70 entries: Vec::new(),
71 selected: 0,
72 filter: AuditFilter::Subagents,
73 refresh_counter: 0,
74 }
75 }
76}
77
78impl AuditViewState {
79 pub fn select_prev(&mut self) {
81 self.selected = self.selected.saturating_sub(1);
82 }
83
84 pub fn select_next(&mut self) {
86 if self.selected + 1 < self.entries.len() {
87 self.selected += 1;
88 }
89 }
90
91 pub fn toggle_filter(&mut self) {
93 self.filter = match self.filter {
94 AuditFilter::Subagents => AuditFilter::All,
95 AuditFilter::All => AuditFilter::Subagents,
96 };
97 self.selected = 0;
98 }
99}
100
101pub async fn refresh_audit_snapshot(state: &mut AuditViewState) {
106 let Some(log) = try_audit_log() else {
107 return;
108 };
109 let recent = log.recent(SNAPSHOT_LIMIT).await;
110 state.entries = recent
111 .into_iter()
112 .filter(|e| state.filter.matches(e))
113 .collect();
114 if state.selected >= state.entries.len() {
115 state.selected = state.entries.len().saturating_sub(1);
116 }
117 state.refresh_counter = state.refresh_counter.wrapping_add(1);
118}
119
120pub fn render_audit_view(f: &mut Frame, state: &mut AuditViewState, area: Rect) {
122 let chunks = Layout::default()
123 .direction(Direction::Vertical)
124 .constraints([
125 Constraint::Length(1),
126 Constraint::Min(1),
127 Constraint::Length(1),
128 ])
129 .split(area);
130
131 render_header(f, state, chunks[0]);
132 render_body(f, state, chunks[1]);
133 render_footer(f, state, chunks[2]);
134}
135
136fn render_header(f: &mut Frame, state: &AuditViewState, area: Rect) {
137 let line = Line::from(vec![
138 "Audit".bold(),
139 " · ".dim(),
140 Span::styled(
141 state.filter.label(),
142 Style::default()
143 .fg(Color::Cyan)
144 .add_modifier(Modifier::BOLD),
145 ),
146 " · ".dim(),
147 format!("{} entries", state.entries.len()).into(),
148 ]);
149 f.render_widget(Paragraph::new(line), area);
150}
151
152fn render_body(f: &mut Frame, state: &mut AuditViewState, area: Rect) {
153 let chunks = Layout::default()
154 .direction(Direction::Horizontal)
155 .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
156 .split(area);
157
158 render_list(f, state, chunks[0]);
159 render_detail(f, state, chunks[1]);
160}
161
162fn render_list(f: &mut Frame, state: &mut AuditViewState, area: Rect) {
163 let items: Vec<ListItem> = state
164 .entries
165 .iter()
166 .map(|e| ListItem::new(format_row(e)))
167 .collect();
168
169 let mut list_state = ListState::default();
170 if !state.entries.is_empty() {
171 list_state.select(Some(state.selected));
172 }
173
174 let list = List::new(items)
175 .block(Block::default().borders(Borders::ALL).title(" Events "))
176 .highlight_style(
177 Style::default()
178 .bg(Color::DarkGray)
179 .add_modifier(Modifier::BOLD),
180 )
181 .highlight_symbol("▶ ");
182 f.render_stateful_widget(list, area, &mut list_state);
183}
184
185fn render_detail(f: &mut Frame, state: &AuditViewState, area: Rect) {
186 let block = Block::default().borders(Borders::ALL).title(" Detail ");
187 let Some(entry) = state.entries.get(state.selected) else {
188 f.render_widget(
189 Paragraph::new("No audit entries yet.".dim()).block(block),
190 area,
191 );
192 return;
193 };
194 let detail_json = entry
195 .detail
196 .as_ref()
197 .map(|v| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()))
198 .unwrap_or_else(|| "(none)".to_string());
199
200 let body = vec![
201 kv_line("id", &entry.id),
202 kv_line("time", &entry.timestamp.format("%H:%M:%S%.3f").to_string()),
203 kv_line("category", &format!("{:?}", entry.category)),
204 kv_line("action", &entry.action),
205 kv_line(
206 "outcome",
207 match entry.outcome {
208 AuditOutcome::Success => "success",
209 AuditOutcome::Failure => "failure",
210 AuditOutcome::Denied => "denied",
211 },
212 ),
213 kv_line("principal", entry.principal.as_deref().unwrap_or("-")),
214 kv_line("session", entry.session_id.as_deref().unwrap_or("-")),
215 kv_line(
216 "duration_ms",
217 &entry
218 .duration_ms
219 .map(|d| d.to_string())
220 .unwrap_or_else(|| "-".into()),
221 ),
222 Line::from(""),
223 "detail:".dim().into(),
224 Line::from(detail_json),
225 ];
226
227 f.render_widget(
228 Paragraph::new(body).block(block).wrap(Wrap { trim: false }),
229 area,
230 );
231}
232
233fn render_footer(f: &mut Frame, _state: &AuditViewState, area: Rect) {
234 let hint = Line::from(vec![
235 "↑/↓".bold(),
236 " select · ".dim(),
237 "f".bold(),
238 " toggle filter · ".dim(),
239 "Esc".bold(),
240 " chat".dim(),
241 ]);
242 f.render_widget(Paragraph::new(hint), area);
243}
244
245fn format_row(e: &AuditEntry) -> Line<'static> {
246 let ts = e.timestamp.format("%H:%M:%S").to_string();
247 let outcome_color = match e.outcome {
248 AuditOutcome::Success => Color::Green,
249 AuditOutcome::Failure => Color::Red,
250 AuditOutcome::Denied => Color::Yellow,
251 };
252 let outcome_mark = match e.outcome {
253 AuditOutcome::Success => "✓",
254 AuditOutcome::Failure => "✗",
255 AuditOutcome::Denied => "⊘",
256 };
257 Line::from(vec![
258 Span::styled(ts, Style::default().fg(Color::DarkGray)),
259 " ".into(),
260 Span::styled(outcome_mark.to_string(), Style::default().fg(outcome_color)),
261 " ".into(),
262 Span::styled(
263 format!("{:<10}", category_short(e.category)),
264 Style::default().fg(Color::Cyan),
265 ),
266 " ".into(),
267 Span::raw(truncate(&e.action, 40)),
268 " ".into(),
269 Span::styled(
270 format!("[{}]", e.principal.as_deref().unwrap_or("-")),
271 Style::default().fg(Color::DarkGray),
272 ),
273 ])
274}
275
276fn kv_line(key: &str, value: &str) -> Line<'static> {
277 Line::from(vec![
278 Span::styled(format!("{key:>12}: "), Style::default().fg(Color::DarkGray)),
279 Span::raw(value.to_string()),
280 ])
281}
282
283fn category_short(c: AuditCategory) -> &'static str {
284 match c {
285 AuditCategory::Api => "api",
286 AuditCategory::ToolExecution => "tool",
287 AuditCategory::Session => "session",
288 AuditCategory::Cognition => "cognition",
289 AuditCategory::Swarm => "swarm",
290 AuditCategory::Auth => "auth",
291 AuditCategory::K8s => "k8s",
292 AuditCategory::Sandbox => "sandbox",
293 AuditCategory::Config => "config",
294 }
295}
296
297fn truncate(s: &str, max: usize) -> String {
298 if s.chars().count() <= max {
299 s.to_string()
300 } else {
301 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
302 format!("{cut}…")
303 }
304}