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::table::TableWindow;
11use crate::ui::table::{expanded_from_columns, render_table, Column as TableColumn, TableConfig};
12use crate::ui::{
13 calculate_dynamic_height, filter_area, format_title, get_cursor, labeled_field,
14 render_fields_with_dynamic_columns, render_tabs,
15};
16use ratatui::{prelude::*, widgets::*};
17use rusticity_core::{LogEvent, LogGroup, LogStream};
18
19pub struct CloudWatchLogGroupsState {
21 pub log_groups: crate::table::TableState<LogGroup>,
22 pub log_streams: Vec<LogStream>,
23 pub log_events: Vec<LogEvent>,
24 pub tags: crate::table::TableState<(String, String)>,
25 pub selected_stream: usize,
26 pub selected_event: usize,
27 pub loading: bool,
28 pub loading_message: String,
29 pub detail_tab: DetailTab,
30 pub stream_filter: String,
31 pub exact_match: bool,
32 pub show_expired: bool,
33 pub filter_mode: bool,
34 pub input_focus: InputFocus,
35 pub stream_page: usize,
36 pub stream_sort: StreamSort,
37 pub stream_sort_desc: bool,
38 pub event_filter: String,
39 pub event_scroll_offset: usize,
40 pub event_horizontal_scroll: usize,
41 pub has_older_events: bool,
42 pub event_input_focus: EventFilterFocus,
43 pub stream_page_size: usize,
44 pub stream_current_page: usize,
45 pub expanded_event: Option<usize>,
46 pub expanded_stream: Option<usize>,
47 pub next_backward_token: Option<String>,
48 pub start_time: Option<i64>,
49 pub end_time: Option<i64>,
50 pub date_range_type: DateRangeType,
51 pub relative_amount: String,
52 pub relative_unit: TimeUnit,
53}
54
55impl CloudWatchLogGroupsState {
56 pub fn new() -> Self {
57 Self::default()
58 }
59}
60
61impl Default for CloudWatchLogGroupsState {
62 fn default() -> Self {
63 Self {
64 log_groups: crate::table::TableState::new(),
65 log_streams: Vec::new(),
66 log_events: Vec::new(),
67 tags: crate::table::TableState::new(),
68 selected_stream: 0,
69 selected_event: 0,
70 loading: false,
71 loading_message: String::new(),
72 detail_tab: DetailTab::LogStreams,
73 stream_filter: String::new(),
74 exact_match: false,
75 show_expired: false,
76 filter_mode: false,
77 input_focus: InputFocus::Filter,
78 stream_page: 0,
79 stream_sort: StreamSort::LastEventTime,
80 stream_sort_desc: true,
81 event_filter: String::new(),
82 event_scroll_offset: 0,
83 event_horizontal_scroll: 0,
84 has_older_events: true,
85 event_input_focus: EventFilterFocus::Filter,
86 stream_page_size: 20,
87 stream_current_page: 0,
88 expanded_event: None,
89 expanded_stream: None,
90 next_backward_token: None,
91 start_time: None,
92 end_time: None,
93 date_range_type: DateRangeType::Relative,
94 relative_amount: String::new(),
95 relative_unit: TimeUnit::Hours,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
102pub enum StreamSort {
103 Name,
104 CreationTime,
105 LastEventTime,
106}
107
108pub const FILTER_CONTROLS: [InputFocus; 4] = [
109 InputFocus::Filter,
110 InputFocus::Checkbox("ExactMatch"),
111 InputFocus::Checkbox("ShowExpired"),
112 InputFocus::Pagination,
113];
114
115pub const LIST_FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
117
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub enum EventFilterFocus {
120 Filter,
121 DateRange,
122}
123
124impl EventFilterFocus {
125 const ALL: [EventFilterFocus; 2] = [EventFilterFocus::Filter, EventFilterFocus::DateRange];
126
127 pub fn next(self) -> Self {
128 let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
129 Self::ALL[(idx + 1) % Self::ALL.len()]
130 }
131
132 pub fn prev(self) -> Self {
133 let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
134 Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub enum DetailTab {
140 LogStreams,
141 Tags,
142 AnomalyDetection,
143 MetricFilter,
144 SubscriptionFilters,
145 ContributorInsights,
146 DataProtection,
147 FieldIndexes,
148 Transformer,
149}
150
151impl CyclicEnum for DetailTab {
152 const ALL: &'static [Self] = &[Self::LogStreams, Self::Tags];
153}
154
155impl DetailTab {
156 pub fn name(&self) -> &'static str {
157 match self {
158 DetailTab::LogStreams => "Log streams",
159 DetailTab::Tags => "Tags",
160 DetailTab::AnomalyDetection => "Anomaly detection",
161 DetailTab::MetricFilter => "Metric filter",
162 DetailTab::SubscriptionFilters => "Subscription filters",
163 DetailTab::ContributorInsights => "Contributor insights",
164 DetailTab::DataProtection => "Data protection",
165 DetailTab::FieldIndexes => "Field indexes",
166 DetailTab::Transformer => "Transformer",
167 }
168 }
169
170 pub fn all() -> Vec<DetailTab> {
171 vec![DetailTab::LogStreams, DetailTab::Tags]
172 }
173}
174
175pub fn selected_log_group(app: &App) -> Option<&LogGroup> {
178 app.log_groups_state
179 .log_groups
180 .items
181 .get(app.log_groups_state.log_groups.selected)
182}
183
184pub fn filtered_log_groups(app: &App) -> Vec<&LogGroup> {
185 if app.log_groups_state.log_groups.filter.is_empty() {
186 app.log_groups_state.log_groups.items.iter().collect()
187 } else {
188 app.log_groups_state
189 .log_groups
190 .items
191 .iter()
192 .filter(|group| {
193 if app.log_groups_state.exact_match {
194 group.name == app.log_groups_state.log_groups.filter
195 } else {
196 group.name.contains(&app.log_groups_state.log_groups.filter)
197 }
198 })
199 .collect()
200 }
201}
202
203pub fn filtered_log_streams(app: &App) -> Vec<&LogStream> {
204 let mut streams: Vec<&LogStream> = if app.log_groups_state.stream_filter.is_empty() {
205 app.log_groups_state.log_streams.iter().collect()
206 } else {
207 app.log_groups_state
208 .log_streams
209 .iter()
210 .filter(|stream| {
211 if app.log_groups_state.exact_match {
212 stream.name == app.log_groups_state.stream_filter
213 } else {
214 stream.name.contains(&app.log_groups_state.stream_filter)
215 }
216 })
217 .collect()
218 };
219
220 if !app.log_groups_state.show_expired {
222 if let Some(group) = selected_log_group(app) {
223 if let Some(retention_days) = group.retention_days {
224 let now = chrono::Utc::now();
225 let retention_cutoff = now - chrono::Duration::days(retention_days as i64);
226
227 streams.retain(|stream| {
228 stream
229 .last_event_time
230 .map(|t| t > retention_cutoff)
231 .unwrap_or(false)
232 });
233 }
234 }
235 }
236
237 streams.sort_by(|a, b| {
238 let cmp = match app.log_groups_state.stream_sort {
239 StreamSort::Name => a.name.cmp(&b.name),
240 StreamSort::CreationTime => a.creation_time.cmp(&b.creation_time),
241 StreamSort::LastEventTime => a.last_event_time.cmp(&b.last_event_time),
242 };
243 if app.log_groups_state.stream_sort_desc {
244 cmp.reverse()
245 } else {
246 cmp
247 }
248 });
249
250 streams
251}
252
253pub fn filtered_log_events(app: &App) -> Vec<&LogEvent> {
254 if app.log_groups_state.event_filter.is_empty() {
255 app.log_groups_state.log_events.iter().collect()
256 } else {
257 app.log_groups_state
258 .log_events
259 .iter()
260 .filter(|event| event.message.contains(&app.log_groups_state.event_filter))
261 .collect()
262 }
263}
264
265pub fn render_groups_list(frame: &mut Frame, app: &App, area: Rect) {
266 let chunks = Layout::default()
267 .direction(Direction::Vertical)
268 .constraints([
269 Constraint::Length(3), Constraint::Min(0), ])
272 .split(area);
273
274 let placeholder = "Filter loaded log groups or try prefix search";
275 let view = app
276 .log_groups_state
277 .log_groups
278 .prepare_view(filtered_log_groups(app), TableWindow::ScrollOffset);
279 let pagination = render_pagination_text(view.current_page, view.total_pages);
280
281 crate::ui::filter::render_simple_filter(
282 frame,
283 chunks[0],
284 crate::ui::filter::SimpleFilterConfig {
285 filter_text: &app.log_groups_state.log_groups.filter,
286 placeholder,
287 pagination: &pagination,
288 mode: app.mode,
289 is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
290 is_pagination_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
291 },
292 );
293
294 let mut columns: Vec<Box<dyn TableColumn<LogGroup>>> = vec![];
295
296 for col_id in &app.cw_log_group_visible_column_ids {
297 let Some(col) = LogGroupColumn::from_id(col_id) else {
298 continue;
299 };
300 columns.push(Box::new(col));
301 }
302
303 let config = TableConfig {
304 items: view.items,
305 selected_index: view.selected_index,
306 expanded_index: view.expanded_index,
307 columns: &columns,
308 sort_column: "",
309 sort_direction: SortDirection::Asc,
310 title: format_title(&format!("Log groups ({})", view.total_items)),
311 area: chunks[1],
312 get_expanded_content: Some(Box::new(|group: &LogGroup| {
313 expanded_from_columns(&columns, group)
314 })),
315 is_active: app.mode != Mode::FilterInput,
316 };
317
318 render_table(frame, config);
319}
320
321pub fn render_group_detail(frame: &mut Frame, app: &App, area: Rect) {
322 frame.render_widget(Clear, area);
323
324 let is_active = !matches!(
325 app.mode,
326 Mode::SpaceMenu
327 | Mode::ServicePicker
328 | Mode::ColumnSelector
329 | Mode::ErrorModal
330 | Mode::HelpModal
331 | Mode::RegionPicker
332 | Mode::CalendarPicker
333 | Mode::TabPicker
334 );
335 let border_style = if is_active {
336 Style::default().fg(Color::Green)
337 } else {
338 Style::default()
339 };
340
341 let detail_height = if let Some(group) = selected_log_group(app) {
342 let arn = format!(
343 "arn:aws:logs:{}:{}:log-group:{}:*",
344 app.config.region, app.config.account_id, group.name
345 );
346 let creation_time = group
347 .creation_time
348 .map(|t| format_timestamp(&t))
349 .unwrap_or_else(|| "-".to_string());
350 let stored_bytes = format_bytes(group.stored_bytes.unwrap_or(0));
351 let deletion_protection = if group.deletion_protection_enabled.unwrap_or(false) {
352 "On"
353 } else {
354 "Off"
355 };
356
357 let lines = vec![
358 labeled_field("Log class", "Standard"),
359 labeled_field("Retention", "Never expire"),
360 labeled_field("Stored bytes", stored_bytes),
361 labeled_field("Deletion protection", deletion_protection),
362 labeled_field("Creation time", creation_time),
363 labeled_field("ARN", arn),
364 Line::from(vec![
365 Span::styled(
366 "Metric filters: ",
367 Style::default().add_modifier(Modifier::BOLD),
368 ),
369 Span::raw("0"),
370 ]),
371 Line::from(vec![
372 Span::styled(
373 "Subscription filters: ",
374 Style::default().add_modifier(Modifier::BOLD),
375 ),
376 Span::raw("0"),
377 ]),
378 Line::from(vec![
379 Span::styled(
380 "KMS key ID: ",
381 Style::default().add_modifier(Modifier::BOLD),
382 ),
383 Span::raw("-"),
384 ]),
385 ];
386
387 calculate_dynamic_height(&lines, area.width.saturating_sub(2)) + 2
388 } else {
389 3
390 };
391
392 let chunks = Layout::default()
393 .direction(Direction::Vertical)
394 .constraints([
395 Constraint::Length(detail_height),
396 Constraint::Length(1),
397 Constraint::Min(0),
398 ])
399 .split(area);
400
401 if let Some(group) = selected_log_group(app) {
402 let detail_block = Block::default()
403 .title(format_title("Log group details"))
404 .borders(Borders::ALL)
405 .border_type(BorderType::Rounded)
406 .border_style(Style::default());
407 let inner = detail_block.inner(chunks[0]);
408 frame.render_widget(detail_block, chunks[0]);
409 frame.render_widget(Clear, inner);
410
411 let arn = format!(
412 "arn:aws:logs:{}:{}:log-group:{}:*",
413 app.config.region, app.config.account_id, group.name
414 );
415 let creation_time = group
416 .creation_time
417 .map(|t| format_timestamp(&t))
418 .unwrap_or_else(|| "-".to_string());
419 let stored_bytes = format_bytes(group.stored_bytes.unwrap_or(0));
420 let deletion_protection = if group.deletion_protection_enabled.unwrap_or(false) {
421 "On"
422 } else {
423 "Off"
424 };
425
426 let lines = vec![
427 labeled_field("Log class", "Standard"),
428 labeled_field("Retention", "Never expire"),
429 labeled_field("Stored bytes", stored_bytes),
430 labeled_field("Deletion protection", deletion_protection),
431 labeled_field("Creation time", creation_time),
432 labeled_field("ARN", arn),
433 Line::from(vec![
434 Span::styled(
435 "Metric filters: ",
436 Style::default().add_modifier(Modifier::BOLD),
437 ),
438 Span::raw("0"),
439 ]),
440 Line::from(vec![
441 Span::styled(
442 "Subscription filters: ",
443 Style::default().add_modifier(Modifier::BOLD),
444 ),
445 Span::raw("0"),
446 ]),
447 Line::from(vec![
448 Span::styled(
449 "KMS key ID: ",
450 Style::default().add_modifier(Modifier::BOLD),
451 ),
452 Span::raw("-"),
453 ]),
454 ];
455
456 render_fields_with_dynamic_columns(frame, inner, lines);
457 }
458
459 render_tab_menu(frame, app, chunks[1]);
460
461 match app.log_groups_state.detail_tab {
462 DetailTab::LogStreams => render_log_streams_table(frame, app, chunks[2], border_style),
463 DetailTab::Tags => render_tags_table(frame, app, chunks[2], border_style),
464 _ => render_tab_placeholder(frame, app, chunks[2], border_style),
465 }
466}
467
468fn render_tab_menu(frame: &mut Frame, app: &App, area: Rect) {
469 frame.render_widget(Clear, area);
470 let all_tabs = DetailTab::all();
471 let tabs: Vec<(&str, DetailTab)> = all_tabs.iter().map(|tab| (tab.name(), *tab)).collect();
472
473 debug_assert_eq!(tabs.len(), 2, "Should have 2 tabs: LogStreams and Tags");
475
476 render_tabs(frame, area, &tabs, &app.log_groups_state.detail_tab);
477}
478
479fn render_tab_placeholder(frame: &mut Frame, app: &App, area: Rect, border_style: Style) {
480 frame.render_widget(Clear, area);
481 let text = format!("{} - Coming soon", app.log_groups_state.detail_tab.name());
482 let paragraph = Paragraph::new(text)
483 .block(
484 Block::default()
485 .borders(Borders::ALL)
486 .border_type(BorderType::Rounded)
487 .border_style(border_style),
488 )
489 .style(Style::default().fg(Color::Gray));
490 frame.render_widget(paragraph, area);
491}
492fn render_log_streams_table(frame: &mut Frame, app: &App, area: Rect, border_style: Style) {
493 frame.render_widget(Clear, area);
494
495 let chunks = Layout::default()
496 .direction(Direction::Vertical)
497 .constraints([Constraint::Length(3), Constraint::Min(0)])
498 .split(area);
499
500 let placeholder = "Filter loaded log streams or try prefix search";
501
502 let exact_match_text = if app.log_groups_state.exact_match {
503 "☑ Exact match"
504 } else {
505 "☐ Exact match"
506 };
507 let show_expired_text = if app.log_groups_state.show_expired {
508 "☑ Show expired"
509 } else {
510 "☐ Show expired"
511 };
512
513 let filtered_streams = filtered_log_streams(app);
514 let count = filtered_streams.len();
515 let page_size = app.log_groups_state.stream_page_size;
516 let total_pages = count.div_ceil(page_size);
517 let current_page = app
518 .log_groups_state
519 .stream_current_page
520 .min(total_pages.saturating_sub(1));
521
522 let start_idx = current_page * page_size;
524 let end_idx = (start_idx + page_size).min(count);
525 let paginated_streams = filtered_streams[start_idx..end_idx].to_vec();
526
527 let pagination = render_pagination_text(current_page, total_pages);
528
529 crate::ui::filter::render_filter_bar(
530 frame,
531 crate::ui::filter::FilterConfig {
532 filter_text: &app.log_groups_state.stream_filter,
533 placeholder,
534 mode: app.mode,
535 is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
536 controls: vec![
537 crate::ui::filter::FilterControl {
538 text: exact_match_text.to_string(),
539 is_focused: app.log_groups_state.input_focus
540 == InputFocus::Checkbox("ExactMatch"),
541 },
542 crate::ui::filter::FilterControl {
543 text: show_expired_text.to_string(),
544 is_focused: app.log_groups_state.input_focus
545 == InputFocus::Checkbox("ShowExpired"),
546 },
547 crate::ui::filter::FilterControl {
548 text: pagination,
549 is_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
550 },
551 ],
552 area: chunks[0],
553 },
554 );
555
556 let columns: Vec<Box<dyn TableColumn<LogStream>>> = app
557 .cw_log_stream_visible_column_ids
558 .iter()
559 .filter_map(|col_id| {
560 StreamColumn::from_id(col_id)
561 .map(|col| Box::new(col) as Box<dyn TableColumn<LogStream>>)
562 })
563 .collect();
564
565 let count_display = if count >= 100 {
566 "100+".to_string()
567 } else {
568 count.to_string()
569 };
570
571 let sort_column = match app.log_groups_state.stream_sort {
572 StreamSort::Name => "Log stream",
573 StreamSort::CreationTime => "Creation time",
574 StreamSort::LastEventTime => "Last event time",
575 };
576
577 let sort_direction = if app.log_groups_state.stream_sort_desc {
578 SortDirection::Desc
579 } else {
580 SortDirection::Asc
581 };
582
583 let config = TableConfig {
584 items: paginated_streams,
585 selected_index: if count > 0 {
586 app.log_groups_state
587 .selected_stream
588 .saturating_sub(start_idx)
589 .min(page_size.saturating_sub(1))
590 } else {
591 0
592 },
593 expanded_index: app
594 .log_groups_state
595 .expanded_stream
596 .map(|idx| idx.saturating_sub(start_idx)),
597 columns: &columns,
598 sort_column,
599 sort_direction,
600 title: format_title(&format!("Log streams ({})", count_display)),
601 area: chunks[1],
602 get_expanded_content: Some(Box::new(|stream: &LogStream| {
603 expanded_from_columns(&columns, stream)
604 })),
605 is_active: border_style.fg == Some(Color::Green)
606 && (app.mode != Mode::FilterInput
607 || app.log_groups_state.input_focus != InputFocus::Filter),
608 };
609
610 render_table(frame, config);
611}
612
613pub fn render_events(frame: &mut Frame, app: &App, area: Rect) {
614 frame.render_widget(Clear, area);
615
616 let is_active = !matches!(
617 app.mode,
618 Mode::SpaceMenu
619 | Mode::ServicePicker
620 | Mode::ColumnSelector
621 | Mode::ErrorModal
622 | Mode::HelpModal
623 | Mode::RegionPicker
624 | Mode::CalendarPicker
625 | Mode::TabPicker
626 );
627 let border_style = if is_active {
628 Style::default().fg(Color::Green)
629 } else {
630 Style::default()
631 };
632
633 let chunks = Layout::default()
634 .direction(Direction::Vertical)
635 .constraints([Constraint::Length(3), Constraint::Min(0)])
636 .split(area);
637
638 let filter_chunks = Layout::default()
640 .direction(Direction::Horizontal)
641 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
642 .split(chunks[0]);
643
644 let cursor = get_cursor(
645 app.mode == Mode::EventFilterInput
646 && app.log_groups_state.event_input_focus == EventFilterFocus::Filter,
647 );
648 let filter_text =
649 if app.log_groups_state.event_filter.is_empty() && app.mode != Mode::EventFilterInput {
650 vec![
651 Span::styled(
652 "Filter events - press ⏎ to search",
653 Style::default().fg(Color::DarkGray),
654 ),
655 Span::styled(cursor, Style::default().fg(Color::Yellow)),
656 ]
657 } else {
658 vec![
659 Span::raw(&app.log_groups_state.event_filter),
660 Span::styled(cursor, Style::default().fg(Color::Yellow)),
661 ]
662 };
663
664 let is_filter_active = app.mode == Mode::EventFilterInput
665 && app.log_groups_state.event_input_focus == EventFilterFocus::Filter;
666 let filter = filter_area(filter_text, is_filter_active);
667
668 let date_border_style = if app.mode == Mode::EventFilterInput
669 && app.log_groups_state.event_input_focus == EventFilterFocus::DateRange
670 {
671 Style::default().fg(Color::Green)
672 } else {
673 Style::default()
674 };
675
676 let date_range_text = vec![
677 Span::raw(format!(
678 "Last [{}] <{}>",
679 if app.log_groups_state.relative_amount.is_empty() {
680 "_"
681 } else {
682 &app.log_groups_state.relative_amount
683 },
684 app.log_groups_state.relative_unit.name()
685 )),
686 Span::styled(cursor, Style::default().fg(Color::Yellow)),
687 ];
688
689 let date_range = Paragraph::new(Line::from(date_range_text)).block(
690 Block::default()
691 .title(format_title("Date range"))
692 .borders(Borders::ALL)
693 .border_type(BorderType::Rounded)
694 .border_style(date_border_style),
695 );
696
697 frame.render_widget(filter, filter_chunks[0]);
698 frame.render_widget(date_range, filter_chunks[1]);
699
700 let table_area = chunks[1];
702
703 let header_cells = app
704 .cw_log_event_visible_column_ids
705 .iter()
706 .enumerate()
707 .filter_map(|(i, col_id)| {
708 EventColumn::from_id(col_id).map(|col| {
709 let name = if i > 0 {
710 format!("⋮ {}", col.name())
711 } else {
712 col.name().to_string()
713 };
714 Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
715 })
716 })
717 .collect::<Vec<_>>();
718 let header = Row::new(header_cells)
719 .style(Style::default().bg(Color::White).fg(Color::Black))
720 .height(1);
721
722 let visible_events: Vec<_> = filtered_log_events(app).into_iter().collect();
723
724 let mut all_rows: Vec<Row> = Vec::new();
726
727 if app.log_groups_state.has_older_events {
728 let banner_cells = vec![
729 Cell::from(""),
730 Cell::from("There are older events to load. Scroll up to load more.")
731 .style(Style::default().fg(Color::Yellow)),
732 ];
733 all_rows.push(Row::new(banner_cells).height(1));
734 }
735
736 let table_width = table_area.width.saturating_sub(4) as usize; let fixed_width: usize = app
739 .cw_log_event_visible_column_ids
740 .iter()
741 .filter_map(|col_id| EventColumn::from_id(col_id))
742 .filter(|col| col.width() > 0)
743 .map(|col| col.width() as usize + 1) .sum();
745 let message_max_width = table_width.saturating_sub(fixed_width).saturating_sub(3); let mut table_row_to_event_idx = Vec::new();
748 let event_rows = visible_events.iter().enumerate().flat_map(|(idx, event)| {
749 let is_expanded = app.log_groups_state.expanded_event == Some(idx);
750 let is_selected = idx == app.log_groups_state.event_scroll_offset;
751
752 let mut rows = Vec::new();
753
754 let mut cells: Vec<Cell> = Vec::new();
756 for (i, col_id) in app.cw_log_event_visible_column_ids.iter().enumerate() {
757 let Some(col) = EventColumn::from_id(col_id) else {
758 continue;
759 };
760 let content = match col {
761 EventColumn::Timestamp => {
762 let timestamp_str = format_timestamp(&event.timestamp);
763 crate::ui::table::format_expandable_with_selection(
764 ×tamp_str,
765 is_expanded,
766 is_selected,
767 )
768 }
769 EventColumn::Message => {
770 let msg = event
771 .message
772 .lines()
773 .next()
774 .unwrap_or("")
775 .replace('\t', " ");
776 if msg.len() > message_max_width {
777 format!("{}…", &msg[..message_max_width.saturating_sub(1)])
778 } else {
779 msg
780 }
781 }
782 EventColumn::IngestionTime => event
783 .ingestion_time
784 .map(|t| format_timestamp(&t))
785 .unwrap_or_else(|| "-".to_string()),
786 EventColumn::EventId => "-".to_string(),
787 EventColumn::LogStreamName => app
788 .log_groups_state
789 .log_streams
790 .get(app.log_groups_state.selected_stream)
791 .map(|s| s.name.clone())
792 .unwrap_or_else(|| "-".to_string()),
793 };
794
795 let cell_content = if i > 0 {
796 format!("⋮ {}", content)
797 } else {
798 content
799 };
800
801 cells.push(Cell::from(cell_content));
802 }
803 table_row_to_event_idx.push(idx);
804 rows.push(Row::new(cells).height(1));
805
806 if is_expanded {
808 let max_width = (table_area.width.saturating_sub(3)) as usize;
810 let mut line_count = 0;
811
812 for col_id in &app.cw_log_event_visible_column_ids {
813 let Some(col) = EventColumn::from_id(col_id) else {
814 continue;
815 };
816 let value = match col {
817 EventColumn::Timestamp => format_timestamp(&event.timestamp),
818 EventColumn::Message => event.message.replace('\t', " "),
819 _ => "-".to_string(),
820 };
821 let full_line = format!("{}: {}", col.name(), value);
822 line_count += full_line.len().div_ceil(max_width);
823 }
824
825 for _ in 0..line_count {
826 table_row_to_event_idx.push(idx);
828 rows.push(Row::new(vec![Cell::from("")]).height(1));
829 }
830 }
831
832 rows
833 });
834
835 all_rows.extend(event_rows);
836
837 let banner_offset = if app.log_groups_state.has_older_events {
838 1
839 } else {
840 0
841 };
842 let mut table_state_index = banner_offset;
843 for (i, &event_idx) in table_row_to_event_idx.iter().enumerate() {
844 if event_idx == app.log_groups_state.event_scroll_offset {
845 table_state_index = banner_offset + i;
846 break;
847 }
848 }
849
850 let widths: Vec<Constraint> = app
851 .cw_log_event_visible_column_ids
852 .iter()
853 .filter_map(|col_id| {
854 EventColumn::from_id(col_id).map(|col| {
855 if col.width() == 0 {
856 Constraint::Min(0)
857 } else {
858 Constraint::Length(col.width())
859 }
860 })
861 })
862 .collect();
863
864 let table = Table::new(all_rows, widths)
865 .header(header)
866 .block(
867 Block::default()
868 .title(format_title(&format!(
869 "Log events ({})",
870 visible_events.len()
871 )))
872 .borders(Borders::ALL)
873 .border_type(BorderType::Rounded)
874 .border_style(border_style),
875 )
876 .column_spacing(1)
877 .row_highlight_style(Style::default().bg(Color::DarkGray))
878 .highlight_symbol("");
879
880 let mut state = TableState::default();
881 state.select(Some(table_state_index));
882
883 frame.render_stateful_widget(table, table_area, &mut state);
884
885 if let Some(expanded_idx) = app.log_groups_state.expanded_event {
887 if let Some(event) = visible_events.get(expanded_idx) {
888 let mut row_y = 0;
890 for (i, &event_idx) in table_row_to_event_idx.iter().enumerate() {
891 if event_idx == expanded_idx {
892 row_y = i;
893 break;
894 }
895 }
896
897 let banner_offset = if app.log_groups_state.has_older_events {
898 1
899 } else {
900 0
901 };
902
903 let mut lines = Vec::new();
905 let max_width = table_area.width.saturating_sub(3) as usize;
906
907 for col_id in &app.cw_log_event_visible_column_ids {
908 let Some(col) = EventColumn::from_id(col_id) else {
909 continue;
910 };
911 let value = match col {
912 EventColumn::Timestamp => format_timestamp(&event.timestamp),
913 EventColumn::Message => event.message.replace('\t', " "),
914 EventColumn::IngestionTime => event
915 .ingestion_time
916 .map(|t| format_timestamp(&t))
917 .unwrap_or_else(|| "-".to_string()),
918 EventColumn::EventId => "-".to_string(),
919 EventColumn::LogStreamName => app
920 .log_groups_state
921 .log_streams
922 .get(app.log_groups_state.selected_stream)
923 .map(|s| s.name.clone())
924 .unwrap_or_else(|| "-".to_string()),
925 };
926 let col_name = format!("{}: ", col.name());
927 let full_line = format!("{}{}", col_name, value);
928
929 if full_line.len() <= max_width {
931 lines.push((full_line, true)); } else {
933 let first_chunk_len = max_width.min(full_line.len());
935 lines.push((full_line[..first_chunk_len].to_string(), true));
936
937 let mut remaining = &full_line[first_chunk_len..];
939 while !remaining.is_empty() {
940 let take = max_width.min(remaining.len());
941 lines.push((remaining[..take].to_string(), false)); remaining = &remaining[take..];
943 }
944 }
945 }
946
947 let start_y = table_area.y + 2 + banner_offset as u16 + row_y as u16 + 1;
950 let max_y = table_area.y + table_area.height - 1;
951
952 if start_y < max_y {
954 let available_height = (max_y - start_y) as usize;
955 let visible_lines = lines.len().min(available_height);
956
957 if visible_lines > 0 {
958 let clear_area = Rect {
959 x: table_area.x + 1,
960 y: start_y,
961 width: table_area.width.saturating_sub(3),
962 height: visible_lines as u16,
963 };
964 frame.render_widget(Clear, clear_area);
965 }
966
967 for (line_idx, (line, is_first)) in lines.iter().enumerate() {
968 let y = start_y + line_idx as u16;
969 if y >= max_y {
970 break;
971 }
972
973 let line_area = Rect {
974 x: table_area.x + 1,
975 y,
976 width: table_area.width.saturating_sub(3), height: 1,
978 };
979
980 let is_last_line = line_idx == lines.len() - 1;
982 let indicator = if is_last_line {
983 "╰ "
984 } else if *is_first {
985 "├ "
986 } else {
987 "│ "
988 };
989
990 let spans = if *is_first {
992 if let Some(colon_pos) = line.find(": ") {
993 let col_name = &line[..colon_pos + 2];
994 let rest = &line[colon_pos + 2..];
995 vec![
996 Span::raw(indicator),
997 Span::styled(
998 col_name.to_string(),
999 Style::default().add_modifier(Modifier::BOLD),
1000 ),
1001 Span::raw(rest.to_string()),
1002 ]
1003 } else {
1004 vec![Span::raw(indicator), Span::raw(line.clone())]
1005 }
1006 } else {
1007 vec![Span::raw(indicator), Span::raw(line.clone())]
1009 };
1010
1011 let paragraph = Paragraph::new(Line::from(spans));
1012 frame.render_widget(paragraph, line_area);
1013 }
1014 }
1015 }
1016 }
1017
1018 let event_count = app.log_groups_state.log_events.len();
1020 if event_count > 0 {
1021 render_vertical_scrollbar(
1022 frame,
1023 table_area.inner(Margin {
1024 vertical: 1,
1025 horizontal: 0,
1026 }),
1027 event_count,
1028 app.log_groups_state.event_scroll_offset,
1029 );
1030 }
1031}
1032
1033fn render_tags_table(frame: &mut Frame, app: &App, area: Rect, _border_style: Style) {
1034 use crate::cw::TagColumn;
1035 use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
1036 use crate::ui::table::{render_table, Column, TableConfig};
1037 use crate::ui::vertical;
1038
1039 let chunks = vertical(
1040 [
1041 Constraint::Length(3), Constraint::Min(0), ],
1044 area,
1045 );
1046
1047 let page_size = app.log_groups_state.tags.page_size.value().max(1);
1049 let filtered_tags: Vec<_> = app
1050 .log_groups_state
1051 .tags
1052 .items
1053 .iter()
1054 .filter(|t| {
1055 if app.log_groups_state.tags.filter.is_empty() {
1056 true
1057 } else {
1058 t.0.to_lowercase()
1059 .contains(&app.log_groups_state.tags.filter.to_lowercase())
1060 || t.1
1061 .to_lowercase()
1062 .contains(&app.log_groups_state.tags.filter.to_lowercase())
1063 }
1064 })
1065 .collect();
1066
1067 let filtered_count = filtered_tags.len();
1068 let total_pages = filtered_count.div_ceil(page_size);
1069 let current_page = app.log_groups_state.tags.selected / page_size;
1070 let pagination = render_pagination_text(current_page, total_pages);
1071
1072 render_simple_filter(
1073 frame,
1074 chunks[0],
1075 SimpleFilterConfig {
1076 filter_text: &app.log_groups_state.tags.filter,
1077 placeholder: "Search",
1078 pagination: &pagination,
1079 mode: app.mode,
1080 is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
1081 is_pagination_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
1082 },
1083 );
1084
1085 let scroll_offset = app.log_groups_state.tags.scroll_offset;
1087 let page_tags: Vec<&(String, String)> = filtered_tags
1088 .into_iter()
1089 .skip(scroll_offset)
1090 .take(page_size)
1091 .collect();
1092
1093 let columns: Vec<Box<dyn Column<(String, String)>>> = app
1094 .cw_log_tag_visible_column_ids
1095 .iter()
1096 .filter_map(|col_id| {
1097 TagColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<(String, String)>>)
1098 })
1099 .collect();
1100
1101 let config = TableConfig {
1102 items: page_tags,
1103 selected_index: app
1104 .log_groups_state
1105 .tags
1106 .selected
1107 .saturating_sub(scroll_offset),
1108 expanded_index: None,
1109 columns: &columns,
1110 sort_column: "Key",
1111 sort_direction: crate::common::SortDirection::Asc,
1112 title: format_title(&format!("Tags ({})", filtered_count)),
1113 area: chunks[1],
1114 get_expanded_content: None,
1115 is_active: app.mode == Mode::Normal,
1116 };
1117
1118 render_table(frame, config);
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124 use crate::app::App;
1125
1126 fn test_app() -> App {
1127 App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1128 }
1129
1130 #[test]
1131 fn test_prepared_groups_uses_scroll_offset_and_handles_stale_window() {
1132 use ratatui::{backend::TestBackend, Terminal};
1133 let mut app = test_app();
1134 app.mode = Mode::Normal;
1135 app.log_groups_state.log_groups.items = (0..25)
1136 .map(|i| LogGroup {
1137 name: format!("group{i:02}"),
1138 creation_time: None,
1139 stored_bytes: None,
1140 retention_days: None,
1141 log_class: None,
1142 arn: None,
1143 log_group_arn: None,
1144 deletion_protection_enabled: None,
1145 })
1146 .collect();
1147 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Ten;
1148 app.log_groups_state.log_groups.scroll_offset = 7;
1149 app.log_groups_state.log_groups.selected = 14;
1150 app.log_groups_state.log_groups.expanded_item = Some(9);
1151 let mut terminal = Terminal::new(TestBackend::new(200, 45)).unwrap();
1152 terminal
1153 .draw(|f| render_groups_list(f, &app, f.area()))
1154 .unwrap();
1155 let content = terminal
1156 .backend()
1157 .buffer()
1158 .content()
1159 .iter()
1160 .map(|c| c.symbol())
1161 .collect::<String>();
1162 assert!(content.contains("Log groups (25)"));
1163 assert!(content.contains(&render_pagination_text(0, 3)));
1164 assert!(content.contains("group07"));
1165 assert!(content.contains("group16"));
1166 assert!(content.contains("► group14"));
1167 assert!(content.contains("▼ group09"));
1168 assert!(!content.contains("group06"));
1169 assert!(!content.contains("group17"));
1170
1171 app.log_groups_state.log_groups.filter = "no-match".into();
1172 terminal
1173 .draw(|f| render_groups_list(f, &app, f.area()))
1174 .unwrap();
1175 let content = terminal
1176 .backend()
1177 .buffer()
1178 .content()
1179 .iter()
1180 .map(|c| c.symbol())
1181 .collect::<String>();
1182 assert!(content.contains("Log groups (0)"));
1183 assert!(!content.contains("group14"));
1184 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 7);
1185 assert_eq!(app.log_groups_state.log_groups.selected, 14);
1186 }
1187
1188 #[test]
1189 fn test_input_focus_enum_cycling() {
1190 use InputFocus;
1191 assert_eq!(
1192 InputFocus::Filter.next(&FILTER_CONTROLS),
1193 InputFocus::Checkbox("ExactMatch")
1194 );
1195 assert_eq!(
1196 InputFocus::Checkbox("ExactMatch").next(&FILTER_CONTROLS),
1197 InputFocus::Checkbox("ShowExpired")
1198 );
1199 assert_eq!(
1200 InputFocus::Checkbox("ShowExpired").next(&FILTER_CONTROLS),
1201 InputFocus::Pagination
1202 );
1203 assert_eq!(
1204 InputFocus::Pagination.next(&FILTER_CONTROLS),
1205 InputFocus::Filter
1206 );
1207
1208 assert_eq!(
1209 InputFocus::Filter.prev(&FILTER_CONTROLS),
1210 InputFocus::Pagination
1211 );
1212 assert_eq!(
1213 InputFocus::Pagination.prev(&FILTER_CONTROLS),
1214 InputFocus::Checkbox("ShowExpired")
1215 );
1216 assert_eq!(
1217 InputFocus::Checkbox("ShowExpired").prev(&FILTER_CONTROLS),
1218 InputFocus::Checkbox("ExactMatch")
1219 );
1220 assert_eq!(
1221 InputFocus::Checkbox("ExactMatch").prev(&FILTER_CONTROLS),
1222 InputFocus::Filter
1223 );
1224 }
1225
1226 #[test]
1227 fn test_exact_match_toggle_with_space() {
1228 use crate::app::{Service, ViewMode};
1229 use crate::keymap::{Action, Mode};
1230
1231 let mut app = test_app();
1232 app.current_service = Service::CloudWatchLogGroups;
1233 app.view_mode = ViewMode::Detail;
1234 app.mode = Mode::FilterInput;
1235 app.log_groups_state.detail_tab = DetailTab::LogStreams;
1236 app.log_groups_state.input_focus = InputFocus::Checkbox("ExactMatch");
1237
1238 assert!(!app.log_groups_state.exact_match);
1240
1241 app.handle_action(Action::ToggleFilterCheckbox);
1243 assert!(app.log_groups_state.exact_match);
1244
1245 app.handle_action(Action::ToggleFilterCheckbox);
1247 assert!(!app.log_groups_state.exact_match);
1248 }
1249
1250 #[test]
1251 fn test_exact_match_filters_log_groups() {
1252 use crate::app::Service;
1253 use rusticity_core::LogGroup;
1254
1255 let mut app = test_app();
1256 app.current_service = Service::CloudWatchLogGroups;
1257
1258 app.log_groups_state.log_groups.items = vec![
1260 LogGroup {
1261 name: "/aws/lambda/test".to_string(),
1262 creation_time: None,
1263 stored_bytes: None,
1264 retention_days: None,
1265 log_class: None,
1266 arn: None,
1267 log_group_arn: None,
1268 deletion_protection_enabled: None,
1269 },
1270 LogGroup {
1271 name: "/aws/lambda/test-prod".to_string(),
1272 creation_time: None,
1273 stored_bytes: None,
1274 retention_days: None,
1275 log_class: None,
1276 arn: None,
1277 log_group_arn: None,
1278 deletion_protection_enabled: None,
1279 },
1280 LogGroup {
1281 name: "/aws/lambda/production".to_string(),
1282 creation_time: None,
1283 stored_bytes: None,
1284 retention_days: None,
1285 log_class: None,
1286 arn: None,
1287 log_group_arn: None,
1288 deletion_protection_enabled: None,
1289 },
1290 ];
1291
1292 app.log_groups_state.log_groups.filter = "test".to_string();
1294 app.log_groups_state.exact_match = false;
1295 let filtered = filtered_log_groups(&app);
1296 assert_eq!(filtered.len(), 2); app.log_groups_state.exact_match = true;
1300 let filtered = filtered_log_groups(&app);
1301 assert_eq!(filtered.len(), 0); app.log_groups_state.log_groups.filter = "/aws/lambda/test".to_string();
1305 let filtered = filtered_log_groups(&app);
1306 assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "/aws/lambda/test");
1308 }
1309
1310 #[test]
1311 fn test_exact_match_filters_log_streams() {
1312 use crate::app::{Service, ViewMode};
1313 use rusticity_core::LogStream;
1314
1315 let mut app = test_app();
1316 app.current_service = Service::CloudWatchLogGroups;
1317 app.view_mode = ViewMode::Detail;
1318
1319 app.log_groups_state.log_streams = vec![
1321 LogStream {
1322 name: "2024/01/01/stream1".to_string(),
1323 creation_time: None,
1324 last_event_time: None,
1325 },
1326 LogStream {
1327 name: "2024/01/01/stream1-backup".to_string(),
1328 creation_time: None,
1329 last_event_time: None,
1330 },
1331 LogStream {
1332 name: "2024/01/02/stream2".to_string(),
1333 creation_time: None,
1334 last_event_time: None,
1335 },
1336 ];
1337
1338 app.log_groups_state.stream_filter = "stream1".to_string();
1340 app.log_groups_state.exact_match = false;
1341 let filtered = filtered_log_streams(&app);
1342 assert_eq!(filtered.len(), 2); app.log_groups_state.exact_match = true;
1346 let filtered = filtered_log_streams(&app);
1347 assert_eq!(filtered.len(), 0); app.log_groups_state.stream_filter = "2024/01/01/stream1".to_string();
1351 let filtered = filtered_log_streams(&app);
1352 assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "2024/01/01/stream1");
1354 }
1355
1356 #[test]
1357 fn test_exact_match_checkbox_focus_cycle() {
1358 use crate::app::{Service, ViewMode};
1359 use crate::keymap::{Action, Mode};
1360
1361 let mut app = test_app();
1362 app.current_service = Service::CloudWatchLogGroups;
1363 app.view_mode = ViewMode::Detail;
1364 app.mode = Mode::FilterInput;
1365 app.log_groups_state.detail_tab = DetailTab::LogStreams;
1366 app.log_groups_state.input_focus = InputFocus::Filter;
1367
1368 app.handle_action(Action::NextFilterFocus);
1370 assert_eq!(
1371 app.log_groups_state.input_focus,
1372 InputFocus::Checkbox("ExactMatch")
1373 );
1374
1375 assert!(!app.log_groups_state.exact_match);
1377 app.handle_action(Action::ToggleFilterCheckbox);
1378 assert!(app.log_groups_state.exact_match);
1379
1380 app.handle_action(Action::NextFilterFocus);
1382 assert_eq!(
1383 app.log_groups_state.input_focus,
1384 InputFocus::Checkbox("ShowExpired")
1385 );
1386
1387 app.handle_action(Action::PrevFilterFocus);
1389 assert_eq!(
1390 app.log_groups_state.input_focus,
1391 InputFocus::Checkbox("ExactMatch")
1392 );
1393 }
1394
1395 #[test]
1396 fn test_tags_tab_in_detail_view() {
1397 use crate::app::{Service, ViewMode};
1398
1399 let mut app = test_app();
1400 app.current_service = Service::CloudWatchLogGroups;
1401 app.view_mode = ViewMode::Detail;
1402
1403 assert_eq!(app.log_groups_state.detail_tab, DetailTab::LogStreams);
1405
1406 app.log_groups_state.detail_tab = app.log_groups_state.detail_tab.next();
1408 assert_eq!(app.log_groups_state.detail_tab, DetailTab::Tags);
1409
1410 app.log_groups_state.detail_tab = app.log_groups_state.detail_tab.next();
1412 assert_eq!(app.log_groups_state.detail_tab, DetailTab::LogStreams);
1413 }
1414
1415 #[test]
1416 fn test_tags_tab_preferences_cycling() {
1417 use crate::app::{Service, ViewMode};
1418 use crate::keymap::{Action, Mode};
1419
1420 let mut app = test_app();
1421 app.current_service = Service::CloudWatchLogGroups;
1422 app.view_mode = ViewMode::Detail;
1423 app.log_groups_state.detail_tab = DetailTab::Tags;
1424 app.mode = Mode::ColumnSelector;
1425 app.column_selector_index = 0;
1426
1427 let page_size_idx = app.cw_log_tag_column_ids.len() + 2;
1429 app.handle_action(Action::NextPreferences);
1430 assert_eq!(app.column_selector_index, page_size_idx);
1431
1432 app.handle_action(Action::NextPreferences);
1434 assert_eq!(app.column_selector_index, 0);
1435
1436 app.handle_action(Action::PrevPreferences);
1438 assert_eq!(app.column_selector_index, page_size_idx);
1439 }
1440
1441 #[test]
1442 fn test_tags_tab_filter_mode() {
1443 use crate::app::{Service, ViewMode};
1444 use crate::keymap::{Action, Mode};
1445
1446 let mut app = test_app();
1447 app.current_service = Service::CloudWatchLogGroups;
1448 app.service_selected = true;
1449 app.view_mode = ViewMode::Detail;
1450 app.log_groups_state.detail_tab = DetailTab::Tags;
1451 app.mode = Mode::Normal;
1452
1453 app.handle_action(Action::StartFilter);
1455 assert_eq!(app.mode, Mode::FilterInput);
1456 assert_eq!(app.log_groups_state.input_focus, InputFocus::Filter);
1457 }
1458
1459 #[test]
1460 fn test_log_streams_tab_filter_mode() {
1461 use crate::app::{Service, ViewMode};
1462 use crate::keymap::{Action, Mode};
1463
1464 let mut app = test_app();
1465 app.current_service = Service::CloudWatchLogGroups;
1466 app.service_selected = true;
1467 app.view_mode = ViewMode::Detail;
1468 app.log_groups_state.detail_tab = DetailTab::LogStreams;
1469 app.mode = Mode::Normal;
1470
1471 app.handle_action(Action::StartFilter);
1473 assert_eq!(app.mode, Mode::FilterInput);
1474 assert_eq!(app.log_groups_state.input_focus, InputFocus::Filter);
1475 }
1476
1477 #[test]
1478 fn test_detail_tab_all_matches_cyclic_enum() {
1479 let all_tabs = DetailTab::all();
1481 let cyclic_tabs: Vec<DetailTab> = DetailTab::ALL.to_vec();
1482
1483 assert_eq!(
1484 all_tabs.len(),
1485 cyclic_tabs.len(),
1486 "DetailTab::all() must return same number of tabs as CyclicEnum::ALL"
1487 );
1488
1489 for (i, tab) in all_tabs.iter().enumerate() {
1490 assert_eq!(
1491 tab, &cyclic_tabs[i],
1492 "DetailTab::all() must return tabs in same order as CyclicEnum::ALL"
1493 );
1494 }
1495 }
1496
1497 #[test]
1498 fn test_show_expired_filters_streams_by_retention() {
1499 use crate::app::{Service, ViewMode};
1500 use chrono::Utc;
1501 use rusticity_core::LogGroup;
1502
1503 let mut app = test_app();
1504 app.current_service = Service::CloudWatchLogGroups;
1505 app.service_selected = true;
1506 app.view_mode = ViewMode::Detail;
1507
1508 let now = Utc::now();
1509 let retention_days = 7;
1510
1511 app.log_groups_state.log_groups.items = vec![LogGroup {
1513 name: "/aws/lambda/test".to_string(),
1514 creation_time: None,
1515 stored_bytes: None,
1516 retention_days: Some(retention_days),
1517 log_class: None,
1518 arn: None,
1519 log_group_arn: None,
1520 deletion_protection_enabled: None,
1521 }];
1522
1523 app.log_groups_state.log_streams = vec![
1525 LogStream {
1526 name: "recent-stream".to_string(),
1527 creation_time: None,
1528 last_event_time: Some(now - chrono::Duration::days(3)),
1529 },
1530 LogStream {
1531 name: "expired-stream".to_string(),
1532 creation_time: None,
1533 last_event_time: Some(now - chrono::Duration::days(10)),
1534 },
1535 ];
1536
1537 app.log_groups_state.show_expired = false;
1539 let filtered = filtered_log_streams(&app);
1540 assert_eq!(filtered.len(), 1);
1541 assert_eq!(filtered[0].name, "recent-stream");
1542
1543 app.log_groups_state.show_expired = true;
1545 let filtered = filtered_log_streams(&app);
1546 assert_eq!(filtered.len(), 2);
1547 }
1548
1549 #[test]
1550 fn test_show_expired_no_retention_shows_all() {
1551 use crate::app::{Service, ViewMode};
1552 use chrono::Utc;
1553 use rusticity_core::LogGroup;
1554
1555 let mut app = test_app();
1556 app.current_service = Service::CloudWatchLogGroups;
1557 app.service_selected = true;
1558 app.view_mode = ViewMode::Detail;
1559
1560 let now = Utc::now();
1561
1562 app.log_groups_state.log_groups.items = vec![LogGroup {
1564 name: "/aws/lambda/test".to_string(),
1565 creation_time: None,
1566 stored_bytes: None,
1567 retention_days: None,
1568 log_class: None,
1569 arn: None,
1570 log_group_arn: None,
1571 deletion_protection_enabled: None,
1572 }];
1573
1574 app.log_groups_state.log_streams = vec![
1575 LogStream {
1576 name: "stream1".to_string(),
1577 creation_time: None,
1578 last_event_time: Some(now - chrono::Duration::days(100)),
1579 },
1580 LogStream {
1581 name: "stream2".to_string(),
1582 creation_time: None,
1583 last_event_time: Some(now - chrono::Duration::days(1)),
1584 },
1585 ];
1586
1587 app.log_groups_state.show_expired = false;
1589 let filtered = filtered_log_streams(&app);
1590 assert_eq!(filtered.len(), 2);
1591 }
1592
1593 #[test]
1594 fn test_log_streams_pagination_right_arrow_increments_by_one_page() {
1595 use crate::app::ViewMode;
1598 use crate::app::{App, Service};
1599 use crate::common::InputFocus;
1600 use crate::keymap::Mode;
1601
1602 let mut app = App::new_without_client("default".to_string(), None);
1603 app.current_service = Service::CloudWatchLogGroups;
1604 app.service_selected = true;
1605 app.view_mode = ViewMode::Detail;
1606 app.mode = Mode::FilterInput;
1607 app.log_groups_state.input_focus = InputFocus::Pagination;
1608 app.log_groups_state.stream_page_size = 20;
1609 app.log_groups_state.stream_current_page = 0;
1610 app.log_groups_state.selected_stream = 0;
1611
1612 app.log_groups_state.log_streams = (0..60)
1614 .map(|i| rusticity_core::types::LogStream {
1615 name: format!("stream-{}", i),
1616 creation_time: None,
1617 last_event_time: None,
1618 })
1619 .collect();
1620
1621 crate::cw::actions::logs_page_down_filter_input(&mut app);
1622
1623 assert_eq!(
1624 app.log_groups_state.stream_current_page, 1,
1625 "Right arrow must advance to page 2 (index 1), not page 21"
1626 );
1627 assert_eq!(app.log_groups_state.selected_stream, 20);
1628
1629 crate::cw::actions::logs_page_up_filter_input(&mut app);
1630 assert_eq!(
1631 app.log_groups_state.stream_current_page, 0,
1632 "Left arrow must go back to page 1 (index 0)"
1633 );
1634 assert_eq!(app.log_groups_state.selected_stream, 0);
1635 }
1636
1637 #[test]
1638 fn test_log_groups_list_tab_cycles_only_filter_and_pagination() {
1639 use crate::app::ViewMode;
1643 use crate::app::{App, Service};
1644 use crate::common::InputFocus;
1645 use crate::keymap::Mode;
1646
1647 let mut app = App::new_without_client("default".to_string(), None);
1648 app.current_service = Service::CloudWatchLogGroups;
1649 app.service_selected = true;
1650 app.view_mode = ViewMode::List;
1651 app.mode = Mode::FilterInput;
1652 app.log_groups_state.input_focus = InputFocus::Filter;
1653
1654 crate::cw::actions::logs_next_filter_focus(&mut app);
1655 assert_eq!(
1656 app.log_groups_state.input_focus,
1657 InputFocus::Pagination,
1658 "In list view, Tab from Filter must go directly to Pagination (skip ExactMatch/ShowExpired)"
1659 );
1660
1661 crate::cw::actions::logs_next_filter_focus(&mut app);
1662 assert_eq!(
1663 app.log_groups_state.input_focus,
1664 InputFocus::Filter,
1665 "Tab from Pagination must wrap back to Filter"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_log_groups_down_arrow_stays_within_page_and_updates_selection() {
1671 use crate::app::ViewMode;
1676 use crate::app::{App, Service};
1677 use crate::common::PageSize;
1678 use rusticity_core::types::LogGroup;
1679
1680 let mut app = App::new_without_client("default".to_string(), None);
1681 app.current_service = Service::CloudWatchLogGroups;
1682 app.service_selected = true;
1683 app.view_mode = ViewMode::List;
1684 app.log_groups_state.log_groups.page_size = PageSize::Ten;
1685
1686 app.log_groups_state.log_groups.items = (0..21)
1688 .map(|i| LogGroup {
1689 name: format!("/aws/group/{}", i),
1690 stored_bytes: Some(0),
1691 retention_days: None,
1692 log_class: None,
1693 arn: None,
1694 log_group_arn: None,
1695 deletion_protection_enabled: None,
1696 creation_time: None,
1697 })
1698 .collect();
1699
1700 app.log_groups_state.log_groups.selected = 20;
1702 app.log_groups_state.log_groups.scroll_offset = 20;
1703
1704 crate::cw::actions::logs_next_item(&mut app);
1706 assert_eq!(
1707 app.log_groups_state.log_groups.selected, 20,
1708 "Can't go past last item"
1709 );
1710 assert_eq!(
1711 app.log_groups_state.log_groups.scroll_offset, 20,
1712 "scroll_offset must stay page-aligned"
1713 );
1714
1715 app.log_groups_state.log_groups.selected = 20;
1717 app.log_groups_state.log_groups.scroll_offset = 20;
1718 crate::cw::actions::logs_prev_item(&mut app);
1719 assert_eq!(app.log_groups_state.log_groups.selected, 19);
1720 let scroll = app.log_groups_state.log_groups.scroll_offset;
1722 assert!(
1723 scroll <= 19,
1724 "scroll_offset {scroll} must be <= selected 19"
1725 );
1726 }
1727}