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