Skip to main content

rusticity_term/ui/cw/
insights.rs

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    // Calculate query text area height
119    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; // rows + borders
128
129    // Split into input pane and results pane
130    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
136    render_input_pane(frame, app, main_chunks[0], query_height);
137
138    // Render results pane
139    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    // Row 1: Query Language selector (left) and Date Picker (right)
180    let row1_chunks = Layout::default()
181        .direction(Direction::Horizontal)
182        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
183        .split(chunks[0]);
184
185    // Query Language selector
186    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    // Date Picker
203    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    // Row 2: "Select log groups by" combo and Selection criteria input
224    let row2_chunks = Layout::default()
225        .direction(Direction::Horizontal)
226        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
227        .split(chunks[1]);
228
229    // Combo box (static for now)
230    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    // Log group search input
236    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    // Row 3: Query editor with line numbers
278    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    // Split for line numbers and query text
295    let query_chunks = Layout::default()
296        .direction(Direction::Horizontal)
297        .constraints([Constraint::Length(5), Constraint::Min(0)])
298        .split(query_inner);
299
300    // Line numbers - count actual lines, add 1 if text ends with newline
301    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    // Query text with syntax highlighting
317    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    // Render dropdown if active
322    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    // Show loading message if executing
410    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        // Display results as table with inline expansion
443        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            // Main row
460            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                        // First visible column gets arrow indicator
475                        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 expanded, add empty rows for space
492            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                    // Add empty row
502                    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                            // Last column takes all remaining width
539                            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        // Render expanded content as overlay
575        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                // Find row position
578                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                // Render each field as overlay
587                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; // +1 for header, +1 for main row
596                    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    // Try to parse as milliseconds timestamp
639    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    // If parsing fails, return original value
648    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(&current.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(&current.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}