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).max(24);
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        let title_display = self.truncate_plain_to_width(&title, width.saturating_sub(3));
345
346        // Box drawing
347        let top_border = format!("┌{}┐", "─".repeat(width.saturating_sub(2)));
348        let bottom_border = format!("└{}┘", "─".repeat(width.saturating_sub(2)));
349
350        lines.push(top_border);
351        lines.push(format!(
352            "│ {:<inner_width$}│",
353            title_display,
354            inner_width = width.saturating_sub(3)
355        ));
356        lines.push(format!("├{}┤", "─".repeat(width.saturating_sub(2))));
357
358        // Utilization bar
359        let bar_width = width.saturating_sub(20);
360        // Intentional truncation: utilization is 0-100%, bar_width is small
361        let filled = ((utilization / 100.0) * bar_width as f64) as usize;
362        let empty = bar_width.saturating_sub(filled);
363        let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
364        let status_width = width.saturating_sub(bar_width + 12);
365
366        // Progress bar with percentage and status
367        lines.push(format!(
368            "│ [{}] {:.0}% {:<width$}│",
369            bar,
370            utilization,
371            health.as_str(),
372            width = status_width
373        ));
374
375        // Connection counts
376        lines.push(
377            format!(
378                "│ Active: {:>4}  │  Idle: {:>4}  │  Max: {:>4}   │",
379                self.active, self.idle, self.max
380            )
381            .chars()
382            .take(width.saturating_sub(1))
383            .collect::<String>()
384                + "│",
385        );
386
387        // Waiting requests (with color if any)
388        if self.pending > 0 {
389            lines.push(format!(
390                "│ ⚠ Waiting requests: {:<width$}│",
391                self.pending,
392                width = width.saturating_sub(24)
393            ));
394        }
395
396        // Statistics
397        if self.acquires > 0 || self.timeouts > 0 {
398            let timeout_rate = if self.acquires > 0 {
399                (self.timeouts as f64 / self.acquires as f64) * 100.0
400            } else {
401                0.0
402            };
403            lines.push(format!(
404                "│ Acquires: {} | Timeouts: {} ({:.1}%){:>width$}│",
405                self.acquires,
406                self.timeouts,
407                timeout_rate,
408                "",
409                width = width.saturating_sub(40)
410            ));
411        }
412
413        // Uptime
414        if let Some(uptime) = self.uptime {
415            lines.push(format!(
416                "│ Uptime: {:<width$}│",
417                Self::format_uptime(uptime),
418                width = width.saturating_sub(12)
419            ));
420        }
421
422        lines.push(bottom_border);
423
424        lines.join("\n")
425    }
426
427    fn truncate_plain_to_width(&self, s: &str, max_visible: usize) -> String {
428        if max_visible == 0 {
429            return String::new();
430        }
431
432        let char_count = s.chars().count();
433        if char_count <= max_visible {
434            return s.to_string();
435        }
436
437        if max_visible <= 3 {
438            return ".".repeat(max_visible);
439        }
440
441        let truncated: String = s.chars().take(max_visible - 3).collect();
442        format!("{truncated}...")
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    /// Mock implementation of PoolStatsProvider for testing.
451    #[derive(Debug, Clone)]
452    struct MockPoolStats {
453        active: usize,
454        idle: usize,
455        max: usize,
456        min: usize,
457        pending: usize,
458        created: u64,
459        closed: u64,
460        acquires: u64,
461        timeouts: u64,
462    }
463
464    impl MockPoolStats {
465        fn healthy() -> Self {
466            Self {
467                active: 3,
468                idle: 2,
469                max: 20,
470                min: 2,
471                pending: 0,
472                created: 100,
473                closed: 95,
474                acquires: 1000,
475                timeouts: 5,
476            }
477        }
478
479        fn busy() -> Self {
480            Self {
481                active: 17,
482                idle: 1,
483                max: 20,
484                min: 2,
485                pending: 0,
486                created: 200,
487                closed: 182,
488                acquires: 5000,
489                timeouts: 10,
490            }
491        }
492
493        fn degraded() -> Self {
494            // Degraded: some requests waiting but not at full capacity
495            Self {
496                active: 15,
497                idle: 0,
498                max: 20,
499                min: 2,
500                pending: 3,
501                created: 300,
502                closed: 280,
503                acquires: 8000,
504                timeouts: 50,
505            }
506        }
507
508        fn exhausted() -> Self {
509            Self {
510                active: 20,
511                idle: 0,
512                max: 20,
513                min: 2,
514                pending: 25,
515                created: 500,
516                closed: 480,
517                acquires: 10000,
518                timeouts: 200,
519            }
520        }
521    }
522
523    impl PoolStatsProvider for MockPoolStats {
524        fn active_connections(&self) -> usize {
525            self.active
526        }
527        fn idle_connections(&self) -> usize {
528            self.idle
529        }
530        fn max_connections(&self) -> usize {
531            self.max
532        }
533        fn min_connections(&self) -> usize {
534            self.min
535        }
536        fn pending_requests(&self) -> usize {
537            self.pending
538        }
539        fn connections_created(&self) -> u64 {
540            self.created
541        }
542        fn connections_closed(&self) -> u64 {
543            self.closed
544        }
545        fn total_acquires(&self) -> u64 {
546            self.acquires
547        }
548        fn total_timeouts(&self) -> u64 {
549            self.timeouts
550        }
551    }
552
553    #[test]
554    fn test_pool_health_healthy() {
555        let stats = MockPoolStats::healthy();
556        let display = PoolStatusDisplay::from_stats(&stats);
557        assert_eq!(display.health(), PoolHealth::Healthy);
558    }
559
560    #[test]
561    fn test_pool_health_busy() {
562        let stats = MockPoolStats::busy();
563        let display = PoolStatusDisplay::from_stats(&stats);
564        assert_eq!(display.health(), PoolHealth::Busy);
565    }
566
567    #[test]
568    fn test_pool_health_degraded() {
569        let stats = MockPoolStats::degraded();
570        let display = PoolStatusDisplay::from_stats(&stats);
571        assert_eq!(display.health(), PoolHealth::Degraded);
572    }
573
574    #[test]
575    fn test_pool_health_exhausted() {
576        let stats = MockPoolStats::exhausted();
577        let display = PoolStatusDisplay::from_stats(&stats);
578        assert_eq!(display.health(), PoolHealth::Exhausted);
579    }
580
581    #[test]
582    fn test_pool_health_as_str() {
583        assert_eq!(PoolHealth::Healthy.as_str(), "HEALTHY");
584        assert_eq!(PoolHealth::Busy.as_str(), "BUSY");
585        assert_eq!(PoolHealth::Degraded.as_str(), "DEGRADED");
586        assert_eq!(PoolHealth::Exhausted.as_str(), "EXHAUSTED");
587    }
588
589    #[test]
590    fn test_pool_health_display() {
591        assert_eq!(format!("{}", PoolHealth::Healthy), "HEALTHY");
592        assert_eq!(format!("{}", PoolHealth::Exhausted), "EXHAUSTED");
593    }
594
595    #[test]
596    fn test_utilization_calculation() {
597        let display = PoolStatusDisplay::new(10, 5, 20, 2, 0);
598        assert!((display.utilization() - 50.0).abs() < 0.01);
599    }
600
601    #[test]
602    fn test_utilization_zero_max() {
603        let display = PoolStatusDisplay::new(0, 0, 0, 0, 0);
604        assert!(display.utilization().abs() < f64::EPSILON);
605    }
606
607    #[test]
608    fn test_total_connections() {
609        let display = PoolStatusDisplay::new(8, 4, 20, 2, 0);
610        assert_eq!(display.total(), 12);
611    }
612
613    #[test]
614    fn test_render_plain_healthy() {
615        let stats = MockPoolStats::healthy();
616        let display = PoolStatusDisplay::from_stats(&stats);
617        let output = display.render_plain();
618
619        assert!(output.contains("Pool:"));
620        assert!(output.contains("HEALTHY"));
621        assert!(output.contains("Active: 3"));
622        assert!(output.contains("Idle: 2"));
623    }
624
625    #[test]
626    fn test_render_plain_with_name() {
627        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).name("PostgreSQL Main");
628        let output = display.render_plain();
629
630        assert!(output.contains("PostgreSQL Main:"));
631    }
632
633    #[test]
634    fn test_render_plain_with_uptime() {
635        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).uptime(Duration::from_secs(3725)); // 1h 2m 5s
636        let output = display.render_plain();
637
638        assert!(output.contains("Uptime:"));
639        assert!(output.contains("1h 2m"));
640    }
641
642    #[test]
643    fn test_render_plain_with_waiting() {
644        let display = PoolStatusDisplay::new(20, 0, 20, 2, 5);
645        let output = display.render_plain();
646
647        assert!(output.contains("5 waiting"));
648        assert!(output.contains("DEGRADED") || output.contains("EXHAUSTED"));
649    }
650
651    #[test]
652    fn test_format_uptime_seconds() {
653        assert_eq!(
654            PoolStatusDisplay::format_uptime(Duration::from_secs(45)),
655            "45s"
656        );
657    }
658
659    #[test]
660    fn test_format_uptime_minutes() {
661        assert_eq!(
662            PoolStatusDisplay::format_uptime(Duration::from_secs(125)),
663            "2m 5s"
664        );
665        assert_eq!(
666            PoolStatusDisplay::format_uptime(Duration::from_secs(120)),
667            "2m"
668        );
669    }
670
671    #[test]
672    fn test_format_uptime_hours() {
673        assert_eq!(
674            PoolStatusDisplay::format_uptime(Duration::from_secs(3725)),
675            "1h 2m"
676        );
677        assert_eq!(
678            PoolStatusDisplay::format_uptime(Duration::from_secs(3600)),
679            "1h"
680        );
681    }
682
683    #[test]
684    fn test_format_uptime_days() {
685        assert_eq!(
686            PoolStatusDisplay::format_uptime(Duration::from_secs(90000)),
687            "1d 1h"
688        );
689        assert_eq!(
690            PoolStatusDisplay::format_uptime(Duration::from_secs(86400)),
691            "1d"
692        );
693    }
694
695    #[test]
696    fn test_new_with_explicit_values() {
697        let display = PoolStatusDisplay::new(10, 5, 30, 3, 2);
698
699        assert_eq!(display.active, 10);
700        assert_eq!(display.idle, 5);
701        assert_eq!(display.max, 30);
702        assert_eq!(display.min, 3);
703        assert_eq!(display.pending, 2);
704    }
705
706    #[test]
707    fn test_builder_pattern() {
708        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0)
709            .theme(Theme::light())
710            .width(80)
711            .name("TestPool")
712            .uptime(Duration::from_secs(60))
713            .with_acquisition_stats(100, 5)
714            .with_lifetime_stats(50, 45);
715
716        assert_eq!(display.width, Some(80));
717        assert_eq!(display.name, Some("TestPool".to_string()));
718        assert!(display.uptime.is_some());
719        assert_eq!(display.acquires, 100);
720        assert_eq!(display.timeouts, 5);
721        assert_eq!(display.created, 50);
722        assert_eq!(display.closed, 45);
723    }
724
725    #[test]
726    fn test_health_color_codes() {
727        assert!(PoolHealth::Healthy.color_code().contains("32")); // Green
728        assert!(PoolHealth::Busy.color_code().contains("33")); // Yellow
729        assert!(PoolHealth::Exhausted.color_code().contains("31")); // Red
730    }
731
732    #[test]
733    fn test_render_styled_contains_box_drawing() {
734        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).width(60);
735        let output = display.render_styled();
736
737        assert!(output.contains("┌"));
738        assert!(output.contains("┐"));
739        assert!(output.contains("└"));
740        assert!(output.contains("┘"));
741        assert!(output.contains("│"));
742    }
743
744    #[test]
745    fn test_render_styled_contains_progress_bar() {
746        let display = PoolStatusDisplay::new(10, 5, 20, 2, 0).width(60);
747        let output = display.render_styled();
748
749        // Should contain bar characters
750        assert!(output.contains("█") || output.contains("░"));
751        assert!(output.contains("50%")); // 10/20 = 50%
752    }
753
754    #[test]
755    fn test_render_styled_tiny_width_does_not_panic() {
756        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).width(1);
757        let output = display.render_styled();
758
759        assert!(!output.is_empty());
760        assert!(output.contains("┌"));
761        assert!(output.contains("┘"));
762    }
763
764    #[test]
765    fn test_render_styled_narrow_width_name_is_truncated() {
766        let display = PoolStatusDisplay::new(5, 3, 20, 2, 0)
767            .name("ExtremelyLongPoolNameForNarrowLayout")
768            .width(24);
769        let output = display.render_styled();
770
771        assert!(output.contains("..."));
772    }
773
774    #[test]
775    fn test_from_stats_captures_all_values() {
776        let stats = MockPoolStats {
777            active: 7,
778            idle: 3,
779            max: 25,
780            min: 5,
781            pending: 1,
782            created: 150,
783            closed: 140,
784            acquires: 2000,
785            timeouts: 15,
786        };
787
788        let display = PoolStatusDisplay::from_stats(&stats);
789
790        assert_eq!(display.active, 7);
791        assert_eq!(display.idle, 3);
792        assert_eq!(display.max, 25);
793        assert_eq!(display.min, 5);
794        assert_eq!(display.pending, 1);
795        assert_eq!(display.created, 150);
796        assert_eq!(display.closed, 140);
797        assert_eq!(display.acquires, 2000);
798        assert_eq!(display.timeouts, 15);
799    }
800}