1use 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
15pub 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#[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
166pub 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), Constraint::Min(0), ])
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 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 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 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 let table_width = table_area.width.saturating_sub(4) as usize; 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) .sum();
654 let message_max_width = table_width.saturating_sub(fixed_width).saturating_sub(3); 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 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 ×tamp_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 is_expanded {
709 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 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 if let Some(expanded_idx) = app.log_groups_state.expanded_event {
789 if let Some(event) = visible_events.get(expanded_idx) {
790 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 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 if full_line.len() <= max_width {
825 lines.push((full_line, true)); } else {
827 let first_chunk_len = max_width.min(full_line.len());
829 lines.push((full_line[..first_chunk_len].to_string(), true));
830
831 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)); remaining = &remaining[take..];
837 }
838 }
839 }
840
841 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 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), height: 1,
872 };
873
874 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 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 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 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}