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