1use 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
16pub 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#[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#[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, 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#[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
420pub 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), Constraint::Min(0), ])
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 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 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 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 let table_width = table_area.width.saturating_sub(4) as usize; 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) .sum();
1028 let message_max_width = table_width.saturating_sub(fixed_width).saturating_sub(3); 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 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 ×tamp_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 is_expanded {
1080 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 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 if let Some(expanded_idx) = app.log_groups_state.expanded_event {
1154 if let Some(event) = visible_events.get(expanded_idx) {
1155 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 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 if full_line.len() <= max_width {
1187 lines.push((full_line, true)); } else {
1189 let first_chunk_len = max_width.min(full_line.len());
1191 lines.push((full_line[..first_chunk_len].to_string(), true));
1192
1193 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)); remaining = &remaining[take..];
1199 }
1200 }
1201 }
1202
1203 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 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), height: 1,
1234 };
1235
1236 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 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 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 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}