Skip to main content

rusticity_term/ui/
cloudtrail.rs

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    // Filter
55    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    // Show loaded pages + 1 if more data available
61    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    // Apply pagination
117    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    // If navigated beyond loaded items, show empty page
123    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        // Always allocate space for expansion: (n_rows + n_visible_cols - 1) + 1 table header + 2 borders + 1 title
213        (resource_count + visible_column_count - 1 + 1 + 2 + 1) as u16
214    } else {
215        3 // Empty block with borders + title
216    };
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 down
368        scroll = (scroll + 10).min(line_count.saturating_sub(1));
369        assert_eq!(scroll, 10);
370
371        // Scroll down again
372        scroll = (scroll + 10).min(line_count.saturating_sub(1));
373        assert_eq!(scroll, 20);
374
375        // Scroll up
376        scroll = scroll.saturating_sub(10);
377        assert_eq!(scroll, 10);
378
379        // Scroll up to beginning
380        scroll = scroll.saturating_sub(10);
381        assert_eq!(scroll, 0);
382
383        // Can't scroll below 0
384        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        // Tab (next) cycles to EventRecord
396        focus = focus.next();
397        assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
398
399        // Tab (next) cycles back to Resources
400        focus = focus.next();
401        assert_eq!(focus, CloudTrailDetailFocus::Resources);
402
403        // Shift+Tab (prev) cycles to EventRecord
404        focus = focus.prev();
405        assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
406
407        // Shift+Tab (prev) cycles back to Resources
408        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        // Test Resource type column
442        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        // Test Resource name column
449        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        // Test AWS Config resource timeline column
456        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}