rusticity_term/ui/cw/
logs.rs

1// CloudWatch Logs UI rendering and state
2use crate::app::App;
3use crate::common::{
4    format_bytes, format_timestamp, render_pagination_text, render_vertical_scrollbar, CyclicEnum,
5    InputFocus, SortDirection,
6};
7use crate::cw::insights::{DateRangeType, TimeUnit};
8use crate::cw::logs::{EventColumn, LogGroupColumn, StreamColumn};
9use crate::keymap::Mode;
10use crate::ui::table::{expanded_from_columns, render_table, Column as TableColumn, TableConfig};
11use crate::ui::{
12    calculate_dynamic_height, filter_area, format_title, get_cursor, labeled_field,
13    render_fields_with_dynamic_columns, render_tabs,
14};
15use ratatui::{prelude::*, widgets::*};
16use rusticity_core::{LogEvent, LogGroup, LogStream};
17
18// State
19pub struct CloudWatchLogGroupsState {
20    pub log_groups: crate::table::TableState<LogGroup>,
21    pub log_streams: Vec<LogStream>,
22    pub log_events: Vec<LogEvent>,
23    pub selected_stream: usize,
24    pub selected_event: usize,
25    pub loading: bool,
26    pub loading_message: String,
27    pub detail_tab: DetailTab,
28    pub stream_filter: String,
29    pub exact_match: bool,
30    pub show_expired: bool,
31    pub filter_mode: bool,
32    pub input_focus: InputFocus,
33    pub stream_page: usize,
34    pub stream_sort: StreamSort,
35    pub stream_sort_desc: bool,
36    pub event_filter: String,
37    pub event_scroll_offset: usize,
38    pub event_horizontal_scroll: usize,
39    pub has_older_events: bool,
40    pub event_input_focus: EventFilterFocus,
41    pub stream_page_size: usize,
42    pub stream_current_page: usize,
43    pub expanded_event: Option<usize>,
44    pub expanded_stream: Option<usize>,
45    pub next_backward_token: Option<String>,
46    pub start_time: Option<i64>,
47    pub end_time: Option<i64>,
48    pub date_range_type: DateRangeType,
49    pub relative_amount: String,
50    pub relative_unit: TimeUnit,
51}
52
53impl CloudWatchLogGroupsState {
54    pub fn new() -> Self {
55        Self::default()
56    }
57}
58
59impl Default for CloudWatchLogGroupsState {
60    fn default() -> Self {
61        Self {
62            log_groups: crate::table::TableState::new(),
63            log_streams: Vec::new(),
64            log_events: Vec::new(),
65            selected_stream: 0,
66            selected_event: 0,
67            loading: false,
68            loading_message: String::new(),
69            detail_tab: DetailTab::LogStreams,
70            stream_filter: String::new(),
71            exact_match: false,
72            show_expired: false,
73            filter_mode: false,
74            input_focus: InputFocus::Filter,
75            stream_page: 0,
76            stream_sort: StreamSort::LastEventTime,
77            stream_sort_desc: true,
78            event_filter: String::new(),
79            event_scroll_offset: 0,
80            event_horizontal_scroll: 0,
81            has_older_events: true,
82            event_input_focus: EventFilterFocus::Filter,
83            stream_page_size: 20,
84            stream_current_page: 0,
85            expanded_event: None,
86            expanded_stream: None,
87            next_backward_token: None,
88            start_time: None,
89            end_time: None,
90            date_range_type: DateRangeType::Relative,
91            relative_amount: String::new(),
92            relative_unit: TimeUnit::Hours,
93        }
94    }
95}
96
97// Enums
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub enum StreamSort {
100    Name,
101    CreationTime,
102    LastEventTime,
103}
104
105pub const FILTER_CONTROLS: [InputFocus; 4] = [
106    InputFocus::Filter,
107    InputFocus::Checkbox("ExactMatch"),
108    InputFocus::Checkbox("ShowExpired"),
109    InputFocus::Pagination,
110];
111
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub enum EventFilterFocus {
114    Filter,
115    DateRange,
116}
117
118impl EventFilterFocus {
119    const ALL: [EventFilterFocus; 2] = [EventFilterFocus::Filter, EventFilterFocus::DateRange];
120
121    pub fn next(self) -> Self {
122        let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
123        Self::ALL[(idx + 1) % Self::ALL.len()]
124    }
125
126    pub fn prev(self) -> Self {
127        let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
128        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum DetailTab {
134    LogStreams,
135    Tags,
136    AnomalyDetection,
137    MetricFilter,
138    SubscriptionFilters,
139    ContributorInsights,
140    DataProtection,
141    FieldIndexes,
142    Transformer,
143}
144
145impl CyclicEnum for DetailTab {
146    const ALL: &'static [Self] = &[Self::LogStreams];
147}
148
149impl DetailTab {
150    pub fn name(&self) -> &'static str {
151        match self {
152            DetailTab::LogStreams => "Log streams",
153            DetailTab::Tags => "Tags",
154            DetailTab::AnomalyDetection => "Anomaly detection",
155            DetailTab::MetricFilter => "Metric filter",
156            DetailTab::SubscriptionFilters => "Subscription filters",
157            DetailTab::ContributorInsights => "Contributor insights",
158            DetailTab::DataProtection => "Data protection",
159            DetailTab::FieldIndexes => "Field indexes",
160            DetailTab::Transformer => "Transformer",
161        }
162    }
163
164    pub fn all() -> Vec<DetailTab> {
165        vec![DetailTab::LogStreams]
166    }
167}
168
169// Helper functions
170
171pub fn selected_log_group(app: &App) -> Option<&LogGroup> {
172    app.log_groups_state
173        .log_groups
174        .items
175        .get(app.log_groups_state.log_groups.selected)
176}
177
178pub fn filtered_log_groups(app: &App) -> Vec<&LogGroup> {
179    if app.log_groups_state.log_groups.filter.is_empty() {
180        app.log_groups_state.log_groups.items.iter().collect()
181    } else {
182        app.log_groups_state
183            .log_groups
184            .items
185            .iter()
186            .filter(|group| {
187                if app.log_groups_state.exact_match {
188                    group.name == app.log_groups_state.log_groups.filter
189                } else {
190                    group.name.contains(&app.log_groups_state.log_groups.filter)
191                }
192            })
193            .collect()
194    }
195}
196
197pub fn filtered_log_streams(app: &App) -> Vec<&LogStream> {
198    let mut streams: Vec<&LogStream> = if app.log_groups_state.stream_filter.is_empty() {
199        app.log_groups_state.log_streams.iter().collect()
200    } else {
201        app.log_groups_state
202            .log_streams
203            .iter()
204            .filter(|stream| {
205                if app.log_groups_state.exact_match {
206                    stream.name == app.log_groups_state.stream_filter
207                } else {
208                    stream.name.contains(&app.log_groups_state.stream_filter)
209                }
210            })
211            .collect()
212    };
213
214    streams.sort_by(|a, b| {
215        let cmp = match app.log_groups_state.stream_sort {
216            StreamSort::Name => a.name.cmp(&b.name),
217            StreamSort::CreationTime => a.creation_time.cmp(&b.creation_time),
218            StreamSort::LastEventTime => a.last_event_time.cmp(&b.last_event_time),
219        };
220        if app.log_groups_state.stream_sort_desc {
221            cmp.reverse()
222        } else {
223            cmp
224        }
225    });
226
227    streams
228}
229
230pub fn filtered_log_events(app: &App) -> Vec<&LogEvent> {
231    if app.log_groups_state.event_filter.is_empty() {
232        app.log_groups_state.log_events.iter().collect()
233    } else {
234        app.log_groups_state
235            .log_events
236            .iter()
237            .filter(|event| event.message.contains(&app.log_groups_state.event_filter))
238            .collect()
239    }
240}
241
242pub fn render_groups_list(frame: &mut Frame, app: &App, area: Rect) {
243    let chunks = Layout::default()
244        .direction(Direction::Vertical)
245        .constraints([
246            Constraint::Length(3), // Filter
247            Constraint::Min(0),    // Table
248        ])
249        .split(area);
250
251    let placeholder = "Filter loaded log groups or try prefix search";
252    let filtered_groups = filtered_log_groups(app);
253    let filtered_count = filtered_groups.len();
254    let page_size = app.log_groups_state.log_groups.page_size.value();
255    let total_pages = filtered_count.div_ceil(page_size);
256    let current_page = app.log_groups_state.log_groups.selected / page_size;
257    let pagination = render_pagination_text(current_page, total_pages);
258
259    crate::ui::filter::render_simple_filter(
260        frame,
261        chunks[0],
262        crate::ui::filter::SimpleFilterConfig {
263            filter_text: &app.log_groups_state.log_groups.filter,
264            placeholder,
265            pagination: &pagination,
266            mode: app.mode,
267            is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
268            is_pagination_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
269        },
270    );
271
272    let scroll_offset = app.log_groups_state.log_groups.scroll_offset;
273    let start_idx = scroll_offset;
274    let end_idx = (start_idx + page_size).min(filtered_groups.len());
275    let paginated: Vec<&LogGroup> = filtered_groups[start_idx..end_idx].to_vec();
276
277    let mut columns: Vec<Box<dyn TableColumn<LogGroup>>> = vec![];
278
279    for col_id in &app.cw_log_group_visible_column_ids {
280        let Some(col) = LogGroupColumn::from_id(col_id) else {
281            continue;
282        };
283        columns.push(Box::new(col));
284    }
285
286    let expanded_index = app
287        .log_groups_state
288        .log_groups
289        .expanded_item
290        .and_then(|idx| {
291            if idx >= start_idx && idx < end_idx {
292                Some(idx - start_idx)
293            } else {
294                None
295            }
296        });
297
298    let config = TableConfig {
299        items: paginated,
300        selected_index: app.log_groups_state.log_groups.selected % page_size,
301        expanded_index,
302        columns: &columns,
303        sort_column: "",
304        sort_direction: SortDirection::Asc,
305        title: format_title(&format!("Log groups ({})", filtered_count)),
306        area: chunks[1],
307        get_expanded_content: Some(Box::new(|group: &LogGroup| {
308            expanded_from_columns(&columns, group)
309        })),
310        is_active: app.mode != Mode::FilterInput,
311    };
312
313    render_table(frame, config);
314}
315
316pub fn render_group_detail(frame: &mut Frame, app: &App, area: Rect) {
317    frame.render_widget(Clear, area);
318
319    let is_active = !matches!(
320        app.mode,
321        Mode::SpaceMenu
322            | Mode::ServicePicker
323            | Mode::ColumnSelector
324            | Mode::ErrorModal
325            | Mode::HelpModal
326            | Mode::RegionPicker
327            | Mode::CalendarPicker
328            | Mode::TabPicker
329    );
330    let border_style = if is_active {
331        Style::default().fg(Color::Green)
332    } else {
333        Style::default()
334    };
335
336    let detail_height = if let Some(group) = selected_log_group(app) {
337        let arn = format!(
338            "arn:aws:logs:{}:{}:log-group:{}:*",
339            app.config.region, app.config.account_id, group.name
340        );
341        let creation_time = group
342            .creation_time
343            .map(|t| format_timestamp(&t))
344            .unwrap_or_else(|| "-".to_string());
345        let stored_bytes = format_bytes(group.stored_bytes.unwrap_or(0));
346
347        let lines = vec![
348            labeled_field("Log class", "Standard"),
349            labeled_field("Retention", "Never expire"),
350            labeled_field("Stored bytes", stored_bytes),
351            labeled_field("Creation time", creation_time),
352            labeled_field("ARN", arn),
353            Line::from(vec![
354                Span::styled(
355                    "Metric filters: ",
356                    Style::default().add_modifier(Modifier::BOLD),
357                ),
358                Span::raw("0"),
359            ]),
360            Line::from(vec![
361                Span::styled(
362                    "Subscription filters: ",
363                    Style::default().add_modifier(Modifier::BOLD),
364                ),
365                Span::raw("0"),
366            ]),
367            Line::from(vec![
368                Span::styled(
369                    "KMS key ID: ",
370                    Style::default().add_modifier(Modifier::BOLD),
371                ),
372                Span::raw("-"),
373            ]),
374        ];
375
376        calculate_dynamic_height(&lines, area.width.saturating_sub(2)) + 2
377    } else {
378        3
379    };
380
381    let chunks = Layout::default()
382        .direction(Direction::Vertical)
383        .constraints([
384            Constraint::Length(detail_height),
385            Constraint::Length(1),
386            Constraint::Min(0),
387        ])
388        .split(area);
389
390    if let Some(group) = selected_log_group(app) {
391        let detail_block = Block::default()
392            .title(format_title("Log group details"))
393            .borders(Borders::ALL)
394            .border_type(BorderType::Rounded)
395            .border_style(Style::default());
396        let inner = detail_block.inner(chunks[0]);
397        frame.render_widget(detail_block, chunks[0]);
398        frame.render_widget(Clear, inner);
399
400        let arn = format!(
401            "arn:aws:logs:{}:{}:log-group:{}:*",
402            app.config.region, app.config.account_id, group.name
403        );
404        let creation_time = group
405            .creation_time
406            .map(|t| format_timestamp(&t))
407            .unwrap_or_else(|| "-".to_string());
408        let stored_bytes = format_bytes(group.stored_bytes.unwrap_or(0));
409
410        let lines = vec![
411            labeled_field("Log class", "Standard"),
412            labeled_field("Retention", "Never expire"),
413            labeled_field("Stored bytes", stored_bytes),
414            labeled_field("Creation time", creation_time),
415            labeled_field("ARN", arn),
416            Line::from(vec![
417                Span::styled(
418                    "Metric filters: ",
419                    Style::default().add_modifier(Modifier::BOLD),
420                ),
421                Span::raw("0"),
422            ]),
423            Line::from(vec![
424                Span::styled(
425                    "Subscription filters: ",
426                    Style::default().add_modifier(Modifier::BOLD),
427                ),
428                Span::raw("0"),
429            ]),
430            Line::from(vec![
431                Span::styled(
432                    "KMS key ID: ",
433                    Style::default().add_modifier(Modifier::BOLD),
434                ),
435                Span::raw("-"),
436            ]),
437        ];
438
439        render_fields_with_dynamic_columns(frame, inner, lines);
440    }
441
442    render_tab_menu(frame, app, chunks[1]);
443
444    match app.log_groups_state.detail_tab {
445        DetailTab::LogStreams => render_log_streams_table(frame, app, chunks[2], border_style),
446        _ => render_tab_placeholder(frame, app, chunks[2], border_style),
447    }
448}
449
450fn render_tab_menu(frame: &mut Frame, app: &App, area: Rect) {
451    frame.render_widget(Clear, area);
452    let all_tabs = DetailTab::all();
453    let tabs: Vec<(&str, DetailTab)> = all_tabs.iter().map(|tab| (tab.name(), *tab)).collect();
454    render_tabs(frame, area, &tabs, &app.log_groups_state.detail_tab);
455}
456
457fn render_tab_placeholder(frame: &mut Frame, app: &App, area: Rect, border_style: Style) {
458    frame.render_widget(Clear, area);
459    let text = format!("{} - Coming soon", app.log_groups_state.detail_tab.name());
460    let paragraph = Paragraph::new(text)
461        .block(
462            Block::default()
463                .borders(Borders::ALL)
464                .border_type(BorderType::Rounded)
465                .border_style(border_style),
466        )
467        .style(Style::default().fg(Color::Gray));
468    frame.render_widget(paragraph, area);
469}
470fn render_log_streams_table(frame: &mut Frame, app: &App, area: Rect, border_style: Style) {
471    frame.render_widget(Clear, area);
472
473    let chunks = Layout::default()
474        .direction(Direction::Vertical)
475        .constraints([Constraint::Length(3), Constraint::Min(0)])
476        .split(area);
477
478    let placeholder = "Filter loaded log streams or try prefix search";
479
480    let exact_match_text = if app.log_groups_state.exact_match {
481        "☑ Exact match"
482    } else {
483        "☐ Exact match"
484    };
485    let show_expired_text = if app.log_groups_state.show_expired {
486        "☑ Show expired"
487    } else {
488        "☐ Show expired"
489    };
490
491    let filtered_streams = filtered_log_streams(app);
492    let count = filtered_streams.len();
493    let page_size = app.log_groups_state.stream_page_size;
494    let total_pages = count.div_ceil(page_size);
495    let current_page = app
496        .log_groups_state
497        .stream_current_page
498        .min(total_pages.saturating_sub(1));
499
500    // Paginate the filtered streams
501    let start_idx = current_page * page_size;
502    let end_idx = (start_idx + page_size).min(count);
503    let paginated_streams = filtered_streams[start_idx..end_idx].to_vec();
504
505    let pagination = render_pagination_text(current_page, total_pages);
506
507    crate::ui::filter::render_filter_bar(
508        frame,
509        crate::ui::filter::FilterConfig {
510            filter_text: &app.log_groups_state.stream_filter,
511            placeholder,
512            mode: app.mode,
513            is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
514            controls: vec![
515                crate::ui::filter::FilterControl {
516                    text: exact_match_text.to_string(),
517                    is_focused: app.log_groups_state.input_focus
518                        == InputFocus::Checkbox("ExactMatch"),
519                },
520                crate::ui::filter::FilterControl {
521                    text: show_expired_text.to_string(),
522                    is_focused: app.log_groups_state.input_focus
523                        == InputFocus::Checkbox("ShowExpired"),
524                },
525                crate::ui::filter::FilterControl {
526                    text: pagination,
527                    is_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
528                },
529            ],
530            area: chunks[0],
531        },
532    );
533
534    let columns: Vec<Box<dyn TableColumn<LogStream>>> = app
535        .cw_log_stream_visible_column_ids
536        .iter()
537        .filter_map(|col_id| {
538            StreamColumn::from_id(col_id)
539                .map(|col| Box::new(col) as Box<dyn TableColumn<LogStream>>)
540        })
541        .collect();
542
543    let count_display = if count >= 100 {
544        "100+".to_string()
545    } else {
546        count.to_string()
547    };
548
549    let sort_column = match app.log_groups_state.stream_sort {
550        StreamSort::Name => "Log stream",
551        StreamSort::CreationTime => "Creation time",
552        StreamSort::LastEventTime => "Last event time",
553    };
554
555    let sort_direction = if app.log_groups_state.stream_sort_desc {
556        SortDirection::Desc
557    } else {
558        SortDirection::Asc
559    };
560
561    let config = TableConfig {
562        items: paginated_streams,
563        selected_index: app.log_groups_state.selected_stream,
564        expanded_index: app.log_groups_state.expanded_stream,
565        columns: &columns,
566        sort_column,
567        sort_direction,
568        title: format_title(&format!("Log streams ({})", count_display)),
569        area: chunks[1],
570        get_expanded_content: Some(Box::new(|stream: &LogStream| {
571            expanded_from_columns(&columns, stream)
572        })),
573        is_active: border_style.fg == Some(Color::Green)
574            && (app.mode != Mode::FilterInput
575                || app.log_groups_state.input_focus != InputFocus::Filter),
576    };
577
578    render_table(frame, config);
579}
580
581pub fn render_events(frame: &mut Frame, app: &App, area: Rect) {
582    frame.render_widget(Clear, area);
583
584    let is_active = !matches!(
585        app.mode,
586        Mode::SpaceMenu
587            | Mode::ServicePicker
588            | Mode::ColumnSelector
589            | Mode::ErrorModal
590            | Mode::HelpModal
591            | Mode::RegionPicker
592            | Mode::CalendarPicker
593            | Mode::TabPicker
594    );
595    let border_style = if is_active {
596        Style::default().fg(Color::Green)
597    } else {
598        Style::default()
599    };
600
601    let chunks = Layout::default()
602        .direction(Direction::Vertical)
603        .constraints([Constraint::Length(3), Constraint::Min(0)])
604        .split(area);
605
606    // Filter and date range
607    let filter_chunks = Layout::default()
608        .direction(Direction::Horizontal)
609        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
610        .split(chunks[0]);
611
612    let cursor = get_cursor(
613        app.mode == Mode::EventFilterInput
614            && app.log_groups_state.event_input_focus == EventFilterFocus::Filter,
615    );
616    let filter_text =
617        if app.log_groups_state.event_filter.is_empty() && app.mode != Mode::EventFilterInput {
618            vec![
619                Span::styled(
620                    "Filter events - press ⏎ to search",
621                    Style::default().fg(Color::DarkGray),
622                ),
623                Span::styled(cursor, Style::default().fg(Color::Yellow)),
624            ]
625        } else {
626            vec![
627                Span::raw(&app.log_groups_state.event_filter),
628                Span::styled(cursor, Style::default().fg(Color::Yellow)),
629            ]
630        };
631
632    let is_filter_active = app.mode == Mode::EventFilterInput
633        && app.log_groups_state.event_input_focus == EventFilterFocus::Filter;
634    let filter = filter_area(filter_text, is_filter_active);
635
636    let date_border_style = if app.mode == Mode::EventFilterInput
637        && app.log_groups_state.event_input_focus == EventFilterFocus::DateRange
638    {
639        Style::default().fg(Color::Green)
640    } else {
641        Style::default()
642    };
643
644    let date_range_text = vec![
645        Span::raw(format!(
646            "Last [{}] <{}>",
647            if app.log_groups_state.relative_amount.is_empty() {
648                "_"
649            } else {
650                &app.log_groups_state.relative_amount
651            },
652            app.log_groups_state.relative_unit.name()
653        )),
654        Span::styled(cursor, Style::default().fg(Color::Yellow)),
655    ];
656
657    let date_range = Paragraph::new(Line::from(date_range_text)).block(
658        Block::default()
659            .title(format_title("Date range"))
660            .borders(Borders::ALL)
661            .border_type(BorderType::Rounded)
662            .border_style(date_border_style),
663    );
664
665    frame.render_widget(filter, filter_chunks[0]);
666    frame.render_widget(date_range, filter_chunks[1]);
667
668    // Events table with banner
669    let table_area = chunks[1];
670
671    let header_cells = app
672        .cw_log_event_visible_column_ids
673        .iter()
674        .enumerate()
675        .filter_map(|(i, col_id)| {
676            EventColumn::from_id(col_id).map(|col| {
677                let name = if i > 0 {
678                    format!("⋮ {}", col.name())
679                } else {
680                    col.name().to_string()
681                };
682                Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
683            })
684        })
685        .collect::<Vec<_>>();
686    let header = Row::new(header_cells)
687        .style(Style::default().bg(Color::White).fg(Color::Black))
688        .height(1);
689
690    let visible_events: Vec<_> = filtered_log_events(app).into_iter().collect();
691
692    // Add banner as first row if there are older events
693    let mut all_rows: Vec<Row> = Vec::new();
694
695    if app.log_groups_state.has_older_events {
696        let banner_cells = vec![
697            Cell::from(""),
698            Cell::from("There are older events to load. Scroll up to load more.")
699                .style(Style::default().fg(Color::Yellow)),
700        ];
701        all_rows.push(Row::new(banner_cells).height(1));
702    }
703
704    // Calculate available width for message column
705    let table_width = table_area.width.saturating_sub(4) as usize; // borders + spacing
706    let fixed_width: usize = app
707        .cw_log_event_visible_column_ids
708        .iter()
709        .filter_map(|col_id| EventColumn::from_id(col_id))
710        .filter(|col| col.width() > 0)
711        .map(|col| col.width() as usize + 1) // +1 for spacing
712        .sum();
713    let message_max_width = table_width.saturating_sub(fixed_width).saturating_sub(3); // -3 for highlight symbol
714
715    let mut table_row_to_event_idx = Vec::new();
716    let event_rows = visible_events.iter().enumerate().flat_map(|(idx, event)| {
717        let is_expanded = app.log_groups_state.expanded_event == Some(idx);
718        let is_selected = idx == app.log_groups_state.event_scroll_offset;
719
720        let mut rows = Vec::new();
721
722        // Main row with columns - always show first line or truncated message
723        let mut cells: Vec<Cell> = Vec::new();
724        for (i, col_id) in app.cw_log_event_visible_column_ids.iter().enumerate() {
725            let Some(col) = EventColumn::from_id(col_id) else {
726                continue;
727            };
728            let content = match col {
729                EventColumn::Timestamp => {
730                    let timestamp_str = format_timestamp(&event.timestamp);
731                    crate::ui::table::format_expandable_with_selection(
732                        &timestamp_str,
733                        is_expanded,
734                        is_selected,
735                    )
736                }
737                EventColumn::Message => {
738                    let msg = event
739                        .message
740                        .lines()
741                        .next()
742                        .unwrap_or("")
743                        .replace('\t', " ");
744                    if msg.len() > message_max_width {
745                        format!("{}…", &msg[..message_max_width.saturating_sub(1)])
746                    } else {
747                        msg
748                    }
749                }
750                EventColumn::IngestionTime => "-".to_string(),
751                EventColumn::EventId => "-".to_string(),
752                EventColumn::LogStreamName => "-".to_string(),
753            };
754
755            let cell_content = if i > 0 {
756                format!("⋮ {}", content)
757            } else {
758                content
759            };
760
761            cells.push(Cell::from(cell_content));
762        }
763        table_row_to_event_idx.push(idx);
764        rows.push(Row::new(cells).height(1));
765
766        // If expanded, add empty rows to reserve space for overlay
767        if is_expanded {
768            // Calculate wrapped line count
769            let max_width = (table_area.width.saturating_sub(3)) as usize;
770            let mut line_count = 0;
771
772            for col_id in &app.cw_log_event_visible_column_ids {
773                let Some(col) = EventColumn::from_id(col_id) else {
774                    continue;
775                };
776                let value = match col {
777                    EventColumn::Timestamp => format_timestamp(&event.timestamp),
778                    EventColumn::Message => event.message.replace('\t', "    "),
779                    _ => "-".to_string(),
780                };
781                let full_line = format!("{}: {}", col.name(), value);
782                line_count += full_line.len().div_ceil(max_width);
783            }
784
785            for _ in 0..line_count {
786                // Empty row to reserve space - will be covered by overlay
787                table_row_to_event_idx.push(idx);
788                rows.push(Row::new(vec![Cell::from("")]).height(1));
789            }
790        }
791
792        rows
793    });
794
795    all_rows.extend(event_rows);
796
797    let banner_offset = if app.log_groups_state.has_older_events {
798        1
799    } else {
800        0
801    };
802    let mut table_state_index = banner_offset;
803    for (i, &event_idx) in table_row_to_event_idx.iter().enumerate() {
804        if event_idx == app.log_groups_state.event_scroll_offset {
805            table_state_index = banner_offset + i;
806            break;
807        }
808    }
809
810    let widths: Vec<Constraint> = app
811        .cw_log_event_visible_column_ids
812        .iter()
813        .filter_map(|col_id| {
814            EventColumn::from_id(col_id).map(|col| {
815                if col.width() == 0 {
816                    Constraint::Min(0)
817                } else {
818                    Constraint::Length(col.width())
819                }
820            })
821        })
822        .collect();
823
824    let table = Table::new(all_rows, widths)
825        .header(header)
826        .block(
827            Block::default()
828                .title(format_title(&format!(
829                    "Log events ({})",
830                    visible_events.len()
831                )))
832                .borders(Borders::ALL)
833                .border_type(BorderType::Rounded)
834                .border_style(border_style),
835        )
836        .column_spacing(1)
837        .row_highlight_style(Style::default().bg(Color::DarkGray))
838        .highlight_symbol("");
839
840    let mut state = TableState::default();
841    state.select(Some(table_state_index));
842
843    frame.render_stateful_widget(table, table_area, &mut state);
844
845    // Render expanded content as overlay
846    if let Some(expanded_idx) = app.log_groups_state.expanded_event {
847        if let Some(event) = visible_events.get(expanded_idx) {
848            // Find row position
849            let mut row_y = 0;
850            for (i, &event_idx) in table_row_to_event_idx.iter().enumerate() {
851                if event_idx == expanded_idx {
852                    row_y = i;
853                    break;
854                }
855            }
856
857            let banner_offset = if app.log_groups_state.has_older_events {
858                1
859            } else {
860                0
861            };
862
863            // Build content with column names
864            let mut lines = Vec::new();
865            let max_width = table_area.width.saturating_sub(3) as usize;
866
867            for col_id in &app.cw_log_event_visible_column_ids {
868                let Some(col) = EventColumn::from_id(col_id) else {
869                    continue;
870                };
871                let value = match col {
872                    EventColumn::Timestamp => format_timestamp(&event.timestamp),
873                    EventColumn::Message => event.message.replace('\t', "    "),
874                    EventColumn::IngestionTime => "-".to_string(),
875                    EventColumn::EventId => "-".to_string(),
876                    EventColumn::LogStreamName => "-".to_string(),
877                };
878                let col_name = format!("{}: ", col.name());
879                let full_line = format!("{}{}", col_name, value);
880
881                // Wrap long lines, marking first line
882                if full_line.len() <= max_width {
883                    lines.push((full_line, true)); // true = first line with column name
884                } else {
885                    // First chunk includes column name
886                    let first_chunk_len = max_width.min(full_line.len());
887                    lines.push((full_line[..first_chunk_len].to_string(), true));
888
889                    // Remaining chunks are continuation
890                    let mut remaining = &full_line[first_chunk_len..];
891                    while !remaining.is_empty() {
892                        let take = max_width.min(remaining.len());
893                        lines.push((remaining[..take].to_string(), false)); // false = continuation
894                        remaining = &remaining[take..];
895                    }
896                }
897            }
898
899            // Render each line as overlay
900            // Clear entire expanded area once
901            let start_y = table_area.y + 2 + banner_offset as u16 + row_y as u16 + 1;
902            let max_y = table_area.y + table_area.height - 1;
903
904            // Only render if start_y is within bounds
905            if start_y < max_y {
906                let available_height = (max_y - start_y) as usize;
907                let visible_lines = lines.len().min(available_height);
908
909                if visible_lines > 0 {
910                    let clear_area = Rect {
911                        x: table_area.x + 1,
912                        y: start_y,
913                        width: table_area.width.saturating_sub(3),
914                        height: visible_lines as u16,
915                    };
916                    frame.render_widget(Clear, clear_area);
917                }
918
919                for (line_idx, (line, is_first)) in lines.iter().enumerate() {
920                    let y = start_y + line_idx as u16;
921                    if y >= max_y {
922                        break;
923                    }
924
925                    let line_area = Rect {
926                        x: table_area.x + 1,
927                        y,
928                        width: table_area.width.saturating_sub(3), // Leave room for scrollbar
929                        height: 1,
930                    };
931
932                    // Add expansion indicator on the left
933                    let is_last_line = line_idx == lines.len() - 1;
934                    let indicator = if is_last_line {
935                        "╰ "
936                    } else if *is_first {
937                        "├ "
938                    } else {
939                        "│ "
940                    };
941
942                    // Bold column name only on first line of each field
943                    let spans = if *is_first {
944                        if let Some(colon_pos) = line.find(": ") {
945                            let col_name = &line[..colon_pos + 2];
946                            let rest = &line[colon_pos + 2..];
947                            vec![
948                                Span::raw(indicator),
949                                Span::styled(
950                                    col_name.to_string(),
951                                    Style::default().add_modifier(Modifier::BOLD),
952                                ),
953                                Span::raw(rest.to_string()),
954                            ]
955                        } else {
956                            vec![Span::raw(indicator), Span::raw(line.clone())]
957                        }
958                    } else {
959                        // Continuation line - no bold
960                        vec![Span::raw(indicator), Span::raw(line.clone())]
961                    };
962
963                    let paragraph = Paragraph::new(Line::from(spans));
964                    frame.render_widget(paragraph, line_area);
965                }
966            }
967        }
968    }
969
970    // Render scrollbar
971    let event_count = app.log_groups_state.log_events.len();
972    if event_count > 0 {
973        render_vertical_scrollbar(
974            frame,
975            table_area.inner(Margin {
976                vertical: 1,
977                horizontal: 0,
978            }),
979            event_count,
980            app.log_groups_state.event_scroll_offset,
981        );
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    #[test]
990    fn test_input_focus_enum_cycling() {
991        use InputFocus;
992        assert_eq!(
993            InputFocus::Filter.next(&FILTER_CONTROLS),
994            InputFocus::Checkbox("ExactMatch")
995        );
996        assert_eq!(
997            InputFocus::Checkbox("ExactMatch").next(&FILTER_CONTROLS),
998            InputFocus::Checkbox("ShowExpired")
999        );
1000        assert_eq!(
1001            InputFocus::Checkbox("ShowExpired").next(&FILTER_CONTROLS),
1002            InputFocus::Pagination
1003        );
1004        assert_eq!(
1005            InputFocus::Pagination.next(&FILTER_CONTROLS),
1006            InputFocus::Filter
1007        );
1008
1009        assert_eq!(
1010            InputFocus::Filter.prev(&FILTER_CONTROLS),
1011            InputFocus::Pagination
1012        );
1013        assert_eq!(
1014            InputFocus::Pagination.prev(&FILTER_CONTROLS),
1015            InputFocus::Checkbox("ShowExpired")
1016        );
1017        assert_eq!(
1018            InputFocus::Checkbox("ShowExpired").prev(&FILTER_CONTROLS),
1019            InputFocus::Checkbox("ExactMatch")
1020        );
1021        assert_eq!(
1022            InputFocus::Checkbox("ExactMatch").prev(&FILTER_CONTROLS),
1023            InputFocus::Filter
1024        );
1025    }
1026}