Skip to main content

sqlmodel_console/renderables/
pool_status.rs

1//! Connection pool status display renderable.
2//!
3//! Provides a visual dashboard for connection pool status, showing utilization,
4//! health, and queue information at a glance.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sqlmodel_console::renderables::PoolStatusDisplay;
10//!
11//! // Create display with pool stats: active=8, idle=2, max=20, pending=0, timeouts=3
12//! let display = PoolStatusDisplay::new(8, 2, 20, 0, 3);
13//!
14//! // Rich mode: Styled panel with progress bar
15//! // Plain mode: Simple text output for agents
16//! println!("{}", display.render_plain());
17//! ```
18
19use crate::theme::Theme;
20use std::time::Duration;
21
22/// Pool health status based on utilization and queue depth.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum PoolHealth {
25    /// Pool is healthy: low utilization, no waiting requests
26    Healthy,
27    /// Pool is busy: high utilization (>80%) but no waiting requests
28    Busy,
29    /// Pool is degraded: some requests are waiting
30    Degraded,
31    /// Pool is exhausted: at capacity with significant waiting queue
32    Exhausted,
33}
34
35impl PoolHealth {
36    /// Get a human-readable status string.
37    #[must_use]
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Self::Healthy => "HEALTHY",
41            Self::Busy => "BUSY",
42            Self::Degraded => "DEGRADED",
43            Self::Exhausted => "EXHAUSTED",
44        }
45    }
46
47    /// Get the ANSI color code for this health status.
48    #[must_use]
49    pub fn color_code(&self) -> &'static str {
50        match self {
51            Self::Healthy => "\x1b[32m",        // Green
52            Self::Busy => "\x1b[33m",           // Yellow
53            Self::Degraded => "\x1b[38;5;208m", // Orange (256-color)
54            Self::Exhausted => "\x1b[31m",      // Red
55        }
56    }
57}
58
59impl std::fmt::Display for PoolHealth {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "{}", self.as_str())
62    }
63}
64
65/// Statistics snapshot for pool status display.
66///
67/// This trait allows PoolStatusDisplay to work with any type that provides
68/// pool statistics, including `sqlmodel_pool::PoolStats`.
69pub trait PoolStatsProvider {
70    /// Number of connections currently in use.
71    fn active_connections(&self) -> usize;
72    /// Number of idle connections available.
73    fn idle_connections(&self) -> usize;
74    /// Maximum number of connections allowed.
75    fn max_connections(&self) -> usize;
76    /// Minimum number of connections to maintain.
77    fn min_connections(&self) -> usize;
78    /// Number of requests waiting for a connection.
79    fn pending_requests(&self) -> usize;
80    /// Total connections ever created.
81    fn connections_created(&self) -> u64;
82    /// Total connections closed.
83    fn connections_closed(&self) -> u64;
84    /// Total successful acquires.
85    fn total_acquires(&self) -> u64;
86    /// Total acquire timeouts.
87    fn total_timeouts(&self) -> u64;
88}
89
90/// Display options for pool status.
91#[derive(Debug, Clone)]
92pub struct PoolStatusDisplay {
93    /// Active connections
94    active: usize,
95    /// Idle connections
96    idle: usize,
97    /// Maximum connections
98    max: usize,
99    /// Minimum connections
100    min: usize,
101    /// Pending requests
102    pending: usize,
103    /// Total connections created
104    created: u64,
105    /// Total connections closed
106    closed: u64,
107    /// Total acquires
108    acquires: u64,
109    /// Total timeouts
110    timeouts: u64,
111    /// Theme for styled output
112    theme: Theme,
113    /// Optional width constraint
114    width: Option<usize>,
115    /// Pool uptime
116    uptime: Option<Duration>,
117    /// Pool name/label
118    name: Option<String>,
119}
120
121impl PoolStatusDisplay {
122    /// Create a new pool status display from statistics.
123    #[must_use]
124    pub fn from_stats<S: PoolStatsProvider>(stats: &S) -> Self {
125        Self {
126            active: stats.active_connections(),
127            idle: stats.idle_connections(),
128            max: stats.max_connections(),
129            min: stats.min_connections(),
130            pending: stats.pending_requests(),
131            created: stats.connections_created(),
132            closed: stats.connections_closed(),
133            acquires: stats.total_acquires(),
134            timeouts: stats.total_timeouts(),
135            theme: Theme::default(),
136            width: None,
137            uptime: None,
138            name: None,
139        }
140    }
141
142    /// Create a pool status display with explicit values.
143    #[must_use]
144    pub fn new(active: usize, idle: usize, max: usize, min: usize, pending: usize) -> Self {
145        Self {
146            active,
147            idle,
148            max,
149            min,
150            pending,
151            created: 0,
152            closed: 0,
153            acquires: 0,
154            timeouts: 0,
155            theme: Theme::default(),
156            width: None,
157            uptime: None,
158            name: None,
159        }
160    }
161
162    /// Set the theme for styled output.
163    #[must_use]
164    pub fn theme(mut self, theme: Theme) -> Self {
165        self.theme = theme;
166        self
167    }
168
169    /// Set the display width.
170    #[must_use]
171    pub fn width(mut self, width: usize) -> Self {
172        self.width = Some(width);
173        self
174    }
175
176    /// Set the pool uptime.
177    #[must_use]
178    pub fn uptime(mut self, uptime: Duration) -> Self {
179        self.uptime = Some(uptime);
180        self
181    }
182
183    /// Set the pool name/label.
184    #[must_use]
185    pub fn name<S: Into<String>>(mut self, name: S) -> Self {
186        self.name = Some(name.into());
187        self
188    }
189
190    /// Set acquisition statistics.
191    #[must_use]
192    pub fn with_acquisition_stats(mut self, acquires: u64, timeouts: u64) -> Self {
193        self.acquires = acquires;
194        self.timeouts = timeouts;
195        self
196    }
197
198    /// Set lifetime statistics.
199    #[must_use]
200    pub fn with_lifetime_stats(mut self, created: u64, closed: u64) -> Self {
201        self.created = created;
202        self.closed = closed;
203        self
204    }
205
206    /// Get the total number of connections.
207    #[must_use]
208    pub fn total(&self) -> usize {
209        self.active + self.idle
210    }
211
212    /// Calculate pool utilization as a percentage.
213    #[must_use]
214    pub fn utilization(&self) -> f64 {
215        if self.max == 0 {
216            0.0
217        } else {
218            (self.active as f64 / self.max as f64) * 100.0
219        }
220    }
221
222    /// Determine pool health status.
223    #[must_use]
224    pub fn health(&self) -> PoolHealth {
225        let utilization = self.utilization();
226
227        if self.pending > 0 {
228            if self.pending >= self.max || self.active >= self.max {
229                PoolHealth::Exhausted
230            } else {
231                PoolHealth::Degraded
232            }
233        } else if utilization >= 80.0 {
234            PoolHealth::Busy
235        } else {
236            PoolHealth::Healthy
237        }
238    }
239
240    /// Format uptime duration as human-readable string.
241    fn format_uptime(duration: Duration) -> String {
242        let secs = duration.as_secs();
243        if secs < 60 {
244            format!("{}s", secs)
245        } else if secs < 3600 {
246            let mins = secs / 60;
247            let secs = secs % 60;
248            if secs == 0 {
249                format!("{}m", mins)
250            } else {
251                format!("{}m {}s", mins, secs)
252            }
253        } else if secs < 86400 {
254            let hours = secs / 3600;
255            let mins = (secs % 3600) / 60;
256            if mins == 0 {
257                format!("{}h", hours)
258            } else {
259                format!("{}h {}m", hours, mins)
260            }
261        } else {
262            let days = secs / 86400;
263            let hours = (secs % 86400) / 3600;
264            if hours == 0 {
265                format!("{}d", days)
266            } else {
267                format!("{}d {}h", days, hours)
268            }
269        }
270    }
271
272    /// Render as plain text for agent consumption.
273    #[must_use]
274    pub fn render_plain(&self) -> String {
275        let health = self.health();
276        let utilization = self.utilization();
277        let total = self.total();
278
279        let mut lines = Vec::new();
280
281        // Main status line
282        let name_prefix = self
283            .name
284            .as_ref()
285            .map(|n| format!("{}: ", n))
286            .unwrap_or_default();
287
288        lines.push(format!(
289            "{}Pool: {}/{} active ({:.0}%), {} waiting, {}",
290            name_prefix, self.active, self.max, utilization, self.pending, health
291        ));
292
293        // Detail line
294        lines.push(format!(
295            "  Active: {}, Idle: {}, Total: {}, Max: {}, Min: {}",
296            self.active, self.idle, total, self.max, self.min
297        ));
298
299        // Statistics line (if available)
300        if self.acquires > 0 || self.timeouts > 0 || self.created > 0 {
301            let mut stats_parts = Vec::new();
302
303            if self.acquires > 0 {
304                stats_parts.push(format!("Acquires: {}", self.acquires));
305            }
306            if self.timeouts > 0 {
307                stats_parts.push(format!("Timeouts: {}", self.timeouts));
308            }
309            if self.created > 0 {
310                stats_parts.push(format!("Created: {}", self.created));
311            }
312            if self.closed > 0 {
313                stats_parts.push(format!("Closed: {}", self.closed));
314            }
315
316            if !stats_parts.is_empty() {
317                lines.push(format!("  {}", stats_parts.join(", ")));
318            }
319        }
320
321        // Uptime line (if available)
322        if let Some(uptime) = self.uptime {
323            lines.push(format!("  Uptime: {}", Self::format_uptime(uptime)));
324        }
325
326        lines.join("\n")
327    }
328
329    /// Render with ANSI colors for terminal display.
330    #[must_use]
331    #[allow(clippy::cast_possible_truncation)]
332    pub fn render_styled(&self) -> String {
333        let health = self.health();
334        let utilization = self.utilization();
335        let width = self.width.unwrap_or(60);
336
337        let mut lines = Vec::new();
338
339        // Header with pool name
340        let title = self.name.as_ref().map_or_else(
341            || "Connection Pool Status".to_string(),
342            |n| format!("Connection Pool: {}", n),
343        );
344
345        // Box drawing
346        let top_border = format!("┌{}┐", "─".repeat(width - 2));
347        let bottom_border = format!("└{}┘", "─".repeat(width - 2));
348
349        lines.push(top_border);
350        lines.push(format!("│ {:<width$}│", title, width = width - 3));
351        lines.push(format!("├{}┤", "─".repeat(width - 2)));
352
353        // Utilization bar
354        let bar_width = width - 20;
355        // Intentional truncation: utilization is 0-100%, bar_width is small
356        let filled = ((utilization / 100.0) * bar_width as f64) as usize;
357        let empty = bar_width.saturating_sub(filled);
358        let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
359
360        // Progress bar with percentage and status
361        lines.push(format!(
362            "│ [{}] {:.0}% {:<width$}│",
363            bar,
364            utilization,
365            health.as_str(),
366            width = width - bar_width - 12
367        ));
368
369        // Connection counts
370        lines.push(
371            format!(
372                "│ Active: {:>4}  │  Idle: {:>4}  │  Max: {:>4}   │",
373                self.active, self.idle, self.max
374            )
375            .chars()
376            .take(width - 1)
377            .collect::<String>()
378                + "│",
379        );
380
381        // Waiting requests (with color if any)
382        if self.pending > 0 {
383            lines.push(format!(
384                "│ ⚠ Waiting requests: {:<width$}│",
385                self.pending,
386                width = width - 24
387            ));
388        }
389
390        // Statistics
391        if self.acquires > 0 || self.timeouts > 0 {
392            let timeout_rate = if self.acquires > 0 {
393                (self.timeouts as f64 / self.acquires as f64) * 100.0
394            } else {
395                0.0
396            };
397            lines.push(format!(
398                "│ Acquires: {} | Timeouts: {} ({:.1}%){:>width$}│",
399                self.acquires,
400                self.timeouts,
401                timeout_rate,
402                "",
403                width = width.saturating_sub(40)
404            ));
405        }
406
407        // Uptime
408        if let Some(uptime) = self.uptime {
409            lines.push(format!(
410                "│ Uptime: {:<width$}│",
411                Self::format_uptime(uptime),
412                width = width - 12
413            ));
414        }
415
416        lines.push(bottom_border);
417
418        lines.join("\n")
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    /// Mock implementation of PoolStatsProvider for testing.
427    #[derive(Debug, Clone)]
428    struct MockPoolStats {
429        active: usize,
430        idle: usize,
431        max: usize,
432        min: usize,
433        pending: usize,
434        created: u64,
435        closed: u64,
436        acquires: u64,
437        timeouts: u64,
438    }
439
440    impl MockPoolStats {
441        fn healthy() -> Self {
442            Self {
443                active: 3,
444                idle: 2,
445                max: 20,
446                min: 2,
447                pending: 0,
448                created: 100,
449                closed: 95,
450                acquires: 1000,
451                timeouts: 5,
452            }
453        }
454
455        fn busy() -> Self {
456            Self {
457                active: 17,
458                idle: 1,
459                max: 20,
460                min: 2,
461                pending: 0,
462                created: 200,
463                closed: 182,
464                acquires: 5000,
465                timeouts: 10,
466            }
467        }
468
469        fn degraded() -> Self {
470            // Degraded: some requests waiting but not at full capacity
471            Self {
472                active: 15,
473                idle: 0,
474                max: 20,
475                min: 2,
476                pending: 3,
477                created: 300,
478                closed: 280,
479                acquires: 8000,
480                timeouts: 50,
481            }
482        }
483
484        fn exhausted() -> Self {
485            Self {
486                active: 20,
487                idle: 0,
488                max: 20,
489                min: 2,
490                pending: 25,
491                created: 500,
492                closed: 480,
493                acquires: 10000,
494                timeouts: 200,
495            }
496        }
497    }
498
499    impl PoolStatsProvider for MockPoolStats {
500        fn active_connections(&self) -> usize {
501            self.active
502        }
503        fn idle_connections(&self) -> usize {
504            self.idle
505        }
506        fn max_connections(&self) -> usize {
507            self.max
508        }
509        fn min_connections(&self) -> usize {
510            self.min
511        }
512        fn pending_requests(&self) -> usize {
513            self.pending
514        }
515        fn connections_created(&self) -> u64 {
516            self.created
517        }
518        fn connections_closed(&self) -> u64 {
519            self.closed
520        }
521        fn total_acquires(&self) -> u64 {
522            self.acquires
523        }
524        fn total_timeouts(&self) -> u64 {
525            self.timeouts
526        }
527    }
528
529    #[test]
530    fn test_pool_health_healthy() {
531        let stats = MockPoolStats::healthy();
532        let display = PoolStatusDisplay::from_stats(&stats);
533        assert_eq!(display.health(), PoolHealth::Healthy);
534    }
535
536    #[test]
537    fn test_pool_health_busy() {
538        let stats = MockPoolStats::busy();
539        let display = PoolStatusDisplay::from_stats(&stats);
540        assert_eq!(display.health(), PoolHealth::Busy);
541    }
542
543    #[test]
544    fn test_pool_health_degraded() {
545        let stats = MockPoolStats::degraded();
546        let display = PoolStatusDisplay::from_stats(&stats);
547        assert_eq!(display.health(), PoolHealth::Degraded);
548    }
549
550    #[test]
551    fn test_pool_health_exhausted() {
552        let stats = MockPoolStats::exhausted();
553        let display = PoolStatusDisplay::from_stats(&stats);
554        assert_eq!(display.health(), PoolHealth::Exhausted);
555    }
556
557    #[test]
558    fn test_pool_health_as_str() {
559        assert_eq!(PoolHealth::Healthy.as_str(), "HEALTHY");
560        assert_eq!(PoolHealth::Busy.as_str(), "BUSY");
561        assert_eq!(PoolHealth::Degraded.as_str(), "DEGRADED");
562        assert_eq!(PoolHealth::Exhausted.as_str(), "EXHAUSTED");
563    }
564
565    #[test]
566    fn test_pool_health_display() {
567        assert_eq!(format!("{}", PoolHealth::Healthy), "HEALTHY");
568        assert_eq!(format!("{}", PoolHealth::Exhausted), "EXHAUSTED");
569    }
570
571    #[test]
572    fn test_utilization_calculation() {
573        let display = PoolStatusDisplay::new(10, 5, 20, 2, 0);
574        assert!((display.utilization() - 50.0).abs() < 0.01);
575    }
576
577    #[test]
578    fn test_utilization_zero_max() {
579        let display = PoolStatusDisplay::new(0, 0, 0, 0, 0);
580        assert!(display.utilization().abs() < f64::EPSILON);
581    }
582
583    #[test]
584    fn test_total_connections() {
585        let display = PoolStatusDisplay::new(8, 4, 20, 2, 0);
586        assert_eq!(display.total(), 12);
587    }
588
589    #[test]
590    fn test_render_plain_healthy() {
591        let stats = MockPoolStats::healthy();
592        let display = PoolStatusDisplay::from_stats(&stats);
593        let output = display.render_plain();
594
595        assert!(output.contains("Pool:"));
596        assert!(output.contains("HEALTHY"));
597        assert!(output.contains("Active: 3"));
598        assert!(output.contains("Idle: 2"));
599    }
600
601    #[test]
602    fn test_render_plain_with_name() {
603        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).name("PostgreSQL Main");
604        let output = display.render_plain();
605
606        assert!(output.contains("PostgreSQL Main:"));
607    }
608
609    #[test]
610    fn test_render_plain_with_uptime() {
611        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).uptime(Duration::from_secs(3725)); // 1h 2m 5s
612        let output = display.render_plain();
613
614        assert!(output.contains("Uptime:"));
615        assert!(output.contains("1h 2m"));
616    }
617
618    #[test]
619    fn test_render_plain_with_waiting() {
620        let display = PoolStatusDisplay::new(20, 0, 20, 2, 5);
621        let output = display.render_plain();
622
623        assert!(output.contains("5 waiting"));
624        assert!(output.contains("DEGRADED") || output.contains("EXHAUSTED"));
625    }
626
627    #[test]
628    fn test_format_uptime_seconds() {
629        assert_eq!(
630            PoolStatusDisplay::format_uptime(Duration::from_secs(45)),
631            "45s"
632        );
633    }
634
635    #[test]
636    fn test_format_uptime_minutes() {
637        assert_eq!(
638            PoolStatusDisplay::format_uptime(Duration::from_secs(125)),
639            "2m 5s"
640        );
641        assert_eq!(
642            PoolStatusDisplay::format_uptime(Duration::from_secs(120)),
643            "2m"
644        );
645    }
646
647    #[test]
648    fn test_format_uptime_hours() {
649        assert_eq!(
650            PoolStatusDisplay::format_uptime(Duration::from_secs(3725)),
651            "1h 2m"
652        );
653        assert_eq!(
654            PoolStatusDisplay::format_uptime(Duration::from_secs(3600)),
655            "1h"
656        );
657    }
658
659    #[test]
660    fn test_format_uptime_days() {
661        assert_eq!(
662            PoolStatusDisplay::format_uptime(Duration::from_secs(90000)),
663            "1d 1h"
664        );
665        assert_eq!(
666            PoolStatusDisplay::format_uptime(Duration::from_secs(86400)),
667            "1d"
668        );
669    }
670
671    #[test]
672    fn test_new_with_explicit_values() {
673        let display = PoolStatusDisplay::new(10, 5, 30, 3, 2);
674
675        assert_eq!(display.active, 10);
676        assert_eq!(display.idle, 5);
677        assert_eq!(display.max, 30);
678        assert_eq!(display.min, 3);
679        assert_eq!(display.pending, 2);
680    }
681
682    #[test]
683    fn test_builder_pattern() {
684        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0)
685            .theme(Theme::light())
686            .width(80)
687            .name("TestPool")
688            .uptime(Duration::from_secs(60))
689            .with_acquisition_stats(100, 5)
690            .with_lifetime_stats(50, 45);
691
692        assert_eq!(display.width, Some(80));
693        assert_eq!(display.name, Some("TestPool".to_string()));
694        assert!(display.uptime.is_some());
695        assert_eq!(display.acquires, 100);
696        assert_eq!(display.timeouts, 5);
697        assert_eq!(display.created, 50);
698        assert_eq!(display.closed, 45);
699    }
700
701    #[test]
702    fn test_health_color_codes() {
703        assert!(PoolHealth::Healthy.color_code().contains("32")); // Green
704        assert!(PoolHealth::Busy.color_code().contains("33")); // Yellow
705        assert!(PoolHealth::Exhausted.color_code().contains("31")); // Red
706    }
707
708    #[test]
709    fn test_render_styled_contains_box_drawing() {
710        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).width(60);
711        let output = display.render_styled();
712
713        assert!(output.contains("┌"));
714        assert!(output.contains("┐"));
715        assert!(output.contains("└"));
716        assert!(output.contains("┘"));
717        assert!(output.contains("│"));
718    }
719
720    #[test]
721    fn test_render_styled_contains_progress_bar() {
722        let display = PoolStatusDisplay::new(10, 5, 20, 2, 0).width(60);
723        let output = display.render_styled();
724
725        // Should contain bar characters
726        assert!(output.contains("█") || output.contains("░"));
727        assert!(output.contains("50%")); // 10/20 = 50%
728    }
729
730    #[test]
731    fn test_from_stats_captures_all_values() {
732        let stats = MockPoolStats {
733            active: 7,
734            idle: 3,
735            max: 25,
736            min: 5,
737            pending: 1,
738            created: 150,
739            closed: 140,
740            acquires: 2000,
741            timeouts: 15,
742        };
743
744        let display = PoolStatusDisplay::from_stats(&stats);
745
746        assert_eq!(display.active, 7);
747        assert_eq!(display.idle, 3);
748        assert_eq!(display.max, 25);
749        assert_eq!(display.min, 5);
750        assert_eq!(display.pending, 1);
751        assert_eq!(display.created, 150);
752        assert_eq!(display.closed, 140);
753        assert_eq!(display.acquires, 2000);
754        assert_eq!(display.timeouts, 15);
755    }
756}