1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DashboardConfig {
21 pub enabled: bool,
23
24 pub title: String,
26
27 pub refresh_interval_secs: u64,
29
30 pub max_data_points: usize,
32
33 pub layout: DashboardLayout,
35
36 pub charts: Vec<ChartConfig>,
38
39 pub theme: DashboardTheme,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DashboardLayout {
46 pub columns: u32,
48
49 pub cell_height: u32,
51
52 pub spacing: u32,
54
55 pub sections: Vec<DashboardSection>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DashboardSection {
62 pub id: String,
64
65 pub title: String,
67
68 pub position: GridPosition,
70
71 pub chart_ids: Vec<String>,
73
74 pub visible: bool,
76}
77
78#[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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ChartConfig {
90 pub id: String,
92
93 pub title: String,
95
96 pub chart_type: ChartType,
98
99 pub data_sources: Vec<DataSource>,
101
102 pub styling: ChartStyling,
104
105 pub options: ChartOptions,
107}
108
109#[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#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DataSource {
128 pub id: String,
130
131 pub name: String,
133
134 pub metric_path: String,
136
137 pub aggregation: AggregationType,
139
140 pub color: String,
142
143 pub line_style: LineStyle,
145}
146
147#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ChartStyling {
176 pub background_color: String,
178
179 pub grid_color: String,
181
182 pub text_color: String,
184
185 pub axis_color: String,
187
188 pub font_family: String,
190
191 pub font_size: u32,
193
194 pub show_legend: bool,
196
197 pub show_grid: bool,
199
200 pub show_axes: bool,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ChartOptions {
207 pub y_min: Option<f64>,
209
210 pub y_max: Option<f64>,
212
213 pub y_label: Option<String>,
215
216 pub x_label: Option<String>,
218
219 pub time_range_secs: Option<u64>,
221
222 pub stacked: bool,
224
225 pub animated: bool,
227
228 pub zoomable: bool,
230
231 pub pannable: bool,
233
234 pub thresholds: Vec<Threshold>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Threshold {
241 pub value: f64,
242 pub color: String,
243 pub label: String,
244}
245
246#[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#[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#[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
275pub 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 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 pub async fn update_metrics(&self, metrics: MetricsSnapshot) {
294 {
296 let mut current = self.current_metrics.write().await;
297 *current = Some(metrics.clone());
298 }
299
300 let timestamp = Utc::now();
302 let mut historical = self.historical_data.write().await;
303
304 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 if series.len() > self.config.max_data_points {
320 series.remove(0);
321 }
322 }
323 }
324 }
325
326 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 pub fn get_config(&self) -> &DashboardConfig {
402 &self.config
403 }
404
405 pub async fn get_current_metrics(&self) -> Option<MetricsSnapshot> {
407 let current = self.current_metrics.read().await;
408 current.clone()
409 }
410
411 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 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 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 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 §ion.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 async fn generate_dashboard_js(&self) -> String {
568 let mut js = String::new();
569
570 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 js.push_str(include_str!("../assets/dashboard.js"));
585
586 js
587 }
588}
589
590#[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#[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), 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 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}