1use crate::app::App;
2use crate::common::{format_timestamp, render_horizontal_scrollbar, render_vertical_scrollbar};
3use crate::keymap::Mode;
4use crate::ui::{format_title, vertical};
5use ratatui::{prelude::*, widgets::*};
6
7pub struct State {
8 pub query_language: QueryLanguage,
9 pub query_text: String,
10 pub query_cursor_line: usize,
11 pub query_cursor_col: usize,
12 pub log_group_search: String,
13 pub selected_log_groups: Vec<String>,
14 pub log_group_matches: Vec<String>,
15 pub show_dropdown: bool,
16 pub dropdown_selected: usize,
17 pub insights_start_time: Option<i64>,
18 pub insights_end_time: Option<i64>,
19 pub insights_date_range_type: DateRangeType,
20 pub insights_relative_amount: String,
21 pub insights_relative_unit: TimeUnit,
22 pub insights_focus: InsightsFocus,
23 pub query_completed: bool,
24 pub query_results: Vec<Vec<(String, String)>>,
25 pub results_selected: usize,
26 pub expanded_result: Option<usize>,
27 pub results_horizontal_scroll: usize,
28 pub results_vertical_scroll: usize,
29}
30
31impl Default for State {
32 fn default() -> Self {
33 Self {
34 query_language: QueryLanguage::LogsInsightsQL,
35 query_text: String::from("fields @timestamp, @message, @logStream, @log\n| sort @timestamp desc\n| limit 10000"),
36 query_cursor_line: 0,
37 query_cursor_col: 0,
38 log_group_search: String::new(),
39 selected_log_groups: Vec::new(),
40 log_group_matches: Vec::new(),
41 show_dropdown: false,
42 dropdown_selected: 0,
43 insights_start_time: None,
44 insights_end_time: None,
45 insights_date_range_type: DateRangeType::Relative,
46 insights_relative_amount: "1".to_string(),
47 insights_relative_unit: TimeUnit::Hours,
48 insights_focus: InsightsFocus::Query,
49 query_completed: false,
50 query_results: Vec::new(),
51 results_selected: 0,
52 expanded_result: None,
53 results_horizontal_scroll: 0,
54 results_vertical_scroll: 0,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub enum QueryLanguage {
61 LogsInsightsQL,
62 PPL,
63 SQL,
64}
65
66impl crate::common::CyclicEnum for QueryLanguage {
67 const ALL: &'static [Self] = &[Self::LogsInsightsQL, Self::PPL, Self::SQL];
68}
69
70impl QueryLanguage {
71 pub fn name(&self) -> &'static str {
72 match self {
73 QueryLanguage::LogsInsightsQL => "Logs Insights QL",
74 QueryLanguage::PPL => "PPL",
75 QueryLanguage::SQL => "SQL",
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub enum InsightsFocus {
82 QueryLanguage,
83 DatePicker,
84 LogGroupSearch,
85 Query,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum DateRangeType {
90 Relative,
91 Absolute,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum TimeUnit {
96 Minutes,
97 Hours,
98 Days,
99 Weeks,
100}
101
102impl crate::common::CyclicEnum for TimeUnit {
103 const ALL: &'static [Self] = &[Self::Minutes, Self::Hours, Self::Days, Self::Weeks];
104}
105
106impl TimeUnit {
107 pub fn name(&self) -> &'static str {
108 match self {
109 TimeUnit::Minutes => "minutes",
110 TimeUnit::Hours => "hours",
111 TimeUnit::Days => "days",
112 TimeUnit::Weeks => "weeks",
113 }
114 }
115}
116
117pub fn render(frame: &mut Frame, app: &App, area: Rect) {
118 let query_lines = app
120 .insights_state
121 .insights
122 .query_text
123 .lines()
124 .count()
125 .max(1);
126 let query_height = (query_lines + 1).max(3) as u16;
127 let input_pane_height = 3 + 3 + query_height + 2 + 2; let main_chunks = Layout::default()
131 .direction(Direction::Vertical)
132 .constraints([Constraint::Length(input_pane_height), Constraint::Min(0)])
133 .split(area);
134
135 render_input_pane(frame, app, main_chunks[0], query_height);
137
138 render_results_pane(frame, app, main_chunks[1]);
140}
141
142fn render_input_pane(frame: &mut Frame, app: &App, area: Rect, query_height: u16) {
143 let is_active = app.mode == Mode::InsightsInput
144 && !matches!(
145 app.mode,
146 Mode::SpaceMenu
147 | Mode::ServicePicker
148 | Mode::ColumnSelector
149 | Mode::ErrorModal
150 | Mode::HelpModal
151 | Mode::RegionPicker
152 | Mode::CalendarPicker
153 | Mode::TabPicker
154 );
155 let border_style = if is_active {
156 Style::default().fg(Color::Green)
157 } else {
158 Style::default()
159 };
160
161 let block = Block::default()
162 .title(format_title("Logs Insights"))
163 .borders(Borders::ALL)
164 .border_type(BorderType::Rounded)
165 .border_style(border_style);
166
167 let inner = block.inner(area);
168 frame.render_widget(block, area);
169
170 let chunks = vertical(
171 [
172 Constraint::Length(3),
173 Constraint::Length(3),
174 Constraint::Length(query_height + 2),
175 ],
176 inner,
177 );
178
179 let row1_chunks = Layout::default()
181 .direction(Direction::Horizontal)
182 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
183 .split(chunks[0]);
184
185 let ql_focused = app.mode == Mode::InsightsInput
187 && app.insights_state.insights.insights_focus == InsightsFocus::QueryLanguage;
188 let ql_style = if ql_focused {
189 Style::default().fg(Color::Green)
190 } else {
191 Style::default()
192 };
193
194 let ql_block = Block::default()
195 .borders(Borders::ALL)
196 .border_type(BorderType::Rounded)
197 .border_style(ql_style);
198 let ql_text = format!(" {} ", app.insights_state.insights.query_language.name());
199 let ql_para = Paragraph::new(ql_text).block(ql_block);
200 frame.render_widget(ql_para, row1_chunks[0]);
201
202 let date_focused = app.mode == Mode::InsightsInput
204 && app.insights_state.insights.insights_focus == InsightsFocus::DatePicker;
205 let date_style = if date_focused {
206 Style::default().fg(Color::Green)
207 } else {
208 Style::default()
209 };
210
211 let date_block = Block::default()
212 .borders(Borders::ALL)
213 .border_type(BorderType::Rounded)
214 .border_style(date_style);
215 let date_text = format!(
216 " Last {} {} ",
217 app.insights_state.insights.insights_relative_amount,
218 app.insights_state.insights.insights_relative_unit.name()
219 );
220 let date_para = Paragraph::new(date_text).block(date_block);
221 frame.render_widget(date_para, row1_chunks[1]);
222
223 let row2_chunks = Layout::default()
225 .direction(Direction::Horizontal)
226 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
227 .split(chunks[1]);
228
229 let combo_block = crate::ui::rounded_block();
231 let combo_text = " Log group name ";
232 let combo_para = Paragraph::new(combo_text).block(combo_block);
233 frame.render_widget(combo_para, row2_chunks[0]);
234
235 let search_focused = (app.mode == Mode::InsightsInput || app.mode == Mode::Normal)
237 && app.insights_state.insights.insights_focus == InsightsFocus::LogGroupSearch;
238 let search_style = if search_focused {
239 Style::default().fg(Color::Green)
240 } else {
241 Style::default()
242 };
243
244 let search_block = Block::default()
245 .borders(Borders::ALL)
246 .border_type(BorderType::Rounded)
247 .border_style(search_style);
248
249 let search_text = if !app.insights_state.insights.show_dropdown
250 && !app.insights_state.insights.selected_log_groups.is_empty()
251 {
252 let count = app.insights_state.insights.selected_log_groups.len();
253 format!(
254 " {} log group{} selected",
255 count,
256 if count == 1 { "" } else { "s" }
257 )
258 } else if app.insights_state.insights.log_group_search.is_empty() {
259 " Select up to 50 groups".to_string()
260 } else {
261 format!(" {} ", app.insights_state.insights.log_group_search)
262 };
263
264 let search_para = Paragraph::new(search_text)
265 .style(
266 if app.insights_state.insights.log_group_search.is_empty()
267 && app.insights_state.insights.selected_log_groups.is_empty()
268 {
269 Style::default().fg(Color::DarkGray)
270 } else {
271 Style::default()
272 },
273 )
274 .block(search_block);
275 frame.render_widget(search_para, row2_chunks[1]);
276
277 let query_focused = app.mode == Mode::InsightsInput
279 && app.insights_state.insights.insights_focus == InsightsFocus::Query;
280 let query_style = if query_focused {
281 Style::default().fg(Color::Green)
282 } else {
283 Style::default()
284 };
285
286 let query_block = Block::default()
287 .borders(Borders::ALL)
288 .border_type(BorderType::Rounded)
289 .border_style(query_style);
290
291 let query_inner = query_block.inner(chunks[2]);
292 frame.render_widget(query_block, chunks[2]);
293
294 let query_chunks = Layout::default()
296 .direction(Direction::Horizontal)
297 .constraints([Constraint::Length(5), Constraint::Min(0)])
298 .split(query_inner);
299
300 let num_lines = if app.insights_state.insights.query_text.is_empty() {
302 1
303 } else {
304 let base_lines = app.insights_state.insights.query_text.lines().count();
305 if app.insights_state.insights.query_text.ends_with('\n') {
306 base_lines + 1
307 } else {
308 base_lines
309 }
310 };
311 let line_numbers: Vec<String> = (1..=num_lines).map(|i| format!("{:>3} ", i)).collect();
312 let line_num_text = line_numbers.join("\n");
313 let line_num_para = Paragraph::new(line_num_text).style(Style::default().fg(Color::DarkGray));
314 frame.render_widget(line_num_para, query_chunks[0]);
315
316 let query_lines = highlight_insights_query(&app.insights_state.insights.query_text);
318 let query_para = Paragraph::new(query_lines);
319 frame.render_widget(query_para, query_chunks[1]);
320
321 let dropdown_visible = app.insights_state.insights.show_dropdown
323 && !app.insights_state.insights.log_group_matches.is_empty()
324 && (app.mode == Mode::InsightsInput
325 || (app.mode == Mode::Normal
326 && app.insights_state.insights.insights_focus == InsightsFocus::LogGroupSearch));
327 if dropdown_visible {
328 let row2_chunks = Layout::default()
329 .direction(Direction::Horizontal)
330 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
331 .split(chunks[1]);
332
333 let dropdown_height =
334 (app.insights_state.insights.log_group_matches.len() as u16).min(10) + 2;
335 let dropdown_area = Rect {
336 x: row2_chunks[1].x,
337 y: row2_chunks[1].y + row2_chunks[1].height,
338 width: row2_chunks[1].width,
339 height: dropdown_height.min(
340 area.height
341 .saturating_sub(row2_chunks[1].y + row2_chunks[1].height),
342 ),
343 };
344
345 let items: Vec<ListItem> = app
346 .insights_state
347 .insights
348 .log_group_matches
349 .iter()
350 .map(|name| {
351 let is_selected = app
352 .insights_state
353 .insights
354 .selected_log_groups
355 .contains(name);
356 let checkbox = if is_selected { "☑" } else { "☐" };
357 let text = format!("{} {}", checkbox, name);
358 ListItem::new(text)
359 })
360 .collect();
361
362 let list = List::new(items)
363 .block(crate::ui::rounded_block())
364 .highlight_style(
365 Style::default()
366 .bg(Color::DarkGray)
367 .add_modifier(Modifier::BOLD),
368 );
369
370 let mut state = ListState::default();
371 state.select(Some(app.insights_state.insights.dropdown_selected));
372
373 frame.render_widget(Clear, dropdown_area);
374 frame.render_stateful_widget(list, dropdown_area, &mut state);
375 }
376}
377
378fn render_results_pane(frame: &mut Frame, app: &App, area: Rect) {
379 frame.render_widget(Clear, area);
380
381 let is_active = app.mode == Mode::Normal
382 && !matches!(
383 app.mode,
384 Mode::SpaceMenu
385 | Mode::ServicePicker
386 | Mode::ColumnSelector
387 | Mode::ErrorModal
388 | Mode::HelpModal
389 | Mode::RegionPicker
390 | Mode::CalendarPicker
391 | Mode::TabPicker
392 );
393 let border_style = if is_active {
394 Style::default().fg(Color::Green)
395 } else {
396 Style::default()
397 };
398
399 let results_block = Block::default()
400 .title(format_title(&format!(
401 "Logs ({})",
402 app.insights_state.insights.query_results.len()
403 )))
404 .borders(Borders::ALL)
405 .border_type(BorderType::Rounded)
406 .border_style(border_style);
407
408 let results_inner = results_block.inner(area);
409 frame.render_widget(results_block, area);
410
411 if app.log_groups_state.loading {
413 let loading_text = "Executing query...";
414 let loading = Paragraph::new(loading_text)
415 .alignment(Alignment::Center)
416 .style(Style::default().fg(Color::Yellow));
417
418 let centered_area = Rect {
419 x: results_inner.x,
420 y: results_inner.y + results_inner.height / 3,
421 width: results_inner.width,
422 height: 1,
423 };
424 frame.render_widget(loading, centered_area);
425 } else if app.insights_state.insights.query_results.is_empty() {
426 let display_text = if app.insights_state.insights.query_completed {
427 "No results found\n\nTry adjusting your query or time range"
428 } else {
429 "No results\nRun a query to see related events"
430 };
431
432 let no_results = Paragraph::new(display_text)
433 .alignment(Alignment::Center)
434 .style(Style::default().fg(Color::DarkGray));
435
436 let centered_area = Rect {
437 x: results_inner.x,
438 y: results_inner.y + results_inner.height / 3,
439 width: results_inner.width,
440 height: 2,
441 };
442 frame.render_widget(no_results, centered_area);
443 } else {
444 let num_cols = app
446 .insights_state
447 .insights
448 .query_results
449 .first()
450 .map(|r| r.len())
451 .unwrap_or(0);
452 let scroll_offset = app.insights_state.insights.results_horizontal_scroll;
453
454 let mut all_rows = Vec::new();
455 let mut row_to_result_idx = Vec::new();
456
457 for (idx, result_row) in app.insights_state.insights.query_results.iter().enumerate() {
458 let is_expanded = app.insights_state.insights.expanded_result == Some(idx);
459 let is_selected = idx == app.insights_state.insights.results_selected;
460
461 let cells: Vec<Cell> = result_row
463 .iter()
464 .enumerate()
465 .skip(scroll_offset)
466 .map(|(i, (field, value))| {
467 let formatted_value = if field == "@timestamp" {
468 format_timestamp_value(value)
469 } else {
470 value.replace('\t', " ")
471 };
472
473 let cell_content = if i > scroll_offset {
474 format!("⋮ {}", formatted_value)
475 } else if i == scroll_offset {
476 crate::ui::table::format_expandable_with_selection(
478 &formatted_value,
479 is_expanded,
480 is_selected,
481 )
482 } else {
483 formatted_value
484 };
485
486 Cell::from(cell_content)
487 })
488 .collect();
489
490 row_to_result_idx.push(idx);
491 all_rows.push(Row::new(cells).height(1));
492
493 if is_expanded {
495 for (field, value) in result_row.iter() {
496 let formatted_value = if field == "@timestamp" {
497 format_timestamp_value(value)
498 } else {
499 value.replace('\t', " ")
500 };
501 let _detail_text = format!(" {}: {}", field, formatted_value);
502
503 let mut detail_cells = Vec::new();
505 for _ in 0..result_row.iter().skip(scroll_offset).count() {
506 detail_cells.push(Cell::from(""));
507 }
508 row_to_result_idx.push(idx);
509 all_rows.push(Row::new(detail_cells).height(1));
510 }
511 }
512 }
513
514 let (headers, widths) =
515 if let Some(first_row) = app.insights_state.insights.query_results.first() {
516 let headers: Vec<Cell> = first_row
517 .iter()
518 .enumerate()
519 .skip(scroll_offset)
520 .map(|(i, (field, _))| {
521 let name = if i > scroll_offset {
522 format!("⋮ {}", field)
523 } else {
524 field.to_string()
525 };
526 Cell::from(name).style(
527 Style::default()
528 .fg(Color::Cyan)
529 .add_modifier(Modifier::BOLD),
530 )
531 })
532 .collect();
533
534 let visible_cols: Vec<_> = first_row.iter().skip(scroll_offset).collect();
535 let widths: Vec<Constraint> = visible_cols
536 .iter()
537 .enumerate()
538 .map(|(i, (field, _))| {
539 if i == visible_cols.len() - 1 {
540 Constraint::Min(0)
542 } else if field == "@timestamp" {
543 Constraint::Length(28)
544 } else {
545 Constraint::Length(50)
546 }
547 })
548 .collect();
549
550 (headers, widths)
551 } else {
552 (vec![], vec![])
553 };
554
555 let header = Row::new(headers).style(Style::default().bg(Color::White).fg(Color::Black));
556
557 let table = Table::new(all_rows, widths)
558 .header(header)
559 .column_spacing(1)
560 .row_highlight_style(
561 Style::default()
562 .bg(Color::DarkGray)
563 .add_modifier(Modifier::BOLD),
564 )
565 .highlight_symbol("");
566
567 let mut state = TableState::default();
568 let table_idx = row_to_result_idx
569 .iter()
570 .position(|&i| i == app.insights_state.insights.results_selected)
571 .unwrap_or(0);
572 state.select(Some(table_idx));
573
574 frame.render_stateful_widget(table, results_inner, &mut state);
575
576 if let Some(expanded_idx) = app.insights_state.insights.expanded_result {
578 if let Some(result_row) = app.insights_state.insights.query_results.get(expanded_idx) {
579 let mut row_y = 0;
581 for (i, &idx) in row_to_result_idx.iter().enumerate() {
582 if idx == expanded_idx {
583 row_y = i;
584 break;
585 }
586 }
587
588 for (line_offset, (field, value)) in result_row.iter().enumerate() {
590 let formatted_value = if field == "@timestamp" {
591 format_timestamp_value(value)
592 } else {
593 value.replace('\t', " ")
594 };
595 let detail_text = format!(" {}: {}", field, formatted_value);
596
597 let y = results_inner.y + 1 + row_y as u16 + 1 + line_offset as u16; if y >= results_inner.y + results_inner.height {
599 break;
600 }
601
602 let line_area = Rect {
603 x: results_inner.x,
604 y,
605 width: results_inner.width,
606 height: 1,
607 };
608
609 let paragraph = Paragraph::new(detail_text);
610 frame.render_widget(paragraph, line_area);
611 }
612 }
613 }
614
615 render_vertical_scrollbar(
616 frame,
617 results_inner,
618 app.insights_state.insights.query_results.len(),
619 app.insights_state.insights.results_selected,
620 );
621
622 if app.insights_state.insights.results_horizontal_scroll > 0 {
623 let h_scrollbar_area = Rect {
624 x: results_inner.x,
625 y: results_inner.y + results_inner.height - 1,
626 width: results_inner.width,
627 height: 1,
628 };
629 render_horizontal_scrollbar(
630 frame,
631 h_scrollbar_area,
632 app.insights_state.insights.results_horizontal_scroll,
633 num_cols.saturating_sub(1).max(1),
634 );
635 }
636 }
637}
638
639fn format_timestamp_value(value: &str) -> String {
640 if let Ok(millis) = value.parse::<i64>() {
642 use chrono::DateTime;
643 if let Some(dt) =
644 DateTime::from_timestamp(millis / 1000, ((millis % 1000) * 1_000_000) as u32)
645 {
646 return format_timestamp(&dt);
647 }
648 }
649 value.to_string()
651}
652
653fn highlight_insights_query(query: &str) -> Vec<Line<'_>> {
654 const KEYWORDS: &[&str] = &[
655 "fields",
656 "filter",
657 "stats",
658 "sort",
659 "limit",
660 "parse",
661 "display",
662 "dedup",
663 "by",
664 "as",
665 "asc",
666 "desc",
667 "in",
668 "like",
669 "and",
670 "or",
671 "not",
672 "count",
673 "sum",
674 "avg",
675 "min",
676 "max",
677 "stddev",
678 "pct",
679 "earliest",
680 "latest",
681 "sortsFirst",
682 "sortsLast",
683 "concat",
684 "strlen",
685 "trim",
686 "ltrim",
687 "rtrim",
688 "tolower",
689 "toupper",
690 "substr",
691 "replace",
692 "strcontains",
693 "isempty",
694 "isblank",
695 "ispresent",
696 "abs",
697 "ceil",
698 "floor",
699 "greatest",
700 "least",
701 "log",
702 "sqrt",
703 "bin",
704 "dateceil",
705 "datefloor",
706 "fromMillis",
707 "toMillis",
708 ];
709
710 query
711 .lines()
712 .map(|line| {
713 let mut spans = Vec::new();
714 let mut current = String::new();
715 let chars = line.chars().peekable();
716
717 for ch in chars {
718 if ch.is_whitespace() || ch == '|' || ch == ',' || ch == '(' || ch == ')' {
719 if !current.is_empty() {
720 let is_keyword = KEYWORDS.contains(¤t.to_lowercase().as_str());
721 let is_at_field = current.starts_with('@');
722
723 let style = if is_keyword {
724 Style::default().fg(Color::Blue)
725 } else if is_at_field {
726 Style::default().add_modifier(Modifier::ITALIC)
727 } else {
728 Style::default()
729 };
730
731 spans.push(Span::styled(current.clone(), style));
732 current.clear();
733 }
734 spans.push(Span::raw(ch.to_string()));
735 } else {
736 current.push(ch);
737 }
738 }
739
740 if !current.is_empty() {
741 let is_keyword = KEYWORDS.contains(¤t.to_lowercase().as_str());
742 let is_at_field = current.starts_with('@');
743
744 let style = if is_keyword {
745 Style::default().fg(Color::Blue)
746 } else if is_at_field {
747 Style::default().add_modifier(Modifier::ITALIC)
748 } else {
749 Style::default()
750 };
751
752 spans.push(Span::styled(current, style));
753 }
754
755 Line::from(spans)
756 })
757 .collect()
758}