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