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
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 if app.mode == Mode::InsightsInput
323 && app.insights_state.insights.show_dropdown
324 && !app.insights_state.insights.log_group_matches.is_empty()
325 {
326 let row2_chunks = Layout::default()
327 .direction(Direction::Horizontal)
328 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
329 .split(chunks[1]);
330
331 let dropdown_height =
332 (app.insights_state.insights.log_group_matches.len() as u16).min(10) + 2;
333 let dropdown_area = Rect {
334 x: row2_chunks[1].x,
335 y: row2_chunks[1].y + row2_chunks[1].height,
336 width: row2_chunks[1].width,
337 height: dropdown_height.min(
338 area.height
339 .saturating_sub(row2_chunks[1].y + row2_chunks[1].height),
340 ),
341 };
342
343 let items: Vec<ListItem> = app
344 .insights_state
345 .insights
346 .log_group_matches
347 .iter()
348 .map(|name| {
349 let is_selected = app
350 .insights_state
351 .insights
352 .selected_log_groups
353 .contains(name);
354 let checkbox = if is_selected { "☑" } else { "☐" };
355 let text = format!("{} {}", checkbox, name);
356 ListItem::new(text)
357 })
358 .collect();
359
360 let list = List::new(items)
361 .block(crate::ui::rounded_block())
362 .highlight_style(
363 Style::default()
364 .bg(Color::DarkGray)
365 .add_modifier(Modifier::BOLD),
366 );
367
368 let mut state = ListState::default();
369 state.select(Some(app.insights_state.insights.dropdown_selected));
370
371 frame.render_widget(Clear, dropdown_area);
372 frame.render_stateful_widget(list, dropdown_area, &mut state);
373 }
374}
375
376fn render_results_pane(frame: &mut Frame, app: &App, area: Rect) {
377 frame.render_widget(Clear, area);
378
379 let is_active = app.mode == Mode::Normal
380 && !matches!(
381 app.mode,
382 Mode::SpaceMenu
383 | Mode::ServicePicker
384 | Mode::ColumnSelector
385 | Mode::ErrorModal
386 | Mode::HelpModal
387 | Mode::RegionPicker
388 | Mode::CalendarPicker
389 | Mode::TabPicker
390 );
391 let border_style = if is_active {
392 Style::default().fg(Color::Green)
393 } else {
394 Style::default()
395 };
396
397 let results_block = Block::default()
398 .title(format_title(&format!(
399 "Logs ({})",
400 app.insights_state.insights.query_results.len()
401 )))
402 .borders(Borders::ALL)
403 .border_type(BorderType::Rounded)
404 .border_style(border_style);
405
406 let results_inner = results_block.inner(area);
407 frame.render_widget(results_block, area);
408
409 if app.log_groups_state.loading {
411 let loading_text = "Executing query...";
412 let loading = Paragraph::new(loading_text)
413 .alignment(Alignment::Center)
414 .style(Style::default().fg(Color::Yellow));
415
416 let centered_area = Rect {
417 x: results_inner.x,
418 y: results_inner.y + results_inner.height / 3,
419 width: results_inner.width,
420 height: 1,
421 };
422 frame.render_widget(loading, centered_area);
423 } else if app.insights_state.insights.query_results.is_empty() {
424 let display_text = if app.insights_state.insights.query_completed {
425 "No results found\n\nTry adjusting your query or time range"
426 } else {
427 "No results\nRun a query to see related events"
428 };
429
430 let no_results = Paragraph::new(display_text)
431 .alignment(Alignment::Center)
432 .style(Style::default().fg(Color::DarkGray));
433
434 let centered_area = Rect {
435 x: results_inner.x,
436 y: results_inner.y + results_inner.height / 3,
437 width: results_inner.width,
438 height: 2,
439 };
440 frame.render_widget(no_results, centered_area);
441 } else {
442 let num_cols = app
444 .insights_state
445 .insights
446 .query_results
447 .first()
448 .map(|r| r.len())
449 .unwrap_or(0);
450 let scroll_offset = app.insights_state.insights.results_horizontal_scroll;
451
452 let mut all_rows = Vec::new();
453 let mut row_to_result_idx = Vec::new();
454
455 for (idx, result_row) in app.insights_state.insights.query_results.iter().enumerate() {
456 let is_expanded = app.insights_state.insights.expanded_result == Some(idx);
457 let is_selected = idx == app.insights_state.insights.results_selected;
458
459 let cells: Vec<Cell> = result_row
461 .iter()
462 .enumerate()
463 .skip(scroll_offset)
464 .map(|(i, (field, value))| {
465 let formatted_value = if field == "@timestamp" {
466 format_timestamp_value(value)
467 } else {
468 value.replace('\t', " ")
469 };
470
471 let cell_content = if i > scroll_offset {
472 format!("⋮ {}", formatted_value)
473 } else if i == scroll_offset {
474 crate::ui::table::format_expandable_with_selection(
476 &formatted_value,
477 is_expanded,
478 is_selected,
479 )
480 } else {
481 formatted_value
482 };
483
484 Cell::from(cell_content)
485 })
486 .collect();
487
488 row_to_result_idx.push(idx);
489 all_rows.push(Row::new(cells).height(1));
490
491 if is_expanded {
493 for (field, value) in result_row.iter() {
494 let formatted_value = if field == "@timestamp" {
495 format_timestamp_value(value)
496 } else {
497 value.replace('\t', " ")
498 };
499 let _detail_text = format!(" {}: {}", field, formatted_value);
500
501 let mut detail_cells = Vec::new();
503 for _ in 0..result_row.iter().skip(scroll_offset).count() {
504 detail_cells.push(Cell::from(""));
505 }
506 row_to_result_idx.push(idx);
507 all_rows.push(Row::new(detail_cells).height(1));
508 }
509 }
510 }
511
512 let (headers, widths) =
513 if let Some(first_row) = app.insights_state.insights.query_results.first() {
514 let headers: Vec<Cell> = first_row
515 .iter()
516 .enumerate()
517 .skip(scroll_offset)
518 .map(|(i, (field, _))| {
519 let name = if i > scroll_offset {
520 format!("⋮ {}", field)
521 } else {
522 field.to_string()
523 };
524 Cell::from(name).style(
525 Style::default()
526 .fg(Color::Cyan)
527 .add_modifier(Modifier::BOLD),
528 )
529 })
530 .collect();
531
532 let visible_cols: Vec<_> = first_row.iter().skip(scroll_offset).collect();
533 let widths: Vec<Constraint> = visible_cols
534 .iter()
535 .enumerate()
536 .map(|(i, (field, _))| {
537 if i == visible_cols.len() - 1 {
538 Constraint::Min(0)
540 } else if field == "@timestamp" {
541 Constraint::Length(28)
542 } else {
543 Constraint::Length(50)
544 }
545 })
546 .collect();
547
548 (headers, widths)
549 } else {
550 (vec![], vec![])
551 };
552
553 let header = Row::new(headers).style(Style::default().bg(Color::White).fg(Color::Black));
554
555 let table = Table::new(all_rows, widths)
556 .header(header)
557 .column_spacing(1)
558 .row_highlight_style(
559 Style::default()
560 .bg(Color::DarkGray)
561 .add_modifier(Modifier::BOLD),
562 )
563 .highlight_symbol("");
564
565 let mut state = TableState::default();
566 let table_idx = row_to_result_idx
567 .iter()
568 .position(|&i| i == app.insights_state.insights.results_selected)
569 .unwrap_or(0);
570 state.select(Some(table_idx));
571
572 frame.render_stateful_widget(table, results_inner, &mut state);
573
574 if let Some(expanded_idx) = app.insights_state.insights.expanded_result {
576 if let Some(result_row) = app.insights_state.insights.query_results.get(expanded_idx) {
577 let mut row_y = 0;
579 for (i, &idx) in row_to_result_idx.iter().enumerate() {
580 if idx == expanded_idx {
581 row_y = i;
582 break;
583 }
584 }
585
586 for (line_offset, (field, value)) in result_row.iter().enumerate() {
588 let formatted_value = if field == "@timestamp" {
589 format_timestamp_value(value)
590 } else {
591 value.replace('\t', " ")
592 };
593 let detail_text = format!(" {}: {}", field, formatted_value);
594
595 let y = results_inner.y + 1 + row_y as u16 + 1 + line_offset as u16; if y >= results_inner.y + results_inner.height {
597 break;
598 }
599
600 let line_area = Rect {
601 x: results_inner.x,
602 y,
603 width: results_inner.width,
604 height: 1,
605 };
606
607 let paragraph = Paragraph::new(detail_text);
608 frame.render_widget(paragraph, line_area);
609 }
610 }
611 }
612
613 render_vertical_scrollbar(
614 frame,
615 results_inner,
616 app.insights_state.insights.query_results.len(),
617 app.insights_state.insights.results_selected,
618 );
619
620 if app.insights_state.insights.results_horizontal_scroll > 0 {
621 let h_scrollbar_area = Rect {
622 x: results_inner.x,
623 y: results_inner.y + results_inner.height - 1,
624 width: results_inner.width,
625 height: 1,
626 };
627 render_horizontal_scrollbar(
628 frame,
629 h_scrollbar_area,
630 app.insights_state.insights.results_horizontal_scroll,
631 num_cols.saturating_sub(1).max(1),
632 );
633 }
634 }
635}
636
637fn format_timestamp_value(value: &str) -> String {
638 if let Ok(millis) = value.parse::<i64>() {
640 use chrono::DateTime;
641 if let Some(dt) =
642 DateTime::from_timestamp(millis / 1000, ((millis % 1000) * 1_000_000) as u32)
643 {
644 return format_timestamp(&dt);
645 }
646 }
647 value.to_string()
649}
650
651fn highlight_insights_query(query: &str) -> Vec<Line<'_>> {
652 const KEYWORDS: &[&str] = &[
653 "fields",
654 "filter",
655 "stats",
656 "sort",
657 "limit",
658 "parse",
659 "display",
660 "dedup",
661 "by",
662 "as",
663 "asc",
664 "desc",
665 "in",
666 "like",
667 "and",
668 "or",
669 "not",
670 "count",
671 "sum",
672 "avg",
673 "min",
674 "max",
675 "stddev",
676 "pct",
677 "earliest",
678 "latest",
679 "sortsFirst",
680 "sortsLast",
681 "concat",
682 "strlen",
683 "trim",
684 "ltrim",
685 "rtrim",
686 "tolower",
687 "toupper",
688 "substr",
689 "replace",
690 "strcontains",
691 "isempty",
692 "isblank",
693 "ispresent",
694 "abs",
695 "ceil",
696 "floor",
697 "greatest",
698 "least",
699 "log",
700 "sqrt",
701 "bin",
702 "dateceil",
703 "datefloor",
704 "fromMillis",
705 "toMillis",
706 ];
707
708 query
709 .lines()
710 .map(|line| {
711 let mut spans = Vec::new();
712 let mut current = String::new();
713 let chars = line.chars().peekable();
714
715 for ch in chars {
716 if ch.is_whitespace() || ch == '|' || ch == ',' || ch == '(' || ch == ')' {
717 if !current.is_empty() {
718 let is_keyword = KEYWORDS.contains(¤t.to_lowercase().as_str());
719 let is_at_field = current.starts_with('@');
720
721 let style = if is_keyword {
722 Style::default().fg(Color::Blue)
723 } else if is_at_field {
724 Style::default().add_modifier(Modifier::ITALIC)
725 } else {
726 Style::default()
727 };
728
729 spans.push(Span::styled(current.clone(), style));
730 current.clear();
731 }
732 spans.push(Span::raw(ch.to_string()));
733 } else {
734 current.push(ch);
735 }
736 }
737
738 if !current.is_empty() {
739 let is_keyword = KEYWORDS.contains(¤t.to_lowercase().as_str());
740 let is_at_field = current.starts_with('@');
741
742 let style = if is_keyword {
743 Style::default().fg(Color::Blue)
744 } else if is_at_field {
745 Style::default().add_modifier(Modifier::ITALIC)
746 } else {
747 Style::default()
748 };
749
750 spans.push(Span::styled(current, style));
751 }
752
753 Line::from(spans)
754 })
755 .collect()
756}