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 total_items = app.cloudtrail_state.table.items.len();
57 let loaded_pages = total_items.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> = if app.cloudtrail_state.table.filter.is_empty() {
97 app.cloudtrail_state.table.items.iter().collect()
98 } else {
99 let q = app.cloudtrail_state.table.filter.to_lowercase();
100 app.cloudtrail_state
101 .table
102 .items
103 .iter()
104 .filter(|e| {
105 e.event_name.to_lowercase().contains(&q)
106 || e.username.to_lowercase().contains(&q)
107 || e.event_source.to_lowercase().contains(&q)
108 || e.resource_type.to_lowercase().contains(&q)
109 || e.resource_name.to_lowercase().contains(&q)
110 || e.source_ip_address.to_lowercase().contains(&q)
111 || e.error_code.to_lowercase().contains(&q)
112 })
113 .collect()
114 };
115
116 let page_size = app.cloudtrail_state.table.page_size.value();
118 let current_page = app.cloudtrail_state.table.selected / page_size;
119 let start_idx = current_page * page_size;
120 let end_idx = (start_idx + page_size).min(filtered_events.len());
121
122 let paginated: Vec<_> = if start_idx < filtered_events.len() {
124 filtered_events[start_idx..end_idx].to_vec()
125 } else {
126 Vec::new()
127 };
128
129 let title = format_title("CloudTrail Events");
130
131 let columns: Vec<Box<dyn Column<CloudTrailEvent>>> = app
132 .cloudtrail_event_visible_column_ids
133 .iter()
134 .filter_map(|col_id| {
135 CloudTrailEventColumn::from_id(col_id).map(|col| {
136 Box::new(CloudTrailEventTableColumn { column_type: col })
137 as Box<dyn Column<CloudTrailEvent>>
138 })
139 })
140 .collect();
141
142 let config = TableConfig {
143 items: paginated,
144 selected_index: app.cloudtrail_state.table.selected % page_size,
145 expanded_index: app.cloudtrail_state.table.expanded_item.and_then(|idx| {
146 if idx >= start_idx && idx < end_idx {
147 Some(idx - start_idx)
148 } else {
149 None
150 }
151 }),
152 columns: &columns,
153 sort_column: "",
154 sort_direction: SortDirection::Asc,
155 title,
156 area: chunks[1],
157 is_active: !matches!(
158 app.mode,
159 Mode::SpaceMenu
160 | Mode::ServicePicker
161 | Mode::ColumnSelector
162 | Mode::ErrorModal
163 | Mode::HelpModal
164 | Mode::RegionPicker
165 | Mode::CalendarPicker
166 | Mode::TabPicker
167 | Mode::FilterInput
168 ),
169 get_expanded_content: Some(Box::new(|event: &CloudTrailEvent| {
170 crate::ui::table::expanded_from_columns(&columns, event)
171 })),
172 };
173
174 render_table(frame, config);
175}
176
177fn render_event_detail(frame: &mut Frame, app: &App, area: Rect) {
178 use crate::ui::{
179 calculate_dynamic_height, format_title, labeled_field, render_fields_with_dynamic_columns,
180 render_json_highlighted, rounded_block,
181 };
182 use ratatui::layout::{Direction, Layout};
183
184 let event = app.cloudtrail_state.current_event.as_ref().unwrap();
185
186 let fields = vec![
187 labeled_field("Event time", &event.event_time),
188 labeled_field("User name", &event.username),
189 labeled_field("Event name", &event.event_name),
190 labeled_field("Event source", &event.event_source),
191 labeled_field("AWS access key", &event.access_key_id),
192 labeled_field("Source IP address", &event.source_ip_address),
193 labeled_field("Event ID", &event.event_id),
194 labeled_field("Request ID", &event.request_id),
195 labeled_field("AWS region", &event.aws_region),
196 labeled_field(
197 "Error code",
198 if event.error_code.is_empty() {
199 "-"
200 } else {
201 &event.error_code
202 },
203 ),
204 labeled_field("Read-only", &event.read_only),
205 ];
206
207 let details_height = calculate_dynamic_height(&fields, area.width.saturating_sub(4)) + 2;
208 let has_resources = !event.resource_type.is_empty() && !event.resource_name.is_empty();
209 let resource_count = if has_resources { 1 } else { 0 };
210 let visible_column_count = app.cloudtrail_resource_visible_column_ids.len();
211 let resources_height = if has_resources {
212 (resource_count + visible_column_count - 1 + 1 + 2 + 1) as u16
214 } else {
215 3 };
217
218 let chunks = Layout::default()
219 .direction(Direction::Vertical)
220 .constraints([
221 Constraint::Length(details_height),
222 Constraint::Length(resources_height),
223 Constraint::Min(0),
224 ])
225 .split(area);
226
227 let block = rounded_block().title(format_title("Details"));
228 let inner = block.inner(chunks[0]);
229 frame.render_widget(block, chunks[0]);
230 render_fields_with_dynamic_columns(frame, inner, fields);
231
232 if has_resources {
233 use crate::cloudtrail::{EventResource, EventResourceColumn};
234
235 let resource = EventResource {
236 resource_type: event.resource_type.clone(),
237 resource_name: event.resource_name.clone(),
238 timeline: "-".to_string(),
239 };
240
241 let all_columns: Vec<EventResourceColumn> = app
242 .cloudtrail_resource_visible_column_ids
243 .iter()
244 .filter_map(EventResourceColumn::from_id)
245 .collect();
246
247 let columns: Vec<Box<dyn Column<EventResource>>> = all_columns
248 .iter()
249 .map(|col| Box::new(*col) as Box<dyn Column<EventResource>>)
250 .collect();
251
252 let config = TableConfig {
253 items: vec![&resource],
254 selected_index: 0,
255 expanded_index: app.cloudtrail_state.resources_expanded_index,
256 columns: &columns,
257 sort_column: "",
258 sort_direction: SortDirection::Asc,
259 title: format_title(&format!("Resources referenced ({})", resource_count)),
260 area: chunks[1],
261 is_active: app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources,
262 get_expanded_content: Some(Box::new(|resource: &EventResource| {
263 crate::ui::table::expanded_from_columns(&columns, resource)
264 })),
265 };
266
267 render_table(frame, config);
268 } else {
269 let resources_block = rounded_block()
270 .title(format_title(&format!(
271 "Resources referenced ({})",
272 resource_count
273 )))
274 .border_style(
275 if app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::Resources {
276 crate::ui::styles::active_border()
277 } else {
278 Style::default()
279 },
280 );
281 frame.render_widget(resources_block, chunks[1]);
282 }
283
284 let is_json_active = app.cloudtrail_state.detail_focus == CloudTrailDetailFocus::EventRecord;
285
286 render_json_highlighted(
287 frame,
288 chunks[2],
289 &event.cloud_trail_event_json,
290 app.cloudtrail_state.event_json_scroll,
291 "Event record",
292 is_json_active,
293 );
294}
295
296#[cfg(test)]
297mod tests {
298 use crate::ui::labeled_field;
299 use ratatui::style::Modifier;
300
301 #[test]
302 fn test_json_scroll_bounds() {
303 let mut scroll = 0usize;
304
305 scroll = scroll.saturating_sub(10);
306 assert_eq!(scroll, 0);
307
308 scroll = 5;
309 scroll = scroll.saturating_sub(10);
310 assert_eq!(scroll, 0);
311 }
312
313 #[test]
314 fn test_labeled_field_bolds_label() {
315 let line = labeled_field("Test Label", "test value");
316 assert_eq!(line.spans.len(), 2);
317 assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD));
318 assert_eq!(line.spans[0].content, "Test Label: ");
319 assert_eq!(line.spans[1].content, "test value");
320 }
321
322 #[test]
323 fn test_event_json_has_newlines() {
324 use crate::cloudtrail::CloudTrailEvent;
325
326 let event = CloudTrailEvent {
327 event_time: "2024-01-01T12:00:00Z".to_string(),
328 event_name: "CreateBucket".to_string(),
329 username: "test-user".to_string(),
330 event_source: "s3.amazonaws.com".to_string(),
331 resource_type: "AWS::S3::Bucket".to_string(),
332 resource_name: "test-bucket".to_string(),
333 read_only: "false".to_string(),
334 aws_region: "us-east-1".to_string(),
335 event_id: "12345".to_string(),
336 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
337 source_ip_address: "192.0.2.1".to_string(),
338 error_code: "".to_string(),
339 request_id: "req-12345".to_string(),
340 event_type: "AwsApiCall".to_string(),
341 cloud_trail_event_json: r#"{
342 "eventVersion": "1.08",
343 "userIdentity": {
344 "type": "IAMUser"
345 }
346}"#
347 .to_string(),
348 };
349
350 assert!(
351 event.cloud_trail_event_json.contains('\n'),
352 "Event JSON should contain newlines for proper formatting"
353 );
354 assert!(event.cloud_trail_event_json.lines().count() > 1);
355 }
356
357 #[test]
358 fn test_event_json_scroll_bounds() {
359 let json_with_50_lines = (0..50)
360 .map(|i| format!("line {}", i))
361 .collect::<Vec<_>>()
362 .join("\n");
363 let line_count = json_with_50_lines.lines().count();
364
365 let mut scroll = 0usize;
366
367 scroll = (scroll + 10).min(line_count.saturating_sub(1));
369 assert_eq!(scroll, 10);
370
371 scroll = (scroll + 10).min(line_count.saturating_sub(1));
373 assert_eq!(scroll, 20);
374
375 scroll = scroll.saturating_sub(10);
377 assert_eq!(scroll, 10);
378
379 scroll = scroll.saturating_sub(10);
381 assert_eq!(scroll, 0);
382
383 scroll = scroll.saturating_sub(10);
385 assert_eq!(scroll, 0);
386 }
387
388 #[test]
389 fn test_detail_focus_cycles_with_tab_and_shift_tab() {
390 use crate::app::CloudTrailDetailFocus;
391 use crate::common::CyclicEnum;
392
393 let mut focus = CloudTrailDetailFocus::Resources;
394
395 focus = focus.next();
397 assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
398
399 focus = focus.next();
401 assert_eq!(focus, CloudTrailDetailFocus::Resources);
402
403 focus = focus.prev();
405 assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
406
407 focus = focus.prev();
409 assert_eq!(focus, CloudTrailDetailFocus::Resources);
410 }
411
412 #[test]
413 fn test_console_url_includes_event_id() {
414 let event_id = "90c72977-31e0-4079-9a74-ee25e5d7aadf";
415 let region = "us-east-1";
416
417 let url = format!(
418 "https://{}.console.aws.amazon.com/cloudtrailv2/home?region={}#/events/{}",
419 region, region, event_id
420 );
421
422 assert!(url.contains(event_id));
423 assert!(url.contains("cloudtrailv2"));
424 assert_eq!(
425 url,
426 "https://us-east-1.console.aws.amazon.com/cloudtrailv2/home?region=us-east-1#/events/90c72977-31e0-4079-9a74-ee25e5d7aadf"
427 );
428 }
429
430 #[test]
431 fn test_event_resource_column_renders_all_three_columns() {
432 use crate::cloudtrail::{EventResource, EventResourceColumn};
433 use crate::ui::table::Column;
434
435 let resource = EventResource {
436 resource_type: "AWS::S3::Bucket".to_string(),
437 resource_name: "my-bucket".to_string(),
438 timeline: "-".to_string(),
439 };
440
441 let col1 = EventResourceColumn::ResourceType;
443 assert_eq!(col1.name(), "Resource type");
444 assert_eq!(col1.width(), 30);
445 let (value, _) = col1.render(&resource);
446 assert_eq!(value, "AWS::S3::Bucket");
447
448 let col2 = EventResourceColumn::ResourceName;
450 assert_eq!(col2.name(), "Resource name");
451 assert_eq!(col2.width(), 50);
452 let (value, _) = col2.render(&resource);
453 assert_eq!(value, "my-bucket");
454
455 let col3 = EventResourceColumn::Timeline;
457 assert_eq!(col3.name(), "AWS Config resource timeline");
458 assert_eq!(col3.width(), 30);
459 let (value, _) = col3.render(&resource);
460 assert_eq!(value, "-");
461 }
462}