pulseengine_mcp_logging/
dashboard.rs

1//! Custom metrics dashboards for MCP servers
2//!
3//! This module provides:
4//! - Web-based dashboard interface
5//! - Real-time metrics visualization
6//! - Customizable dashboard layouts
7//! - Chart and graph generation
8//! - Historical data views
9
10use crate::metrics::MetricsSnapshot;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16use tracing::error;
17
18/// Dashboard configuration
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DashboardConfig {
21    /// Enable dashboard
22    pub enabled: bool,
23
24    /// Dashboard title
25    pub title: String,
26
27    /// Refresh interval in seconds
28    pub refresh_interval_secs: u64,
29
30    /// Maximum data points to keep in memory
31    pub max_data_points: usize,
32
33    /// Dashboard layout configuration
34    pub layout: DashboardLayout,
35
36    /// Custom chart configurations
37    pub charts: Vec<ChartConfig>,
38
39    /// Color theme
40    pub theme: DashboardTheme,
41}
42
43/// Dashboard layout configuration
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DashboardLayout {
46    /// Number of columns in the grid
47    pub columns: u32,
48
49    /// Grid cell height in pixels
50    pub cell_height: u32,
51
52    /// Spacing between cells in pixels
53    pub spacing: u32,
54
55    /// Dashboard sections
56    pub sections: Vec<DashboardSection>,
57}
58
59/// Dashboard section
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DashboardSection {
62    /// Section ID
63    pub id: String,
64
65    /// Section title
66    pub title: String,
67
68    /// Grid position and size
69    pub position: GridPosition,
70
71    /// Charts in this section
72    pub chart_ids: Vec<String>,
73
74    /// Section visibility
75    pub visible: bool,
76}
77
78/// Grid position and size
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct GridPosition {
81    pub x: u32,
82    pub y: u32,
83    pub width: u32,
84    pub height: u32,
85}
86
87/// Chart configuration
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ChartConfig {
90    /// Unique chart ID
91    pub id: String,
92
93    /// Chart title
94    pub title: String,
95
96    /// Chart type
97    pub chart_type: ChartType,
98
99    /// Data sources
100    pub data_sources: Vec<DataSource>,
101
102    /// Chart styling options
103    pub styling: ChartStyling,
104
105    /// Chart-specific options
106    pub options: ChartOptions,
107}
108
109/// Chart types
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ChartType {
113    LineChart,
114    AreaChart,
115    BarChart,
116    PieChart,
117    GaugeChart,
118    ScatterPlot,
119    Heatmap,
120    Table,
121    Counter,
122    Sparkline,
123}
124
125/// Data source configuration
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DataSource {
128    /// Data source ID
129    pub id: String,
130
131    /// Display name
132    pub name: String,
133
134    /// Metric path (e.g., "request_metrics.avg_response_time_ms")
135    pub metric_path: String,
136
137    /// Data aggregation method
138    pub aggregation: AggregationType,
139
140    /// Color for this data series
141    pub color: String,
142
143    /// Line style for line charts
144    pub line_style: LineStyle,
145}
146
147/// Data aggregation types
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum AggregationType {
151    Raw,
152    Average,
153    Sum,
154    Count,
155    Min,
156    Max,
157    Percentile95,
158    Percentile99,
159    Rate,
160    Delta,
161}
162
163/// Line styles for charts
164#[derive(Debug, Clone, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum LineStyle {
167    Solid,
168    Dashed,
169    Dotted,
170    DashDot,
171}
172
173/// Chart styling options
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ChartStyling {
176    /// Chart background color
177    pub background_color: String,
178
179    /// Grid color
180    pub grid_color: String,
181
182    /// Text color
183    pub text_color: String,
184
185    /// Axis color
186    pub axis_color: String,
187
188    /// Font family
189    pub font_family: String,
190
191    /// Font size
192    pub font_size: u32,
193
194    /// Show legend
195    pub show_legend: bool,
196
197    /// Show grid
198    pub show_grid: bool,
199
200    /// Show axes
201    pub show_axes: bool,
202}
203
204/// Chart-specific options
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ChartOptions {
207    /// Y-axis minimum value
208    pub y_min: Option<f64>,
209
210    /// Y-axis maximum value
211    pub y_max: Option<f64>,
212
213    /// Y-axis label
214    pub y_label: Option<String>,
215
216    /// X-axis label
217    pub x_label: Option<String>,
218
219    /// Time range for historical data (in seconds)
220    pub time_range_secs: Option<u64>,
221
222    /// Stack series (for area/bar charts)
223    pub stacked: bool,
224
225    /// Animation enabled
226    pub animated: bool,
227
228    /// Zoom enabled
229    pub zoomable: bool,
230
231    /// Pan enabled
232    pub pannable: bool,
233
234    /// Custom thresholds for gauge charts
235    pub thresholds: Vec<Threshold>,
236}
237
238/// Threshold configuration for gauge charts
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Threshold {
241    pub value: f64,
242    pub color: String,
243    pub label: String,
244}
245
246/// Dashboard color themes
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum DashboardTheme {
250    Light,
251    Dark,
252    HighContrast,
253    Custom(CustomTheme),
254}
255
256/// Custom theme configuration
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct CustomTheme {
259    pub primary_color: String,
260    pub secondary_color: String,
261    pub background_color: String,
262    pub surface_color: String,
263    pub text_color: String,
264    pub accent_color: String,
265}
266
267/// Time series data point
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct DataPoint {
270    pub timestamp: DateTime<Utc>,
271    pub value: f64,
272    pub labels: HashMap<String, String>,
273}
274
275/// Dashboard data manager
276pub struct DashboardManager {
277    config: DashboardConfig,
278    historical_data: Arc<RwLock<HashMap<String, Vec<DataPoint>>>>,
279    current_metrics: Arc<RwLock<Option<MetricsSnapshot>>>,
280}
281
282impl DashboardManager {
283    /// Create a new dashboard manager
284    pub fn new(config: DashboardConfig) -> Self {
285        Self {
286            config,
287            historical_data: Arc::new(RwLock::new(HashMap::new())),
288            current_metrics: Arc::new(RwLock::new(None)),
289        }
290    }
291
292    /// Update metrics data
293    pub async fn update_metrics(&self, metrics: MetricsSnapshot) {
294        // Store current metrics
295        {
296            let mut current = self.current_metrics.write().await;
297            *current = Some(metrics.clone());
298        }
299
300        // Add to historical data
301        let timestamp = Utc::now();
302        let mut historical = self.historical_data.write().await;
303
304        // Extract data points from metrics for each configured data source
305        for chart in &self.config.charts {
306            for data_source in &chart.data_sources {
307                let value = self.extract_metric_value(&metrics, &data_source.metric_path);
308                let data_point = DataPoint {
309                    timestamp,
310                    value,
311                    labels: HashMap::new(),
312                };
313
314                let key = format!("{}:{}", chart.id, data_source.id);
315                let series = historical.entry(key).or_insert_with(Vec::new);
316                series.push(data_point);
317
318                // Limit data points
319                if series.len() > self.config.max_data_points {
320                    series.remove(0);
321                }
322            }
323        }
324    }
325
326    /// Extract metric value from snapshot using path
327    fn extract_metric_value(&self, metrics: &MetricsSnapshot, path: &str) -> f64 {
328        let parts: Vec<&str> = path.split('.').collect();
329        match parts.as_slice() {
330            ["request_metrics", "total_requests"] => metrics.request_metrics.total_requests as f64,
331            ["request_metrics", "successful_requests"] => {
332                metrics.request_metrics.successful_requests as f64
333            }
334            ["request_metrics", "failed_requests"] => {
335                metrics.request_metrics.failed_requests as f64
336            }
337            ["request_metrics", "avg_response_time_ms"] => {
338                metrics.request_metrics.avg_response_time_ms
339            }
340            ["request_metrics", "p95_response_time_ms"] => {
341                metrics.request_metrics.p95_response_time_ms
342            }
343            ["request_metrics", "p99_response_time_ms"] => {
344                metrics.request_metrics.p99_response_time_ms
345            }
346            ["request_metrics", "active_requests"] => {
347                metrics.request_metrics.active_requests as f64
348            }
349            ["request_metrics", "requests_per_second"] => {
350                metrics.request_metrics.requests_per_second
351            }
352
353            ["health_metrics", "cpu_usage_percent"] => {
354                metrics.health_metrics.cpu_usage_percent.unwrap_or(0.0)
355            }
356            ["health_metrics", "memory_usage_mb"] => {
357                metrics.health_metrics.memory_usage_mb.unwrap_or(0.0)
358            }
359            ["health_metrics", "memory_usage_percent"] => {
360                metrics.health_metrics.memory_usage_percent.unwrap_or(0.0)
361            }
362            ["health_metrics", "disk_usage_percent"] => {
363                metrics.health_metrics.disk_usage_percent.unwrap_or(0.0)
364            }
365            ["health_metrics", "uptime_seconds"] => metrics.health_metrics.uptime_seconds as f64,
366            ["health_metrics", "connection_pool_active"] => {
367                metrics.health_metrics.connection_pool_active.unwrap_or(0) as f64
368            }
369
370            ["error_metrics", "total_errors"] => metrics.error_metrics.total_errors as f64,
371            ["error_metrics", "error_rate_5min"] => metrics.error_metrics.error_rate_5min,
372            ["error_metrics", "error_rate_1hour"] => metrics.error_metrics.error_rate_1hour,
373            ["error_metrics", "error_rate_24hour"] => metrics.error_metrics.error_rate_24hour,
374            ["error_metrics", "client_errors"] => metrics.error_metrics.client_errors as f64,
375            ["error_metrics", "server_errors"] => metrics.error_metrics.server_errors as f64,
376            ["error_metrics", "network_errors"] => metrics.error_metrics.network_errors as f64,
377
378            ["business_metrics", "device_operations_total"] => {
379                metrics.business_metrics.device_operations_total as f64
380            }
381            ["business_metrics", "device_operations_success"] => {
382                metrics.business_metrics.device_operations_success as f64
383            }
384            ["business_metrics", "device_operations_failed"] => {
385                metrics.business_metrics.device_operations_failed as f64
386            }
387            ["business_metrics", "loxone_api_calls_total"] => {
388                metrics.business_metrics.loxone_api_calls_total as f64
389            }
390            ["business_metrics", "cache_hits"] => metrics.business_metrics.cache_hits as f64,
391            ["business_metrics", "cache_misses"] => metrics.business_metrics.cache_misses as f64,
392
393            _ => {
394                error!("Unknown metric path: {}", path);
395                0.0
396            }
397        }
398    }
399
400    /// Get dashboard configuration
401    pub fn get_config(&self) -> &DashboardConfig {
402        &self.config
403    }
404
405    /// Get current metrics
406    pub async fn get_current_metrics(&self) -> Option<MetricsSnapshot> {
407        let current = self.current_metrics.read().await;
408        current.clone()
409    }
410
411    /// Get historical data for a chart
412    pub async fn get_chart_data(&self, chart_id: &str, time_range_secs: Option<u64>) -> ChartData {
413        let historical = self.historical_data.read().await;
414        let mut series = Vec::new();
415
416        if let Some(chart) = self.config.charts.iter().find(|c| c.id == chart_id) {
417            for data_source in &chart.data_sources {
418                let key = format!("{}:{}", chart_id, data_source.id);
419                if let Some(data_points) = historical.get(&key) {
420                    let filtered_points = if let Some(range_secs) = time_range_secs {
421                        let cutoff = Utc::now() - chrono::Duration::seconds(range_secs as i64);
422                        data_points
423                            .iter()
424                            .filter(|dp| dp.timestamp > cutoff)
425                            .cloned()
426                            .collect()
427                    } else {
428                        data_points.clone()
429                    };
430
431                    series.push(ChartSeries {
432                        id: data_source.id.clone(),
433                        name: data_source.name.clone(),
434                        data: filtered_points,
435                        color: data_source.color.clone(),
436                        line_style: data_source.line_style.clone(),
437                    });
438                }
439            }
440        }
441
442        ChartData {
443            chart_id: chart_id.to_string(),
444            series,
445            last_updated: Utc::now(),
446        }
447    }
448
449    /// Generate dashboard HTML
450    pub async fn generate_html(&self) -> String {
451        let _current_metrics = self.get_current_metrics().await;
452        let theme_css = self.generate_theme_css();
453        let charts_html = self.generate_charts_html().await;
454
455        format!(
456            r#"<!DOCTYPE html>
457<html lang="en">
458<head>
459    <meta charset="UTF-8">
460    <meta name="viewport" content="width=device-width, initial-scale=1.0">
461    <title>{}</title>
462    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
463    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
464    <style>
465        {}
466    </style>
467</head>
468<body>
469    <div class="dashboard">
470        <header class="dashboard-header">
471            <h1>{}</h1>
472            <div class="dashboard-controls">
473                <button id="refresh-btn" onclick="refreshDashboard()">🔄 Refresh</button>
474                <span id="last-updated">Last updated: {}</span>
475            </div>
476        </header>
477
478        <div class="dashboard-grid">
479            {}
480        </div>
481    </div>
482
483    <script>
484        {}
485    </script>
486</body>
487</html>"#,
488            self.config.title,
489            theme_css,
490            self.config.title,
491            Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
492            charts_html,
493            self.generate_dashboard_js().await
494        )
495    }
496
497    /// Generate theme CSS
498    fn generate_theme_css(&self) -> String {
499        match &self.config.theme {
500            DashboardTheme::Light => include_str!("../assets/dashboard-light.css").to_string(),
501            DashboardTheme::Dark => include_str!("../assets/dashboard-dark.css").to_string(),
502            DashboardTheme::HighContrast => {
503                include_str!("../assets/dashboard-contrast.css").to_string()
504            }
505            DashboardTheme::Custom(theme) => format!(
506                r#"
507                :root {{
508                    --primary-color: {};
509                    --secondary-color: {};
510                    --background-color: {};
511                    --surface-color: {};
512                    --text-color: {};
513                    --accent-color: {};
514                }}
515                {}
516                "#,
517                theme.primary_color,
518                theme.secondary_color,
519                theme.background_color,
520                theme.surface_color,
521                theme.text_color,
522                theme.accent_color,
523                include_str!("../assets/dashboard-base.css")
524            ),
525        }
526    }
527
528    /// Generate charts HTML
529    async fn generate_charts_html(&self) -> String {
530        let mut html = String::new();
531
532        for section in &self.config.layout.sections {
533            if !section.visible {
534                continue;
535            }
536
537            html.push_str(&format!(
538                r#"<div class="dashboard-section" style="grid-column: {} / span {}; grid-row: {} / span {};">
539                    <h2>{}</h2>
540                    <div class="section-charts">"#,
541                section.position.x + 1,
542                section.position.width,
543                section.position.y + 1,
544                section.position.height,
545                section.title
546            ));
547
548            for chart_id in &section.chart_ids {
549                if let Some(chart) = self.config.charts.iter().find(|c| c.id == *chart_id) {
550                    html.push_str(&format!(
551                        r#"<div class="chart-container">
552                            <h3>{}</h3>
553                            <canvas id="chart-{}"></canvas>
554                        </div>"#,
555                        chart.title, chart.id
556                    ));
557                }
558            }
559
560            html.push_str("</div></div>");
561        }
562
563        html
564    }
565
566    /// Generate dashboard JavaScript
567    async fn generate_dashboard_js(&self) -> String {
568        let mut js = String::new();
569
570        // Add chart initialization code
571        for chart in &self.config.charts {
572            let chart_data = self
573                .get_chart_data(&chart.id, chart.options.time_range_secs)
574                .await;
575            js.push_str(&format!(
576                "initChart('{}', {}, {});",
577                chart.id,
578                serde_json::to_string(chart).unwrap_or_default(),
579                serde_json::to_string(&chart_data).unwrap_or_default()
580            ));
581        }
582
583        // Add base JavaScript functions
584        js.push_str(include_str!("../assets/dashboard.js"));
585
586        js
587    }
588}
589
590/// Chart data structure
591#[derive(Debug, Serialize, Deserialize)]
592pub struct ChartData {
593    pub chart_id: String,
594    pub series: Vec<ChartSeries>,
595    pub last_updated: DateTime<Utc>,
596}
597
598/// Chart data series
599#[derive(Debug, Serialize, Deserialize)]
600pub struct ChartSeries {
601    pub id: String,
602    pub name: String,
603    pub data: Vec<DataPoint>,
604    pub color: String,
605    pub line_style: LineStyle,
606}
607
608impl Default for DashboardConfig {
609    fn default() -> Self {
610        Self {
611            enabled: true,
612            title: "MCP Server Dashboard".to_string(),
613            refresh_interval_secs: 30,
614            max_data_points: 1000,
615            layout: DashboardLayout {
616                columns: 12,
617                cell_height: 200,
618                spacing: 16,
619                sections: vec![
620                    DashboardSection {
621                        id: "overview".to_string(),
622                        title: "Overview".to_string(),
623                        position: GridPosition {
624                            x: 0,
625                            y: 0,
626                            width: 12,
627                            height: 2,
628                        },
629                        chart_ids: vec![
630                            "requests_overview".to_string(),
631                            "response_time".to_string(),
632                        ],
633                        visible: true,
634                    },
635                    DashboardSection {
636                        id: "performance".to_string(),
637                        title: "Performance".to_string(),
638                        position: GridPosition {
639                            x: 0,
640                            y: 2,
641                            width: 6,
642                            height: 2,
643                        },
644                        chart_ids: vec!["cpu_usage".to_string(), "memory_usage".to_string()],
645                        visible: true,
646                    },
647                    DashboardSection {
648                        id: "errors".to_string(),
649                        title: "Errors".to_string(),
650                        position: GridPosition {
651                            x: 6,
652                            y: 2,
653                            width: 6,
654                            height: 2,
655                        },
656                        chart_ids: vec!["error_rate".to_string(), "error_breakdown".to_string()],
657                        visible: true,
658                    },
659                ],
660            },
661            charts: vec![
662                ChartConfig {
663                    id: "requests_overview".to_string(),
664                    title: "Request Overview".to_string(),
665                    chart_type: ChartType::LineChart,
666                    data_sources: vec![
667                        DataSource {
668                            id: "total_requests".to_string(),
669                            name: "Total Requests".to_string(),
670                            metric_path: "request_metrics.total_requests".to_string(),
671                            aggregation: AggregationType::Rate,
672                            color: "#007bff".to_string(),
673                            line_style: LineStyle::Solid,
674                        },
675                        DataSource {
676                            id: "successful_requests".to_string(),
677                            name: "Successful Requests".to_string(),
678                            metric_path: "request_metrics.successful_requests".to_string(),
679                            aggregation: AggregationType::Rate,
680                            color: "#28a745".to_string(),
681                            line_style: LineStyle::Solid,
682                        },
683                        DataSource {
684                            id: "failed_requests".to_string(),
685                            name: "Failed Requests".to_string(),
686                            metric_path: "request_metrics.failed_requests".to_string(),
687                            aggregation: AggregationType::Rate,
688                            color: "#dc3545".to_string(),
689                            line_style: LineStyle::Solid,
690                        },
691                    ],
692                    styling: ChartStyling::default(),
693                    options: ChartOptions {
694                        y_min: Some(0.0),
695                        y_max: None,
696                        y_label: Some("Requests/sec".to_string()),
697                        x_label: Some("Time".to_string()),
698                        time_range_secs: Some(3600), // 1 hour
699                        stacked: false,
700                        animated: true,
701                        zoomable: true,
702                        pannable: true,
703                        thresholds: vec![],
704                    },
705                },
706                ChartConfig {
707                    id: "response_time".to_string(),
708                    title: "Response Time".to_string(),
709                    chart_type: ChartType::LineChart,
710                    data_sources: vec![
711                        DataSource {
712                            id: "avg_response_time".to_string(),
713                            name: "Average".to_string(),
714                            metric_path: "request_metrics.avg_response_time_ms".to_string(),
715                            aggregation: AggregationType::Average,
716                            color: "#007bff".to_string(),
717                            line_style: LineStyle::Solid,
718                        },
719                        DataSource {
720                            id: "p95_response_time".to_string(),
721                            name: "95th Percentile".to_string(),
722                            metric_path: "request_metrics.p95_response_time_ms".to_string(),
723                            aggregation: AggregationType::Percentile95,
724                            color: "#ffc107".to_string(),
725                            line_style: LineStyle::Dashed,
726                        },
727                        DataSource {
728                            id: "p99_response_time".to_string(),
729                            name: "99th Percentile".to_string(),
730                            metric_path: "request_metrics.p99_response_time_ms".to_string(),
731                            aggregation: AggregationType::Percentile99,
732                            color: "#dc3545".to_string(),
733                            line_style: LineStyle::Dotted,
734                        },
735                    ],
736                    styling: ChartStyling::default(),
737                    options: ChartOptions {
738                        y_min: Some(0.0),
739                        y_max: None,
740                        y_label: Some("Response Time (ms)".to_string()),
741                        x_label: Some("Time".to_string()),
742                        time_range_secs: Some(3600),
743                        stacked: false,
744                        animated: true,
745                        zoomable: true,
746                        pannable: true,
747                        thresholds: vec![
748                            Threshold {
749                                value: 1000.0,
750                                color: "#ffc107".to_string(),
751                                label: "Warning".to_string(),
752                            },
753                            Threshold {
754                                value: 5000.0,
755                                color: "#dc3545".to_string(),
756                                label: "Critical".to_string(),
757                            },
758                        ],
759                    },
760                },
761            ],
762            theme: DashboardTheme::Light,
763        }
764    }
765}
766
767impl Default for ChartStyling {
768    fn default() -> Self {
769        Self {
770            background_color: "transparent".to_string(),
771            grid_color: "#e9ecef".to_string(),
772            text_color: "#495057".to_string(),
773            axis_color: "#6c757d".to_string(),
774            font_family: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
775                .to_string(),
776            font_size: 12,
777            show_legend: true,
778            show_grid: true,
779            show_axes: true,
780        }
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::{BusinessMetrics, ErrorMetrics, HealthMetrics, RequestMetrics};
788
789    #[test]
790    fn test_dashboard_config_creation() {
791        let config = DashboardConfig::default();
792        assert!(config.enabled);
793        assert_eq!(config.title, "MCP Server Dashboard");
794        assert_eq!(config.refresh_interval_secs, 30);
795        assert!(!config.charts.is_empty());
796    }
797
798    #[tokio::test]
799    async fn test_dashboard_manager() {
800        let config = DashboardConfig::default();
801        let manager = DashboardManager::new(config);
802
803        // Test metrics update
804        let metrics = MetricsSnapshot {
805            request_metrics: RequestMetrics::default(),
806            health_metrics: HealthMetrics::default(),
807            business_metrics: BusinessMetrics::default(),
808            error_metrics: ErrorMetrics::default(),
809            snapshot_timestamp: 1234567890,
810        };
811
812        manager.update_metrics(metrics.clone()).await;
813
814        let current = manager.get_current_metrics().await;
815        assert!(current.is_some());
816        assert_eq!(
817            current.unwrap().snapshot_timestamp,
818            metrics.snapshot_timestamp
819        );
820    }
821
822    #[test]
823    fn test_metric_path_extraction() {
824        let config = DashboardConfig::default();
825        let manager = DashboardManager::new(config);
826
827        let metrics = MetricsSnapshot {
828            request_metrics: RequestMetrics {
829                total_requests: 100,
830                avg_response_time_ms: 250.5,
831                ..Default::default()
832            },
833            health_metrics: HealthMetrics::default(),
834            business_metrics: BusinessMetrics::default(),
835            error_metrics: ErrorMetrics::default(),
836            snapshot_timestamp: 1234567890,
837        };
838
839        assert_eq!(
840            manager.extract_metric_value(&metrics, "request_metrics.total_requests"),
841            100.0
842        );
843        assert_eq!(
844            manager.extract_metric_value(&metrics, "request_metrics.avg_response_time_ms"),
845            250.5
846        );
847        assert_eq!(manager.extract_metric_value(&metrics, "invalid.path"), 0.0);
848    }
849}