1use crate::app::{App, CloudTrailDetailFocus};
2use crate::cloudtrail::{CloudTrailEvent, CloudTrailEventColumn};
3use crate::common::{InputFocus, SortDirection};
4use crate::keymap::Mode;
5use crate::ui::table::{render_table, Column, TableConfig};
6use crate::ui::{format_title, vertical};
7use ratatui::layout::{Constraint, Rect};
8use ratatui::style::Style;
9use ratatui::Frame;
10
11struct CloudTrailEventTableColumn {
12 column_type: CloudTrailEventColumn,
13}
14
15impl Column<CloudTrailEvent> for CloudTrailEventTableColumn {
16 fn name(&self) -> &str {
17 Box::leak(self.column_type.name().into_boxed_str())
18 }
19
20 fn width(&self) -> u16 {
21 self.column_type.width()
22 }
23
24 fn render(&self, event: &CloudTrailEvent) -> (String, Style) {
25 match self.column_type {
26 CloudTrailEventColumn::EventName => (event.event_name.clone(), Style::default()),
27 CloudTrailEventColumn::EventTime => (event.event_time.clone(), Style::default()),
28 CloudTrailEventColumn::Username => (event.username.clone(), Style::default()),
29 CloudTrailEventColumn::EventSource => (event.event_source.clone(), Style::default()),
30 CloudTrailEventColumn::ResourceType => (event.resource_type.clone(), Style::default()),
31 CloudTrailEventColumn::ResourceName => (event.resource_name.clone(), Style::default()),
32 CloudTrailEventColumn::ReadOnly => (event.read_only.clone(), Style::default()),
33 CloudTrailEventColumn::AwsRegion => (event.aws_region.clone(), Style::default()),
34 CloudTrailEventColumn::EventId => (event.event_id.clone(), Style::default()),
35 CloudTrailEventColumn::AccessKeyId => (event.access_key_id.clone(), Style::default()),
36 CloudTrailEventColumn::SourceIpAddress => {
37 (event.source_ip_address.clone(), Style::default())
38 }
39 CloudTrailEventColumn::ErrorCode => (event.error_code.clone(), Style::default()),
40 CloudTrailEventColumn::RequestId => (event.request_id.clone(), Style::default()),
41 CloudTrailEventColumn::EventType => (event.event_type.clone(), Style::default()),
42 }
43 }
44}
45
46pub fn render_events(frame: &mut Frame, app: &App, area: Rect) {
47 if app.cloudtrail_state.current_event.is_some() {
48 render_event_detail(frame, app, area);
49 return;
50 }
51
52 let chunks = vertical([Constraint::Length(3), Constraint::Min(0)], area);
53
54 let page_size = app.cloudtrail_state.table.page_size.value();
56 let filtered_count = app.cloudtrail_state.table.items.len();
57 let loaded_pages = filtered_count.div_ceil(page_size);
58 let current_page = app.cloudtrail_state.table.selected / page_size;
59
60 let max_page = if app.cloudtrail_state.table.next_token.is_some() {
62 loaded_pages + 1
63 } else {
64 loaded_pages
65 };
66
67 let pagination = (0..max_page)
68 .map(|i| {
69 if i == current_page {
70 format!("[{}]", i + 1)
71 } else {
72 format!("{}", i + 1)
73 }
74 })
75 .collect::<Vec<_>>()
76 .join(" ")
77 + if app.cloudtrail_state.table.next_token.is_some() {
78 " ..."
79 } else {
80 ""
81 };
82
83 crate::ui::filter::render_simple_filter(
84 frame,
85 chunks[0],
86 crate::ui::filter::SimpleFilterConfig {
87 filter_text: &app.cloudtrail_state.table.filter,
88 placeholder: "Search",
89 pagination: &pagination,
90 mode: app.mode,
91 is_input_focused: app.cloudtrail_state.input_focus == InputFocus::Filter,
92 is_pagination_focused: app.cloudtrail_state.input_focus == InputFocus::Pagination,
93 },
94 );
95
96 let filtered_events: Vec<&CloudTrailEvent> = app.cloudtrail_state.table.items.iter().collect();
97
98 let page_size = app.cloudtrail_state.table.page_size.value();
100 let current_page = app.cloudtrail_state.table.selected / page_size;
101 let start_idx = current_page * page_size;
102 let end_idx = (start_idx + page_size).min(filtered_events.len());
103
104 let paginated: Vec<_> = if start_idx < filtered_events.len() {
106 filtered_events[start_idx..end_idx].to_vec()
107 } else {
108 Vec::new()
109 };
110
111 let title = format_title("CloudTrail Events");
112
113 let columns: Vec<Box<dyn Column<CloudTrailEvent>>> = app
114 .cloudtrail_event_visible_column_ids
115 .iter()
116 .filter_map(|col_id| {
117 CloudTrailEventColumn::from_id(col_id).map(|col| {
118 Box::new(CloudTrailEventTableColumn { column_type: col })
119 as Box<dyn Column<CloudTrailEvent>>
120 })
121 })
122 .collect();
123
124 let config = TableConfig {
125 items: paginated,
126 selected_index: app.cloudtrail_state.table.selected % page_size,
127 expanded_index: app.cloudtrail_state.table.expanded_item.and_then(|idx| {
128 if idx >= start_idx && idx < end_idx {
129 Some(idx - start_idx)
130 } else {
131 None
132 }
133 }),
134 columns: &columns,
135 sort_column: "",
136 sort_direction: SortDirection::Asc,
137 title,
138 area: chunks[1],
139 is_active: !matches!(
140 app.mode,
141 Mode::SpaceMenu
142 | Mode::ServicePicker
143 | Mode::ColumnSelector
144 | Mode::ErrorModal
145 | Mode::HelpModal
146 | Mode::RegionPicker
147 | Mode::CalendarPicker
148 | Mode::TabPicker
149 | Mode::FilterInput
150 ),
151 get_expanded_content: Some(Box::new(|event: &CloudTrailEvent| {
152 crate::ui::table::expanded_from_columns(&columns, event)
153 })),
154 };
155
156 render_table(frame, config);
157}
158
159fn render_event_detail(frame: &mut Frame, app: &App, area: Rect) {
160 use crate::ui::{
161 calculate_dynamic_height, format_title, labeled_field, render_fields_with_dynamic_columns,
162 render_json_highlighted, rounded_block,
163 };
164 use ratatui::layout::{Direction, Layout};
165
166 let event = app.cloudtrail_state.current_event.as_ref().unwrap();
167
168 let fields = vec![
169 labeled_field("Event time", &event.event_time),
170 labeled_field("User name", &event.username),
171 labeled_field("Event name", &event.event_name),
172 labeled_field("Event source", &event.event_source),
173 labeled_field("AWS access key", &event.access_key_id),
174 labeled_field("Source IP address", &event.source_ip_address),
175 labeled_field("Event ID", &event.event_id),
176 labeled_field("Request ID", &event.request_id),
177 labeled_field("AWS region", &event.aws_region),
178 labeled_field(
179 "Error code",
180 if event.error_code.is_empty() {
181 "-"
182 } else {
183 &event.error_code
184 },
185 ),
186 labeled_field("Read-only", &event.read_only),
187 ];
188
189 let details_height = calculate_dynamic_height(&fields, area.width.saturating_sub(4)) + 2;
190 let has_resources = !event.resource_type.is_empty() && !event.resource_name.is_empty();
191 let resource_count = if has_resources { 1 } else { 0 };
192 let visible_column_count = app.cloudtrail_resource_visible_column_ids.len();
193 let resources_height = if has_resources {
194 (resource_count + visible_column_count - 1 + 1 + 2 + 1) as u16
196 } else {
197 3 };
199
200 let chunks = Layout::default()
201 .direction(Direction::Vertical)
202 .constraints([
203 Constraint::Length(details_height),
204 Constraint::Length(resources_height),
205 Constraint::Min(0),
206 ])
207 .split(area);
208
209 let block = rounded_block().title(format_title("Details"));
210 let inner = block.inner(chunks[0]);
211 frame.render_widget(block, chunks[0]);
212 render_fields_with_dynamic_columns(frame, inner, fields);
213
214 if has_resources {
215 use crate::cloudtrail::{EventResource, EventResourceColumn};
216
217 let resource = EventResource {
218 resource_type: event.resource_type.clone(),
219 resource_name: event.resource_name.clone(),
220 timeline: "-".to_string(),
221 };
222
223 let all_columns: Vec<EventResourceColumn> = app
224 .cloudtrail_resource_visible_column_ids
225 .iter()
226 .filter_map(EventResourceColumn::from_id)
227 .collect();
228
229 let columns: Vec<Box<dyn Column<EventResource>>> = all_columns
230 .iter()
231 .map(|col| Box::new(*col) as Box<dyn Column<EventResource>>)
232 .collect();
233
234 let config = TableConfig {
235 items: vec![&resource],
236 selected_index: 0,
237 expanded_index: app.cloudtrail_state.resources_expanded_index,
238 columns: &columns,
239 sort_column: "",
240 sort_direction: SortDirection::Asc,
241 title: format_title(&format!("Resources referenced ({})", resource_count)),
242 area: chunks[1],
243 is_active: app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources,
244 get_expanded_content: Some(Box::new(|resource: &EventResource| {
245 crate::ui::table::expanded_from_columns(&columns, resource)
246 })),
247 };
248
249 render_table(frame, config);
250 } else {
251 let resources_block = rounded_block()
252 .title(format_title(&format!(
253 "Resources referenced ({})",
254 resource_count
255 )))
256 .border_style(
257 if app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources {
258 crate::ui::styles::active_border()
259 } else {
260 Style::default()
261 },
262 );
263 frame.render_widget(resources_block, chunks[1]);
264 }
265
266 let is_json_active = app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::EventRecord;
267
268 render_json_highlighted(
269 frame,
270 chunks[2],
271 &event.cloud_trail_event_json,
272 app.cloudtrail_state.event_json_scroll,
273 "Event record",
274 is_json_active,
275 );
276}
277
278#[cfg(test)]
279mod tests {
280 use crate::ui::labeled_field;
281 use ratatui::style::Modifier;
282
283 #[test]
284 fn test_json_scroll_bounds() {
285 let mut scroll = 0usize;
286
287 scroll = scroll.saturating_sub(10);
288 assert_eq!(scroll, 0);
289
290 scroll = 5;
291 scroll = scroll.saturating_sub(10);
292 assert_eq!(scroll, 0);
293 }
294
295 #[test]
296 fn test_labeled_field_bolds_label() {
297 let line = labeled_field("Test Label", "test value");
298 assert_eq!(line.spans.len(), 2);
299 assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD));
300 assert_eq!(line.spans[0].content, "Test Label: ");
301 assert_eq!(line.spans[1].content, "test value");
302 }
303
304 #[test]
305 fn test_event_json_has_newlines() {
306 use crate::cloudtrail::CloudTrailEvent;
307
308 let event = CloudTrailEvent {
309 event_time: "2024-01-01T12:00:00Z".to_string(),
310 event_name: "CreateBucket".to_string(),
311 username: "test-user".to_string(),
312 event_source: "s3.amazonaws.com".to_string(),
313 resource_type: "AWS::S3::Bucket".to_string(),
314 resource_name: "test-bucket".to_string(),
315 read_only: "false".to_string(),
316 aws_region: "us-east-1".to_string(),
317 event_id: "12345".to_string(),
318 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
319 source_ip_address: "192.0.2.1".to_string(),
320 error_code: "".to_string(),
321 request_id: "req-12345".to_string(),
322 event_type: "AwsApiCall".to_string(),
323 cloud_trail_event_json: r#"{
324 "eventVersion": "1.08",
325 "userIdentity": {
326 "type": "IAMUser"
327 }
328}"#
329 .to_string(),
330 };
331
332 assert!(
333 event.cloud_trail_event_json.contains('\n'),
334 "Event JSON should contain newlines for proper formatting"
335 );
336 assert!(event.cloud_trail_event_json.lines().count() > 1);
337 }
338
339 #[test]
340 fn test_event_json_scroll_bounds() {
341 let json_with_50_lines = (0..50)
342 .map(|i| format!("line {}", i))
343 .collect::<Vec<_>>()
344 .join("\n");
345 let line_count = json_with_50_lines.lines().count();
346
347 let mut scroll = 0usize;
348
349 scroll = (scroll + 10).min(line_count.saturating_sub(1));
351 assert_eq!(scroll, 10);
352
353 scroll = (scroll + 10).min(line_count.saturating_sub(1));
355 assert_eq!(scroll, 20);
356
357 scroll = scroll.saturating_sub(10);
359 assert_eq!(scroll, 10);
360
361 scroll = scroll.saturating_sub(10);
363 assert_eq!(scroll, 0);
364
365 scroll = scroll.saturating_sub(10);
367 assert_eq!(scroll, 0);
368 }
369
370 #[test]
371 fn test_detail_focus_cycles_with_tab_and_shift_tab() {
372 use crate::app::CloudTrailDetailFocus;
373 use crate::common::CyclicEnum;
374
375 let mut focus = CloudTrailDetailFocus::Resources;
376
377 focus = focus.next();
379 assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
380
381 focus = focus.next();
383 assert_eq!(focus, CloudTrailDetailFocus::Resources);
384
385 focus = focus.prev();
387 assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
388
389 focus = focus.prev();
391 assert_eq!(focus, CloudTrailDetailFocus::Resources);
392 }
393
394 #[test]
395 fn test_console_url_includes_event_id() {
396 let event_id = "90c72977-31e0-4079-9a74-ee25e5d7aadf";
397 let region = "us-east-1";
398
399 let url = format!(
400 "https://{}.console.aws.amazon.com/cloudtrailv2/home?region={}#/events/{}",
401 region, region, event_id
402 );
403
404 assert!(url.contains(event_id));
405 assert!(url.contains("cloudtrailv2"));
406 assert_eq!(
407 url,
408 "https://us-east-1.console.aws.amazon.com/cloudtrailv2/home?region=us-east-1#/events/90c72977-31e0-4079-9a74-ee25e5d7aadf"
409 );
410 }
411
412 #[test]
413 fn test_event_resource_column_renders_all_three_columns() {
414 use crate::cloudtrail::{EventResource, EventResourceColumn};
415 use crate::ui::table::Column;
416
417 let resource = EventResource {
418 resource_type: "AWS::S3::Bucket".to_string(),
419 resource_name: "my-bucket".to_string(),
420 timeline: "-".to_string(),
421 };
422
423 let col1 = EventResourceColumn::ResourceType;
425 assert_eq!(col1.name(), "Resource type");
426 assert_eq!(col1.width(), 30);
427 let (value, _) = col1.render(&resource);
428 assert_eq!(value, "AWS::S3::Bucket");
429
430 let col2 = EventResourceColumn::ResourceName;
432 assert_eq!(col2.name(), "Resource name");
433 assert_eq!(col2.width(), 50);
434 let (value, _) = col2.render(&resource);
435 assert_eq!(value, "my-bucket");
436
437 let col3 = EventResourceColumn::Timeline;
439 assert_eq!(col3.name(), "AWS Config resource timeline");
440 assert_eq!(col3.width(), 30);
441 let (value, _) = col3.render(&resource);
442 assert_eq!(value, "-");
443 }
444}