diff --git a/src/ui/app.rs b/src/ui/app.rs index cd3e4e5..ec3c09b 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)] @@ -154,6 +155,10 @@ 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>)>, } @@ -274,6 +279,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,6 +484,8 @@ impl App { table_inspector: None, export_selected: 0, + query_stats: QueryStats::default(), + stats_scroll: 0, pending_connection: None, } } @@ -504,6 +572,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, } } @@ -914,6 +983,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 +1293,22 @@ 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; + } + _ => {} + } + Ok(()) + } + async fn open_table_inspector(&mut self) { if self.sidebar_tab != SidebarTab::Tables || self.connection.client.is_none() { return; @@ -1649,6 +1738,11 @@ 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); @@ -1891,3 +1985,41 @@ 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..ce3bd43 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -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); @@ -980,8 +985,16 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { Style::default().fg(theme.text_muted).bg(theme.bg_secondary) }; - // Right section: help hints - let right_text = "? Help | Ctrl+Q/D Quit "; + // Right section: stats + help hints + 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 +1475,113 @@ 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 panel_width = 50.min(area.width.saturating_sub(4)); + let panel_height = 20.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 ") + .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 stats = &app.query_stats; + let mut lines: Vec = Vec::new(); + + 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!( + " Avg time: {:.2} ms", + stats.session_avg_time_ms() + ), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from("")); + 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(theme.error), + ))); + 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!(" Avg time: {:.2} ms", stats.avg_time_ms()), + Style::default().fg(theme.text_primary), + ))); + 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(theme.warning), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" Total time: {:.2} ms", stats.total_time_ms), + Style::default().fg(theme.text_primary), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Esc/q: Close | Up/Down: Scroll", + Style::default().fg(theme.text_muted), + ))); + + 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(); @@ -1516,6 +1636,7 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Ctrl+C Copy cell value", " Ctrl+E Toggle EXPLAIN plan view", " Ctrl+S Export results", + " Ctrl+Shift+S Query statistics", " Ctrl+[/] Prev/Next result set", " PageUp/Down Scroll results", "",