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