1use 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#[derive(Debug, Clone)]
19pub struct StatsRenderer {
20 theme: &'static FastMcpTheme,
21 context: DisplayContext,
22}
23
24impl StatsRenderer {
25 #[must_use]
27 pub fn new(context: DisplayContext) -> Self {
28 Self {
29 theme: crate::theme::theme(),
30 context,
31 }
32 }
33
34 #[must_use]
36 pub fn detect() -> Self {
37 Self::new(DisplayContext::detect())
38 }
39
40 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 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 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 #[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 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}