Skip to main content

fastmcp_console/stats/
renderer.rs

1//! Stats rendering utilities.
2//!
3//! Provides rich and plain renderers for server statistics snapshots.
4
5use std::time::Duration;
6
7use rich_rust::r#box::ROUNDED;
8use rich_rust::markup;
9use rich_rust::prelude::*;
10
11use crate::console::FastMcpConsole;
12use crate::detection::DisplayContext;
13use crate::theme::FastMcpTheme;
14
15use super::StatsSnapshot;
16
17/// Renders server statistics in rich and plain formats.
18#[derive(Debug, Clone)]
19pub struct StatsRenderer {
20    theme: &'static FastMcpTheme,
21    context: DisplayContext,
22}
23
24impl StatsRenderer {
25    /// Create a renderer with an explicit display context.
26    #[must_use]
27    pub fn new(context: DisplayContext) -> Self {
28        Self {
29            theme: crate::theme::theme(),
30            context,
31        }
32    }
33
34    /// Create a renderer using auto-detected display context.
35    #[must_use]
36    pub fn detect() -> Self {
37        Self::new(DisplayContext::detect())
38    }
39
40    /// Render a full statistics panel.
41    pub fn render_panel(&self, stats: &StatsSnapshot, console: &FastMcpConsole) {
42        if !self.should_use_rich(console) {
43            self.render_plain(stats, console);
44            return;
45        }
46
47        let content = self.build_panel_text(stats);
48        let text = markup::render_or_plain(&content);
49        let panel = Panel::from_rich_text(&text, console.width())
50            .title("Server Statistics")
51            .border_style(self.theme.border_style.clone())
52            .rounded();
53
54        console.render(&panel);
55    }
56
57    /// Render as a compact table.
58    pub fn render_table(&self, stats: &StatsSnapshot, console: &FastMcpConsole) {
59        if !self.should_use_rich(console) {
60            self.render_plain(stats, console);
61            return;
62        }
63
64        let mut table = Table::new()
65            .title("Runtime Metrics")
66            .title_style(self.theme.header_style.clone())
67            .box_style(&ROUNDED)
68            .border_style(self.theme.border_style.clone())
69            .show_header(true)
70            .with_column(Column::new("Metric").style(self.theme.label_style.clone()))
71            .with_column(
72                Column::new("Value")
73                    .justify(JustifyMethod::Right)
74                    .style(self.theme.value_style.clone()),
75            );
76
77        table.add_row(Row::new(vec![
78            Cell::new("Uptime"),
79            Cell::new(self.format_duration(stats.uptime)),
80        ]));
81        table.add_row(Row::new(vec![
82            Cell::new("Total Requests"),
83            Cell::new(stats.total_requests.to_string()),
84        ]));
85        table.add_row(Row::new(vec![
86            Cell::new("Success Rate"),
87            Cell::new(self.format_percentage(stats.successful_requests, stats.total_requests)),
88        ]));
89        table.add_row(Row::new(vec![
90            Cell::new("Failed Requests"),
91            Cell::new(stats.failed_requests.to_string()),
92        ]));
93        table.add_row(Row::new(vec![
94            Cell::new("Cancelled Requests"),
95            Cell::new(stats.cancelled_requests.to_string()),
96        ]));
97        table.add_row(Row::new(vec![
98            Cell::new("Tool Calls"),
99            Cell::new(stats.tool_calls.to_string()),
100        ]));
101        table.add_row(Row::new(vec![
102            Cell::new("Resource Reads"),
103            Cell::new(stats.resource_reads.to_string()),
104        ]));
105        table.add_row(Row::new(vec![
106            Cell::new("Prompt Gets"),
107            Cell::new(stats.prompt_gets.to_string()),
108        ]));
109        table.add_row(Row::new(vec![
110            Cell::new("List Operations"),
111            Cell::new(stats.list_operations.to_string()),
112        ]));
113        table.add_row(Row::new(vec![
114            Cell::new("Avg Latency"),
115            Cell::new(self.format_latency(stats.avg_latency)),
116        ]));
117        table.add_row(Row::new(vec![
118            Cell::new("Max Latency"),
119            Cell::new(self.format_latency(stats.max_latency)),
120        ]));
121        table.add_row(Row::new(vec![
122            Cell::new("Min Latency"),
123            Cell::new(self.format_latency(stats.min_latency)),
124        ]));
125        table.add_row(Row::new(vec![
126            Cell::new("Active Connections"),
127            Cell::new(stats.active_connections.to_string()),
128        ]));
129        table.add_row(Row::new(vec![
130            Cell::new("Total Connections"),
131            Cell::new(stats.total_connections.to_string()),
132        ]));
133        table.add_row(Row::new(vec![
134            Cell::new("Data Received"),
135            Cell::new(self.format_bytes(stats.bytes_received)),
136        ]));
137        table.add_row(Row::new(vec![
138            Cell::new("Data Sent"),
139            Cell::new(self.format_bytes(stats.bytes_sent)),
140        ]));
141
142        console.render(&table);
143    }
144
145    /// Render a compact one-line summary.
146    pub fn render_oneline(&self, stats: &StatsSnapshot, console: &FastMcpConsole) {
147        let line = if self.should_use_rich(console) {
148            let dim = color_hex(&self.theme.text_dim);
149            let info = color_hex(&self.theme.info);
150            let ok = color_hex(&self.theme.success);
151            let warn = color_hex(&self.theme.warning);
152            format!(
153                "[{dim}]⏱[/] {} [{dim}]|[/] [{info}]{}[/] reqs [{dim}]|[/] [{ok}]{:.1}%[/] ok [{dim}]|[/] [{warn}]{}[/] avg",
154                self.format_duration(stats.uptime),
155                stats.total_requests,
156                self.success_rate(stats) * 100.0,
157                self.format_latency(stats.avg_latency)
158            )
159        } else {
160            format!(
161                "Uptime: {} | Requests: {} | Success: {:.1}% | Avg latency: {}",
162                self.format_duration(stats.uptime),
163                stats.total_requests,
164                self.success_rate(stats) * 100.0,
165                self.format_latency(stats.avg_latency)
166            )
167        };
168
169        console.print(&line);
170    }
171
172    fn should_use_rich(&self, console: &FastMcpConsole) -> bool {
173        self.context.is_human() && console.is_rich()
174    }
175
176    fn build_panel_text(&self, stats: &StatsSnapshot) -> String {
177        let label = color_hex(&self.theme.text_dim);
178        let value = color_hex(&self.theme.text);
179
180        let rows = [
181            ("Uptime", self.format_duration(stats.uptime)),
182            ("Total Requests", stats.total_requests.to_string()),
183            (
184                "Success Rate",
185                self.format_percentage(stats.successful_requests, stats.total_requests),
186            ),
187            ("Failed Requests", stats.failed_requests.to_string()),
188            ("Cancelled Requests", stats.cancelled_requests.to_string()),
189            ("Tool Calls", stats.tool_calls.to_string()),
190            ("Resource Reads", stats.resource_reads.to_string()),
191            ("Prompt Gets", stats.prompt_gets.to_string()),
192            ("List Operations", stats.list_operations.to_string()),
193            ("Avg Latency", self.format_latency(stats.avg_latency)),
194            ("Max Latency", self.format_latency(stats.max_latency)),
195            ("Min Latency", self.format_latency(stats.min_latency)),
196            ("Active Connections", stats.active_connections.to_string()),
197            ("Total Connections", stats.total_connections.to_string()),
198            ("Data Received", self.format_bytes(stats.bytes_received)),
199            ("Data Sent", self.format_bytes(stats.bytes_sent)),
200        ];
201
202        let mut content = String::new();
203        for (idx, (label_text, value_text)) in rows.iter().enumerate() {
204            content.push_str(&format!(
205                "[{label}]{:<18}[/] [{value}]{}[/]",
206                label_text, value_text
207            ));
208            if idx + 1 < rows.len() {
209                content.push('\n');
210            }
211        }
212        content
213    }
214
215    fn render_plain(&self, stats: &StatsSnapshot, console: &FastMcpConsole) {
216        console.print("=== Server Statistics ===");
217        console.print(&format!("Uptime: {}", self.format_duration(stats.uptime)));
218        console.print(&format!(
219            "Requests: {} total, {} successful, {} failed, {} cancelled",
220            stats.total_requests,
221            stats.successful_requests,
222            stats.failed_requests,
223            stats.cancelled_requests
224        ));
225        console.print(&format!(
226            "Ops: {} tool calls, {} resource reads, {} prompt gets, {} lists",
227            stats.tool_calls, stats.resource_reads, stats.prompt_gets, stats.list_operations
228        ));
229        console.print(&format!(
230            "Latency: {} avg, {} max, {} min",
231            self.format_latency(stats.avg_latency),
232            self.format_latency(stats.max_latency),
233            self.format_latency(stats.min_latency)
234        ));
235        console.print(&format!(
236            "Connections: {} active, {} total",
237            stats.active_connections, stats.total_connections
238        ));
239        console.print(&format!(
240            "Data: {} received, {} sent",
241            self.format_bytes(stats.bytes_received),
242            self.format_bytes(stats.bytes_sent)
243        ));
244    }
245
246    fn format_duration(&self, d: Duration) -> String {
247        let secs = d.as_secs();
248        if secs < 60 {
249            format!("{}s", secs)
250        } else if secs < 3600 {
251            format!("{}m {}s", secs / 60, secs % 60)
252        } else {
253            format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
254        }
255    }
256
257    fn format_latency(&self, d: Duration) -> String {
258        let micros = d.as_micros();
259        if micros < 1000 {
260            format!("{}us", micros)
261        } else if micros < 1_000_000 {
262            format!("{:.1}ms", micros as f64 / 1000.0)
263        } else {
264            format!("{:.2}s", d.as_secs_f64())
265        }
266    }
267
268    fn format_bytes(&self, bytes: u64) -> String {
269        const KB: u64 = 1024;
270        const MB: u64 = KB * 1024;
271        const GB: u64 = MB * 1024;
272
273        if bytes < KB {
274            format!("{bytes} B")
275        } else if bytes < MB {
276            format!("{:.1} KB", bytes as f64 / KB as f64)
277        } else if bytes < GB {
278            format!("{:.1} MB", bytes as f64 / MB as f64)
279        } else {
280            format!("{:.2} GB", bytes as f64 / GB as f64)
281        }
282    }
283
284    fn format_percentage(&self, part: u64, total: u64) -> String {
285        if total == 0 {
286            "N/A".to_string()
287        } else {
288            format!("{:.1}%", (part as f64 / total as f64) * 100.0)
289        }
290    }
291
292    fn success_rate(&self, stats: &StatsSnapshot) -> f64 {
293        if stats.total_requests == 0 {
294            1.0
295        } else {
296            stats.successful_requests as f64 / stats.total_requests as f64
297        }
298    }
299}
300
301impl Default for StatsRenderer {
302    fn default() -> Self {
303        Self::detect()
304    }
305}
306
307fn color_hex(color: &Color) -> String {
308    color.get_truecolor().hex()
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::testing::TestConsole;
315    use crate::theme::theme;
316
317    fn sample_snapshot() -> StatsSnapshot {
318        StatsSnapshot {
319            uptime: Duration::from_secs(3661),
320            total_requests: 100,
321            successful_requests: 90,
322            failed_requests: 8,
323            cancelled_requests: 2,
324            tool_calls: 70,
325            resource_reads: 20,
326            prompt_gets: 10,
327            list_operations: 5,
328            avg_latency: Duration::from_millis(12),
329            max_latency: Duration::from_millis(50),
330            min_latency: Duration::from_millis(2),
331            active_connections: 3,
332            total_connections: 5,
333            bytes_received: 1024,
334            bytes_sent: 2048,
335        }
336    }
337
338    #[test]
339    fn test_format_duration() {
340        let renderer = StatsRenderer::new(DisplayContext::new_agent());
341        assert_eq!(renderer.format_duration(Duration::from_secs(45)), "45s");
342        assert_eq!(renderer.format_duration(Duration::from_secs(90)), "1m 30s");
343        assert_eq!(renderer.format_duration(Duration::from_mins(185)), "3h 5m");
344        assert_eq!(renderer.format_duration(Duration::from_secs(3600)), "1h 0m");
345    }
346
347    #[test]
348    fn test_format_latency() {
349        let renderer = StatsRenderer::new(DisplayContext::new_agent());
350        assert_eq!(renderer.format_latency(Duration::from_micros(500)), "500us");
351        assert_eq!(renderer.format_latency(Duration::from_millis(1)), "1.0ms");
352        assert_eq!(
353            renderer.format_latency(Duration::from_micros(1500)),
354            "1.5ms"
355        );
356        assert_eq!(renderer.format_latency(Duration::from_secs(1)), "1.00s");
357        assert_eq!(renderer.format_latency(Duration::from_secs(2)), "2.00s");
358    }
359
360    #[test]
361    fn test_format_bytes() {
362        let renderer = StatsRenderer::new(DisplayContext::new_agent());
363        assert_eq!(renderer.format_bytes(500), "500 B");
364        assert_eq!(renderer.format_bytes(2048), "2.0 KB");
365        assert_eq!(renderer.format_bytes(3 * 1024 * 1024), "3.0 MB");
366        assert_eq!(renderer.format_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
367    }
368
369    #[test]
370    fn test_render_table_rich() {
371        let stats = sample_snapshot();
372        let console = TestConsole::new_rich();
373        let renderer = StatsRenderer::new(DisplayContext::new_human());
374        renderer.render_table(&stats, console.console());
375        console.assert_contains("Total Requests");
376        console.assert_contains("Success Rate");
377    }
378
379    #[test]
380    fn test_render_oneline_plain() {
381        let stats = sample_snapshot();
382        let console = TestConsole::new();
383        let renderer = StatsRenderer::new(DisplayContext::new_agent());
384        renderer.render_oneline(&stats, console.console());
385        console.assert_contains("Uptime");
386        console.assert_contains("Requests");
387    }
388
389    #[test]
390    fn test_render_panel_rich_and_plain_fallback() {
391        let stats = sample_snapshot();
392
393        let rich_console = TestConsole::new_rich();
394        let rich_renderer = StatsRenderer::new(DisplayContext::new_human());
395        rich_renderer.render_panel(&stats, rich_console.console());
396        rich_console.assert_contains("Server Statistics");
397        rich_console.assert_contains("Total Requests");
398        rich_console.assert_contains("Data Sent");
399
400        let plain_console = TestConsole::new();
401        let plain_renderer = StatsRenderer::new(DisplayContext::new_agent());
402        plain_renderer.render_panel(&stats, plain_console.console());
403        plain_console.assert_contains("=== Server Statistics ===");
404        plain_console.assert_contains("Requests:");
405        plain_console.assert_contains("Connections:");
406    }
407
408    #[test]
409    fn test_render_table_plain_fallback() {
410        let stats = sample_snapshot();
411        let console = TestConsole::new();
412        let renderer = StatsRenderer::new(DisplayContext::new_agent());
413        renderer.render_table(&stats, console.console());
414        console.assert_contains("=== Server Statistics ===");
415        console.assert_contains("Ops:");
416        console.assert_contains("Data:");
417    }
418
419    #[test]
420    fn test_render_oneline_rich() {
421        let stats = sample_snapshot();
422        let console = TestConsole::new_rich();
423        let renderer = StatsRenderer::new(DisplayContext::new_human());
424        renderer.render_oneline(&stats, console.console());
425        console.assert_contains("reqs");
426        console.assert_contains("avg");
427        console.assert_contains("90.0%");
428    }
429
430    #[test]
431    fn test_percentage_and_success_rate_edge_cases() {
432        let renderer = StatsRenderer::new(DisplayContext::new_agent());
433        assert_eq!(renderer.format_percentage(0, 0), "N/A");
434        assert_eq!(renderer.format_percentage(1, 3), "33.3%");
435
436        let mut zero = sample_snapshot();
437        zero.total_requests = 0;
438        zero.successful_requests = 0;
439        assert!((renderer.success_rate(&zero) - 1.0).abs() < f64::EPSILON);
440
441        let normal = sample_snapshot();
442        assert!((renderer.success_rate(&normal) - 0.9).abs() < f64::EPSILON);
443    }
444
445    #[test]
446    fn test_build_panel_text_contains_expected_rows() {
447        let renderer = StatsRenderer::new(DisplayContext::new_human());
448        let content = renderer.build_panel_text(&sample_snapshot());
449        assert!(content.contains("Uptime"));
450        assert!(content.contains("Total Requests"));
451        assert!(content.contains("Data Sent"));
452    }
453
454    #[test]
455    fn test_should_use_rich_respects_context_and_console() {
456        let human = StatsRenderer::new(DisplayContext::new_human());
457        let agent = StatsRenderer::new(DisplayContext::new_agent());
458        let rich_console = FastMcpConsole::with_enabled(true);
459        let plain_console = FastMcpConsole::with_enabled(false);
460
461        assert!(human.should_use_rich(&rich_console));
462        assert!(!human.should_use_rich(&plain_console));
463        assert!(!agent.should_use_rich(&rich_console));
464    }
465
466    #[test]
467    fn test_default_detect_and_color_hex() {
468        let _default = StatsRenderer::default();
469        let _detect = StatsRenderer::detect();
470        let hex = color_hex(&theme().info);
471        assert_eq!(hex.len(), 7);
472        assert!(hex.starts_with('#'));
473    }
474
475    // =========================================================================
476    // Additional coverage tests (bd-2xpx)
477    // =========================================================================
478
479    #[test]
480    fn stats_renderer_debug_and_clone() {
481        let renderer = StatsRenderer::new(DisplayContext::new_agent());
482        let debug = format!("{renderer:?}");
483        assert!(debug.contains("StatsRenderer"));
484
485        let cloned = renderer.clone();
486        assert!(!cloned.should_use_rich(&FastMcpConsole::with_enabled(true)));
487    }
488
489    #[test]
490    fn format_duration_boundary_exactly_60s() {
491        let renderer = StatsRenderer::new(DisplayContext::new_agent());
492        assert_eq!(renderer.format_duration(Duration::from_secs(0)), "0s");
493        assert_eq!(renderer.format_duration(Duration::from_secs(59)), "59s");
494        assert_eq!(renderer.format_duration(Duration::from_secs(60)), "1m 0s");
495        assert_eq!(
496            renderer.format_duration(Duration::from_secs(3599)),
497            "59m 59s"
498        );
499    }
500
501    #[test]
502    fn format_latency_boundary_values() {
503        let renderer = StatsRenderer::new(DisplayContext::new_agent());
504        assert_eq!(renderer.format_latency(Duration::from_micros(0)), "0us");
505        assert_eq!(renderer.format_latency(Duration::from_micros(999)), "999us");
506        assert_eq!(renderer.format_latency(Duration::from_millis(1)), "1.0ms");
507        assert_eq!(
508            renderer.format_latency(Duration::from_micros(999_999)),
509            "1000.0ms"
510        );
511    }
512
513    #[test]
514    fn format_bytes_boundary_values() {
515        let renderer = StatsRenderer::new(DisplayContext::new_agent());
516        assert_eq!(renderer.format_bytes(0), "0 B");
517        assert_eq!(renderer.format_bytes(1023), "1023 B");
518        assert_eq!(renderer.format_bytes(1024), "1.0 KB");
519        assert_eq!(renderer.format_bytes(1024 * 1024 - 1), "1024.0 KB");
520        assert_eq!(renderer.format_bytes(1024 * 1024), "1.0 MB");
521        assert_eq!(renderer.format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB");
522    }
523
524    #[test]
525    fn format_percentage_full_coverage() {
526        let renderer = StatsRenderer::new(DisplayContext::new_agent());
527        assert_eq!(renderer.format_percentage(100, 100), "100.0%");
528        assert_eq!(renderer.format_percentage(50, 200), "25.0%");
529    }
530
531    #[test]
532    fn render_oneline_with_zero_requests() {
533        let tc = TestConsole::new();
534        let renderer = StatsRenderer::new(DisplayContext::new_agent());
535        let mut stats = sample_snapshot();
536        stats.total_requests = 0;
537        stats.successful_requests = 0;
538        renderer.render_oneline(&stats, tc.console());
539        // success_rate returns 1.0 when total_requests is 0
540        tc.assert_contains("Uptime");
541        tc.assert_contains("Requests");
542        tc.assert_contains("100.0");
543    }
544
545    #[test]
546    fn build_panel_text_has_16_rows() {
547        let renderer = StatsRenderer::new(DisplayContext::new_human());
548        let content = renderer.build_panel_text(&sample_snapshot());
549        let line_count = content.lines().count();
550        assert_eq!(line_count, 16);
551    }
552}