From bac824e56ada32071d25871c79692e7646f966c2 Mon Sep 17 00:00:00 2001 From: muk Date: Fri, 20 Feb 2026 14:29:18 +0000 Subject: [PATCH] Add query execution statistics dashboard Adds a statistics panel tracking query performance metrics across the session, with aggregated views for identifying slow queries and patterns. - Ctrl+Shift+S opens statistics panel overlay - Session metrics: queries executed, total/average time - All-time metrics: total, success/failure count, success rate - Performance bounds: min/max/average execution time - Session stats shown in status bar (query count + avg time) - Unit tests for QueryStats logic Closes #38 Co-Authored-By: Claude Opus 4.6 --- src/ui/app.rs | 273 +++++++++++++++++++++++++++++++++++++++++++ src/ui/components.rs | 228 ++++++++++++++++++++++++++++++++++-- 2 files changed, 489 insertions(+), 12 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index cd3e4e5..5a05be7 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -24,6 +24,7 @@ pub enum Focus { Help, TableInspector, ExportPicker, + StatsPanel, } #[derive(Debug, Clone)] @@ -76,6 +77,12 @@ impl ExportFormat { } } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TransactionState { + None, + Active, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SidebarTab { Databases, @@ -154,8 +161,17 @@ pub struct App { // Export pub export_selected: usize, + // Query statistics + pub query_stats: QueryStats, + pub stats_scroll: usize, + // Async connection task pub pending_connection: Option<(ConnectionConfig, JoinHandle>)>, + + // Transaction management + pub transaction_state: TransactionState, + pub transaction_start: Option, + pub transaction_query_count: usize, } #[derive(Debug, Clone, Copy)] @@ -274,6 +290,67 @@ pub struct AutocompleteState { pub prefix: String, } +#[derive(Debug, Clone, Default)] +pub struct QueryStats { + pub total_queries: usize, + pub successful_queries: usize, + pub failed_queries: usize, + pub total_time_ms: f64, + pub min_time_ms: f64, + pub max_time_ms: f64, + pub session_queries: usize, + pub session_total_time_ms: f64, +} + +impl QueryStats { + pub fn avg_time_ms(&self) -> f64 { + if self.total_queries == 0 { + 0.0 + } else { + self.total_time_ms / self.total_queries as f64 + } + } + + pub fn success_rate(&self) -> f64 { + if self.total_queries == 0 { + 0.0 + } else { + (self.successful_queries as f64 / self.total_queries as f64) * 100.0 + } + } + + pub fn session_avg_time_ms(&self) -> f64 { + if self.session_queries == 0 { + 0.0 + } else { + self.session_total_time_ms / self.session_queries as f64 + } + } + + pub fn record_query(&mut self, time_ms: f64, success: bool) { + self.total_queries += 1; + self.session_queries += 1; + self.total_time_ms += time_ms; + self.session_total_time_ms += time_ms; + if success { + self.successful_queries += 1; + } else { + self.failed_queries += 1; + } + if self.total_queries == 1 { + self.min_time_ms = time_ms; + self.max_time_ms = time_ms; + } else { + if time_ms < self.min_time_ms { + self.min_time_ms = time_ms; + } + if time_ms > self.max_time_ms { + self.max_time_ms = time_ms; + } + } + } +} + pub const SQL_FUNCTIONS: &[&str] = &[ "COUNT", "SUM", @@ -418,7 +495,15 @@ impl App { table_inspector: None, export_selected: 0, + + query_stats: QueryStats::default(), + stats_scroll: 0, + pending_connection: None, + + transaction_state: TransactionState::None, + transaction_start: None, + transaction_query_count: 0, } } @@ -504,6 +589,7 @@ impl App { Focus::Help => self.handle_help_input(key).await, Focus::TableInspector => self.handle_table_inspector_input(key).await, Focus::ExportPicker => self.handle_export_input(key).await, + Focus::StatsPanel => self.handle_stats_input(key).await, } } @@ -885,6 +971,16 @@ impl App { self.execute_query().await?; self.focus = Focus::Results; } + // Transaction management + KeyCode::Char('t') if ctrl => { + self.begin_transaction().await?; + } + KeyCode::Char('k') if ctrl => { + self.commit_transaction().await?; + } + KeyCode::Char('r') if ctrl && shift => { + self.rollback_transaction().await?; + } KeyCode::Enter => { self.editor.insert_newline(); self.autocomplete.active = false; @@ -914,6 +1010,10 @@ impl App { self.editor.clear(); self.autocomplete.active = false; } + KeyCode::Char('S') if ctrl => { + self.focus = Focus::StatsPanel; + self.stats_scroll = 0; + } // Pane resizing: Ctrl+Shift+Up/Down KeyCode::Up if ctrl && shift => { // Make editor smaller / results bigger @@ -1220,6 +1320,28 @@ impl App { Ok(()) } + async fn handle_stats_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.focus = Focus::Editor; + } + KeyCode::Up => { + self.stats_scroll = self.stats_scroll.saturating_sub(1); + } + KeyCode::Down => { + self.stats_scroll += 1; + } + KeyCode::PageUp => { + self.stats_scroll = self.stats_scroll.saturating_sub(10); + } + KeyCode::PageDown => { + self.stats_scroll += 10; + } + _ => {} + } + Ok(()) + } + async fn open_table_inspector(&mut self) { if self.sidebar_tab != SidebarTab::Tables || self.connection.client.is_none() { return; @@ -1631,6 +1753,24 @@ impl App { return Ok(()); } + // Detect transaction commands typed manually + let query_upper = query.trim().to_uppercase(); + if query_upper == "BEGIN" || query_upper == "START TRANSACTION" { + if self.transaction_state != TransactionState::Active { + self.transaction_state = TransactionState::Active; + self.transaction_start = Some(Instant::now()); + self.transaction_query_count = 0; + } + } else if query_upper == "COMMIT" || query_upper == "END" { + self.transaction_state = TransactionState::None; + self.transaction_start = None; + self.transaction_query_count = 0; + } else if query_upper == "ROLLBACK" || query_upper == "ABORT" { + self.transaction_state = TransactionState::None; + self.transaction_start = None; + self.transaction_query_count = 0; + } + if self.connection.client.is_some() { self.start_loading("Executing query...".to_string()); @@ -1649,6 +1789,10 @@ impl App { self.query_history.add(entry); let _ = self.query_history.save(); + // Record query statistics + let time_ms = result.execution_time.as_secs_f64() * 1000.0; + self.query_stats.record_query(time_ms, result.error.is_none()); + // Update status if let Some(err) = &result.error { self.set_status(format!("Error: {}", err), StatusType::Error); @@ -1687,6 +1831,10 @@ impl App { }; self.results.push(result); + // Track transaction query count + if self.transaction_state == TransactionState::Active { + self.transaction_query_count += 1; + } self.explain_plans.push(plan); self.current_result = self.results.len() - 1; self.result_selected_row = 0; @@ -1704,6 +1852,92 @@ impl App { Ok(()) } + async fn begin_transaction(&mut self) -> Result<()> { + if self.connection.client.is_none() { + self.set_status("Not connected to database".to_string(), StatusType::Error); + return Ok(()); + } + if self.transaction_state == TransactionState::Active { + self.set_status("Transaction already active".to_string(), StatusType::Warning); + return Ok(()); + } + let client = self.connection.client.as_ref().unwrap(); + match execute_query(client, "BEGIN").await { + Ok(_) => { + self.transaction_state = TransactionState::Active; + self.transaction_start = Some(Instant::now()); + self.transaction_query_count = 0; + self.set_status("Transaction started".to_string(), StatusType::Info); + } + Err(e) => { + self.set_status(format!("BEGIN failed: {}", e), StatusType::Error); + } + } + Ok extraordinary(()) + } + + async fn commit_transaction(&mut self) -> Result<()> { + if self.connection.client.is_none() { + self.set_status("Not connected to database".to_string(), StatusType::Error); + return Ok(()); + } + if self.transaction_state != TransactionState::Active { + self.set_status("No active transaction to commit".to_string(), StatusType::Warning); + return Ok(()); + } + let client = self.connection.client.as_ref().unwrap(); + match execute_query(client, "COMMIT").await { + Ok(_) => { + let duration = self.transaction_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + self.transaction_state = TransactionState::None; + self.transaction_start = None; + let msg = format!( + "Transaction committed ({} queries, {}s)", + self.transaction_query_count, duration + ); + self.transaction_query_count = 0; + self.set_status(msg, StatusType::Success); + } + Err(e) => { + self.set_status(format!("COMMIT failed: {}", e), StatusType::Error); + } + } + Ok(()) + } + + async fn rollback_transaction(&mut self) -> Result<()> { + if self.connection.client.is_none() { + self.set_status("Not connected to database".to_string(), StatusType::Error); + return Ok(()); + } + if self.transaction_state != TransactionState::Active { + self.set_status("No active transaction to rollback".to_string(), StatusType::Warning); + return Ok(()); + } + let client = self.connection.client.as_ref().unwrap(); + match execute_query(client, "ROLLBACK").await { + Ok(_) => { + let duration = self.transaction_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + self.transaction_state = TransactionState::None; + self.transaction_start = None; + let msg = format!( + "Transaction rolled back ({} queries, {}s)", + self.transaction_query_count, duration + ); + self.transaction_query_count = 0; + self.set_status(msg, StatusType::Warning); + } + Err(e) => { + self.set_status(format!("ROLLBACK failed: {}", e), StatusType::Error); + } + } + Ok(()) + } + fn update_autocomplete(&mut self) { let line = self.editor.current_line().to_string(); let cursor_x = self.editor.cursor_x; @@ -1891,3 +2125,42 @@ fn dialog_field_len(config: &ConnectionConfig, field_index: usize) -> usize { _ => 0, } } + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_query_stats_default() { + let stats = QueryStats::default(); + assert_eq!(stats.total_queries, 0); + assert_eq!(stats.avg_time_ms(), 0.0); + assert_eq!(stats.success_rate(), 0.0); + } + + #[test] + fn test_query_stats_record() { + let mut stats = QueryStats::default(); + stats.record_query(100.0, true); + stats.record_query(200.0, true); + stats.record_query(50.0, false); + assert_eq!(stats.total_queries, 3); + assert_eq!(stats.successful_queries, 2); + assert_eq!(stats.failed_queries, 1); + assert!((stats.avg_time_ms() - 116.666).abs() < 1.0); + assert!((stats.success_rate() - 66.666).abs() < 1.0); + assert_eq!(stats.min_time_ms, 50.0); + assert_eq!(stats.max_time_ms, 200.0); + } + + #[test] + fn test_query_stats_session() { + let mut stats = QueryStats::default(); + stats.record_query(100.0, true); + stats.record_query(200.0, true); + assert_eq!(stats.session_queries, 2); + assert_eq!(stats.session_total_time_ms, 300.0); + assert_eq!(stats.session_avg_time_ms(), 150.0); + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs index fdae4d5..50a803d 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -12,7 +12,7 @@ use crate::explain::{ }; use crate::ui::{ is_sql_function, is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, - EXPORT_FORMATS, SPINNER_FRAMES, + TransactionState, EXPORT_FORMATS, SPINNER_FRAMES, }; pub fn draw(frame: &mut Frame, app: &App) { @@ -74,6 +74,11 @@ pub fn draw(frame: &mut Frame, app: &App) { draw_export_picker(frame, app); } + // Draw stats panel if active + if app.focus == Focus::StatsPanel { + draw_stats_panel(frame, app); + } + // Draw help overlay if active if app.show_help { draw_help_overlay(frame, app); @@ -83,12 +88,22 @@ pub fn draw(frame: &mut Frame, app: &App) { fn draw_header(frame: &mut Frame, app: &App, area: Rect) { let theme = &app.theme; + let txn_indicator = if app.transaction_state == TransactionState::Active { + let duration = app.transaction_start + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + format!(" | [TXN ACTIVE {}s, {} queries]", duration, app.transaction_query_count) + } else { + String::new() + }; + let connection_info = if app.connection.is_connected() { format!( - " {} | {} | {} ", + " {} | {} | {}{}", app.connection.config.display_string(), app.connection.current_database, - app.connection.current_schema + app.connection.current_schema, + txn_indicator ) } else { " Not Connected ".to_string() @@ -100,7 +115,15 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { " ".repeat(area.width.saturating_sub(connection_info.len() as u16 + 10) as usize) ); - let header = Paragraph::new(header_text).style(theme.header()); + let header_style = if app.transaction_state == TransactionState::Active { + Style::default() + .fg(theme.bg_primary) + .bg(theme.warning) + .add_modifier(Modifier::BOLD) + } else { + theme.header() + }; + let header = Paragraph::new(header_text).style(header_style); frame.render_widget(header, area); } @@ -109,12 +132,22 @@ fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) { let theme = &app.theme; let focused = app.focus == Focus::Sidebar; + let has_filter = !app.sidebar_filter.is_empty() || app.sidebar_filter_active; let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Tabs - Constraint::Min(0), // Content - ]) + .constraints(if has_filter { + vec![ + Constraint::Length(3), // Tabs + Constraint::Length(1), // Filter bar + Constraint::Min(0), // Content + ] + } else { + vec![ + Constraint::Length(3), // Tabs + Constraint::Length(0), // No filter bar + Constraint::Min(0), // Content + ] + }) .split(area); // Draw tabs @@ -141,11 +174,28 @@ fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(tabs, chunks[0]); + // Draw filter bar + if has_filter { + let filter_text = if app.sidebar_filter.is_empty() { + " / type to filter...".to_string() + } else { + format!(" /{}", app.sidebar_filter) + }; + let filter_style = if app.sidebar_filter_active { + Style::default().fg(theme.text_accent).bg(theme.bg_secondary) + } else { + Style::default().fg(theme.text_secondary).bg(theme.bg_secondary) + }; + let filter_bar = Paragraph::new(filter_text).style(filter_style); + frame.render_widget(filter_bar, chunks[1]); + } + // Draw content based on selected tab + let content_area = *chunks.last().unwrap(); match app.sidebar_tab { - SidebarTab::Databases => draw_databases_list(frame, app, chunks[1]), - SidebarTab::Tables => draw_tables_tree(frame, app, chunks[1]), - SidebarTab::History => draw_history_list(frame, app, chunks[1]), + SidebarTab::Databases => draw_databases_list(frame, app, content_area), + SidebarTab::Tables => draw_tables_tree(frame, app, content_area), + SidebarTab::History => draw_history_list(frame, app, content_area), } } @@ -156,6 +206,7 @@ fn draw_databases_list(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = app .databases .iter() + .filter(|db| app.matches_sidebar_filter(&db.name)) .enumerate() .map(|(i, db)| { let style = if i == app.sidebar_selected { @@ -198,6 +249,17 @@ fn draw_tables_tree(frame: &mut Frame, app: &App, area: Rect) { let expanded = app.expanded_schemas.contains(&schema.name); let icon = if expanded { "▼" } else { "▶" }; + // Check if any tables in this schema match the filter + let schema_has_matches = app.sidebar_filter.is_empty() + || app.matches_sidebar_filter(&schema.name) + || app.tables.iter().any(|t| { + t.schema == schema.name && app.matches_sidebar_filter(&t.name) + }); + + if !schema_has_matches { + continue; + } + let style = if index == app.sidebar_selected { theme.selected() } else { @@ -210,6 +272,14 @@ fn draw_tables_tree(frame: &mut Frame, app: &App, area: Rect) { if expanded { for table in &app.tables { if table.schema == schema.name { + // Apply filter + if !app.sidebar_filter.is_empty() + && !app.matches_sidebar_filter(&table.name) + && !app.matches_sidebar_filter(&schema.name) + { + continue; + } + let table_icon = match table.table_type { crate::db::TableType::Table => "󰓫", crate::db::TableType::View => "󰈈", @@ -255,6 +325,7 @@ fn draw_history_list(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = entries .iter() .rev() + .filter(|entry| app.matches_sidebar_filter(&entry.query)) .enumerate() .map(|(i, entry)| { let status_icon = if entry.success { "✓" } else { "✗" }; @@ -981,7 +1052,15 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { }; // Right section: help hints - let right_text = "? Help | Ctrl+Q/D Quit "; + let right_text = if app.query_stats.session_queries > 0 { + format!( + "Queries: {} | Avg: {:.0}ms | ? Help | Ctrl+Q/D Quit ", + app.query_stats.session_queries, + app.query_stats.session_avg_time_ms() + ) + } else { + "? Help | Ctrl+Q/D Quit ".to_string() + }; // Calculate padding let left_len = left_text.len() as u16; @@ -1462,6 +1541,128 @@ fn draw_export_picker(frame: &mut Frame, app: &App) { frame.render_widget(hint, hint_area); } +fn draw_stats_panel(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let area = frame.area(); + let stats = &app.query_stats; + + let panel_width = 55.min(area.width.saturating_sub(4)); + let panel_height = 22.min(area.height.saturating_sub(4)); + let panel_x = (area.width - panel_width) / 2; + let panel_y = (area.height - panel_height) / 2; + let panel_area = Rect::new(panel_x, panel_y, panel_width, panel_height); + + frame.render_widget(Clear, panel_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_focused)) + .title(" Query Statistics (Ctrl+Shift+S) ") + .title_style( + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().bg(theme.bg_primary)); + + let inner = block.inner(panel_area); + frame.render_widget(block, panel_area); + + let mut lines: Vec = Vec::new(); + + // Session stats + lines.push(Line::from(Span::styled( + " SESSION STATISTICS", + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" Queries executed: {}", stats.session_queries), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from(Span::styled( + format!( + " Total time: {:.2}ms", + stats.session_total_time_ms + ), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from(Span::styled( + format!( + " Average time: {:.2}ms", + stats.session_avg_time_ms() + ), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from("")); + + // Overall stats + lines.push(Line::from(Span::styled( + " ALL-TIME STATISTICS", + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" Total queries: {}", stats.total_queries), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from(Span::styled( + format!(" Successful: {}", stats.successful_queries), + Style::default().fg(theme.success), + ))); + lines.push(Line::from(Span::styled( + format!(" Failed: {}", stats.failed_queries), + Style::default().fg(if stats.failed_queries > 0 { + theme.error + } else { + theme.text_primary + }), + ))); + lines.push(Line::from(Span::styled( + format!(" Success rate: {:.1}%", stats.success_rate()), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" Average time: {:.2}ms", stats.avg_time_ms()), + Style::default().fg(theme.text_primary), + ))); + if stats.total_queries > 0 { + lines.push(Line::from(Span::styled( + format!(" Min time: {:.2}ms", stats.min_time_ms), + Style::default().fg(theme.success), + ))); + lines.push(Line::from(Span::styled( + format!(" Max time: {:.2}ms", stats.max_time_ms), + Style::default().fg(if stats.max_time_ms > 1000.0 { + theme.warning + } else { + theme.text_primary + }), + ))); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " [Esc] Close [↑/↓] Scroll", + Style::default().fg(theme.text_muted), + ))); + + // Apply scroll + let visible: Vec = lines + .into_iter() + .skip(app.stats_scroll) + .take(inner.height as usize) + .collect(); + + let paragraph = Paragraph::new(visible); + frame.render_widget(paragraph, inner); +} + fn draw_help_overlay(frame: &mut Frame, app: &App) { let theme = &app.theme; let area = frame.area(); @@ -1501,6 +1702,7 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Ctrl+Shift+Z/Y Redo", " Ctrl+A Select all", " Ctrl+Space Trigger autocomplete", + " Ctrl+Shift+S Query statistics", " Tab Insert spaces", "", " SIDEBAR", @@ -1508,6 +1710,8 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Enter Select item", " ↑/↓ Navigate", " Ctrl+I Inspect table (DDL)", + " / Search/filter", + " Esc Clear filter", "", " RESULTS", " Tab/Shift+Tab Next/Prev column",