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