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::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    // Calculate query text area height
128    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; // rows + borders
137
138    // Split into input pane and results pane
139    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
145    render_input_pane(frame, app, main_chunks[0], query_height);
146
147    // Render results pane
148    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    // Row 1: Query Language selector (left) and Date Picker (right)
188    let row1_chunks = Layout::default()
189        .direction(Direction::Horizontal)
190        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
191        .split(chunks[0]);
192
193    // Query Language selector
194    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    // Date Picker
210    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    // Row 2: "Select log groups by" combo and Selection criteria input
230    let row2_chunks = Layout::default()
231        .direction(Direction::Horizontal)
232        .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
233        .split(chunks[1]);
234
235    // Combo box (static for now)
236    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    // Log group search input
242    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    // Row 3: Query editor with line numbers
283    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    // Split for line numbers and query text
299    let query_chunks = Layout::default()
300        .direction(Direction::Horizontal)
301        .constraints([Constraint::Length(5), Constraint::Min(0)])
302        .split(query_inner);
303
304    // Line numbers - count actual lines, add 1 if text ends with newline
305    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    // Query text with syntax highlighting
321    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    // Render dropdown if active
326    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    // Show loading message if executing
413    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        // Display results as table with inline expansion
446        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            // Main row
463            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                        // First visible column gets arrow indicator
478                        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 expanded, add empty rows for space
495            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                    // Add empty row
505                    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                            // Last column takes all remaining width
542                            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        // Render expanded content as overlay
578        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                // Find row position
581                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                // Render each field as overlay
590                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; // +1 for header, +1 for main row
599                    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    // Try to parse as milliseconds timestamp
642    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    // If parsing fails, return original value
651    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(&current.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(&current.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}