Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub enum Focus {
Help,
TableInspector,
ExportPicker,
StatsPanel,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -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<Result<Client>>)>,
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -418,6 +484,8 @@ impl App {

table_inspector: None,
export_selected: 0,
query_stats: QueryStats::default(),
stats_scroll: 0,
pending_connection: None,
}
}
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
125 changes: 123 additions & 2 deletions src/ui/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Line> = 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<Line> = 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();
Expand Down Expand Up @@ -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",
"",
Expand Down