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