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 || 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    // 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 (works in both InsightsInput and Normal mode)
322    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    // Show loading message if executing
412    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        // Display results as table with inline expansion
445        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            // Main row
462            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                        // First visible column gets arrow indicator
477                        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 expanded, add empty rows for space
494            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                    // Add empty row
504                    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                            // Last column takes all remaining width
541                            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        // Render expanded content as overlay
577        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                // Find row position
580                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                // Render each field as overlay
589                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; // +1 for header, +1 for main row
598                    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    // Try to parse as milliseconds timestamp
641    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    // If parsing fails, return original value
650    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(&current.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(&current.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}