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 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    // 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> = app.cloudtrail_state.table.items.iter().collect();
97
98    // Apply pagination
99    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    // If navigated beyond loaded items, show empty page
105    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        // Always allocate space for expansion: (n_rows + n_visible_cols - 1) + 1 table header + 2 borders + 1 title
195        (resource_count + visible_column_count - 1 + 1 + 2 + 1) as u16
196    } else {
197        3 // Empty block with borders + title
198    };
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 down
350        scroll = (scroll + 10).min(line_count.saturating_sub(1));
351        assert_eq!(scroll, 10);
352
353        // Scroll down again
354        scroll = (scroll + 10).min(line_count.saturating_sub(1));
355        assert_eq!(scroll, 20);
356
357        // Scroll up
358        scroll = scroll.saturating_sub(10);
359        assert_eq!(scroll, 10);
360
361        // Scroll up to beginning
362        scroll = scroll.saturating_sub(10);
363        assert_eq!(scroll, 0);
364
365        // Can't scroll below 0
366        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        // Tab (next) cycles to EventRecord
378        focus = focus.next();
379        assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
380
381        // Tab (next) cycles back to Resources
382        focus = focus.next();
383        assert_eq!(focus, CloudTrailDetailFocus::Resources);
384
385        // Shift+Tab (prev) cycles to EventRecord
386        focus = focus.prev();
387        assert_eq!(focus, CloudTrailDetailFocus::EventRecord);
388
389        // Shift+Tab (prev) cycles back to Resources
390        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        // Test Resource type column
424        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        // Test Resource name column
431        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        // Test AWS Config resource timeline column
438        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}