mockforge_reporting/
dashboard_layouts.rs

1//! Custom dashboard layouts for observability and chaos engineering
2//!
3//! Allows users to create, save, and share custom dashboard configurations
4//! with different widget arrangements and data sources.
5
6use crate::{ReportingError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11
12/// Dashboard layout configuration
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DashboardLayout {
15    pub id: String,
16    pub name: String,
17    pub description: Option<String>,
18    pub created_at: chrono::DateTime<chrono::Utc>,
19    pub updated_at: chrono::DateTime<chrono::Utc>,
20    pub author: String,
21    pub tags: Vec<String>,
22    pub is_public: bool,
23    pub grid_config: GridConfig,
24    pub widgets: Vec<Widget>,
25    pub filters: Vec<DashboardFilter>,
26}
27
28/// Grid configuration
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GridConfig {
31    pub columns: u32,
32    pub row_height: u32,
33    pub gap: u32,
34    pub responsive_breakpoints: HashMap<String, u32>,
35}
36
37impl Default for GridConfig {
38    fn default() -> Self {
39        let mut breakpoints = HashMap::new();
40        breakpoints.insert("mobile".to_string(), 1);
41        breakpoints.insert("tablet".to_string(), 2);
42        breakpoints.insert("desktop".to_string(), 4);
43        breakpoints.insert("wide".to_string(), 6);
44
45        Self {
46            columns: 12,
47            row_height: 60,
48            gap: 16,
49            responsive_breakpoints: breakpoints,
50        }
51    }
52}
53
54/// Widget configuration
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Widget {
57    pub id: String,
58    pub widget_type: WidgetType,
59    pub title: String,
60    pub position: WidgetPosition,
61    pub data_source: DataSource,
62    pub refresh_interval_seconds: Option<u32>,
63    pub config: serde_json::Value,
64}
65
66/// Widget position in grid
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct WidgetPosition {
69    pub x: u32,
70    pub y: u32,
71    pub width: u32,
72    pub height: u32,
73}
74
75/// Widget type
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77#[serde(rename_all = "snake_case")]
78pub enum WidgetType {
79    LineChart,
80    BarChart,
81    PieChart,
82    Gauge,
83    Counter,
84    Table,
85    Heatmap,
86    Flamegraph,
87    Timeline,
88    AlertList,
89    MetricComparison,
90    ScenarioStatus,
91    ServiceMap,
92    LogStream,
93    Custom,
94}
95
96/// Data source configuration
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct DataSource {
99    pub source_type: DataSourceType,
100    pub query: String,
101    pub aggregation: Option<AggregationType>,
102    pub time_range: TimeRange,
103}
104
105/// Data source type
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107#[serde(rename_all = "snake_case")]
108pub enum DataSourceType {
109    Prometheus,
110    OpenTelemetry,
111    ChaosMetrics,
112    ScenarioExecutions,
113    AlertHistory,
114    CustomMetric,
115}
116
117/// Aggregation type
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119#[serde(rename_all = "snake_case")]
120pub enum AggregationType {
121    Sum,
122    Average,
123    Min,
124    Max,
125    Count,
126    P50,
127    P95,
128    P99,
129}
130
131/// Time range for data queries
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct TimeRange {
134    pub range_type: TimeRangeType,
135    pub value: Option<u64>,
136}
137
138/// Time range type
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140#[serde(rename_all = "snake_case")]
141pub enum TimeRangeType {
142    Last15Minutes,
143    Last1Hour,
144    Last6Hours,
145    Last24Hours,
146    Last7Days,
147    Last30Days,
148    Custom,
149}
150
151/// Dashboard filter
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct DashboardFilter {
154    pub id: String,
155    pub name: String,
156    pub filter_type: FilterType,
157    pub options: Vec<String>,
158    pub default_value: Option<String>,
159}
160
161/// Filter type
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163#[serde(rename_all = "snake_case")]
164pub enum FilterType {
165    ServiceName,
166    Environment,
167    ScenarioType,
168    TimeRange,
169    Custom,
170}
171
172/// Dashboard layout manager
173pub struct DashboardLayoutManager {
174    layouts_dir: String,
175}
176
177impl DashboardLayoutManager {
178    /// Create a new layout manager
179    pub fn new(layouts_dir: String) -> Result<Self> {
180        // Ensure directory exists
181        fs::create_dir_all(&layouts_dir)?;
182
183        Ok(Self { layouts_dir })
184    }
185
186    /// Save a dashboard layout
187    pub fn save_layout(&self, layout: &DashboardLayout) -> Result<()> {
188        let file_path = self.get_layout_path(&layout.id);
189        let json = serde_json::to_string_pretty(layout)?;
190        fs::write(file_path, json)?;
191        Ok(())
192    }
193
194    /// Load a dashboard layout
195    pub fn load_layout(&self, layout_id: &str) -> Result<DashboardLayout> {
196        let file_path = self.get_layout_path(layout_id);
197
198        if !Path::new(&file_path).exists() {
199            return Err(ReportingError::Analysis(format!("Layout not found: {}", layout_id)));
200        }
201
202        let json = fs::read_to_string(file_path)?;
203        let layout: DashboardLayout = serde_json::from_str(&json)?;
204        Ok(layout)
205    }
206
207    /// List all dashboard layouts
208    pub fn list_layouts(&self) -> Result<Vec<DashboardLayoutInfo>> {
209        let mut layouts = Vec::new();
210
211        for entry in fs::read_dir(&self.layouts_dir)? {
212            let entry = entry?;
213            let path = entry.path();
214
215            if path.extension().and_then(|s| s.to_str()) == Some("json") {
216                if let Ok(json) = fs::read_to_string(&path) {
217                    if let Ok(layout) = serde_json::from_str::<DashboardLayout>(&json) {
218                        layouts.push(DashboardLayoutInfo {
219                            id: layout.id,
220                            name: layout.name,
221                            description: layout.description,
222                            author: layout.author,
223                            tags: layout.tags,
224                            created_at: layout.created_at,
225                            updated_at: layout.updated_at,
226                            widget_count: layout.widgets.len(),
227                        });
228                    }
229                }
230            }
231        }
232
233        Ok(layouts)
234    }
235
236    /// Delete a dashboard layout
237    pub fn delete_layout(&self, layout_id: &str) -> Result<()> {
238        let file_path = self.get_layout_path(layout_id);
239
240        if Path::new(&file_path).exists() {
241            fs::remove_file(file_path)?;
242            Ok(())
243        } else {
244            Err(ReportingError::Analysis(format!("Layout not found: {}", layout_id)))
245        }
246    }
247
248    /// Clone a dashboard layout with a new ID
249    pub fn clone_layout(
250        &self,
251        source_id: &str,
252        new_name: &str,
253        new_author: &str,
254    ) -> Result<DashboardLayout> {
255        let mut layout = self.load_layout(source_id)?;
256
257        // Update with new information
258        layout.id = uuid::Uuid::new_v4().to_string();
259        layout.name = new_name.to_string();
260        layout.author = new_author.to_string();
261        layout.created_at = chrono::Utc::now();
262        layout.updated_at = chrono::Utc::now();
263
264        self.save_layout(&layout)?;
265        Ok(layout)
266    }
267
268    /// Export layout to JSON string
269    pub fn export_layout(&self, layout_id: &str) -> Result<String> {
270        let layout = self.load_layout(layout_id)?;
271        let json = serde_json::to_string_pretty(&layout)?;
272        Ok(json)
273    }
274
275    /// Import layout from JSON string
276    pub fn import_layout(&self, json: &str) -> Result<DashboardLayout> {
277        let layout: DashboardLayout = serde_json::from_str(json)?;
278        self.save_layout(&layout)?;
279        Ok(layout)
280    }
281
282    fn get_layout_path(&self, layout_id: &str) -> String {
283        format!("{}/{}.json", self.layouts_dir, layout_id)
284    }
285}
286
287/// Dashboard layout info (summary)
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct DashboardLayoutInfo {
290    pub id: String,
291    pub name: String,
292    pub description: Option<String>,
293    pub author: String,
294    pub tags: Vec<String>,
295    pub created_at: chrono::DateTime<chrono::Utc>,
296    pub updated_at: chrono::DateTime<chrono::Utc>,
297    pub widget_count: usize,
298}
299
300/// Dashboard layout builder
301pub struct DashboardLayoutBuilder {
302    layout: DashboardLayout,
303}
304
305impl DashboardLayoutBuilder {
306    /// Create a new layout builder
307    pub fn new(name: &str, author: &str) -> Self {
308        let now = chrono::Utc::now();
309
310        Self {
311            layout: DashboardLayout {
312                id: uuid::Uuid::new_v4().to_string(),
313                name: name.to_string(),
314                description: None,
315                created_at: now,
316                updated_at: now,
317                author: author.to_string(),
318                tags: Vec::new(),
319                is_public: false,
320                grid_config: GridConfig::default(),
321                widgets: Vec::new(),
322                filters: Vec::new(),
323            },
324        }
325    }
326
327    /// Set description
328    pub fn description(mut self, description: &str) -> Self {
329        self.layout.description = Some(description.to_string());
330        self
331    }
332
333    /// Add tag
334    pub fn tag(mut self, tag: &str) -> Self {
335        self.layout.tags.push(tag.to_string());
336        self
337    }
338
339    /// Set public visibility
340    pub fn public(mut self, is_public: bool) -> Self {
341        self.layout.is_public = is_public;
342        self
343    }
344
345    /// Set grid configuration
346    pub fn grid_config(mut self, config: GridConfig) -> Self {
347        self.layout.grid_config = config;
348        self
349    }
350
351    /// Add widget
352    pub fn add_widget(mut self, widget: Widget) -> Self {
353        self.layout.widgets.push(widget);
354        self
355    }
356
357    /// Add filter
358    pub fn add_filter(mut self, filter: DashboardFilter) -> Self {
359        self.layout.filters.push(filter);
360        self
361    }
362
363    /// Build the layout
364    pub fn build(self) -> DashboardLayout {
365        self.layout
366    }
367}
368
369/// Pre-built dashboard templates
370pub struct DashboardTemplates;
371
372impl DashboardTemplates {
373    /// Create a chaos engineering overview dashboard
374    pub fn chaos_overview() -> DashboardLayout {
375        DashboardLayoutBuilder::new("Chaos Engineering Overview", "MockForge")
376            .description("Real-time overview of chaos engineering activities")
377            .tag("chaos")
378            .tag("overview")
379            .public(true)
380            .add_widget(Widget {
381                id: "active-scenarios".to_string(),
382                widget_type: WidgetType::Counter,
383                title: "Active Scenarios".to_string(),
384                position: WidgetPosition {
385                    x: 0,
386                    y: 0,
387                    width: 3,
388                    height: 2,
389                },
390                data_source: DataSource {
391                    source_type: DataSourceType::ScenarioExecutions,
392                    query: "count(active_scenarios)".to_string(),
393                    aggregation: Some(AggregationType::Count),
394                    time_range: TimeRange {
395                        range_type: TimeRangeType::Last15Minutes,
396                        value: None,
397                    },
398                },
399                refresh_interval_seconds: Some(5),
400                config: serde_json::json!({"color": "blue"}),
401            })
402            .add_widget(Widget {
403                id: "error-rate".to_string(),
404                widget_type: WidgetType::LineChart,
405                title: "Error Rate".to_string(),
406                position: WidgetPosition {
407                    x: 3,
408                    y: 0,
409                    width: 6,
410                    height: 4,
411                },
412                data_source: DataSource {
413                    source_type: DataSourceType::ChaosMetrics,
414                    query: "error_rate".to_string(),
415                    aggregation: Some(AggregationType::Average),
416                    time_range: TimeRange {
417                        range_type: TimeRangeType::Last1Hour,
418                        value: None,
419                    },
420                },
421                refresh_interval_seconds: Some(10),
422                config: serde_json::json!({"yAxisLabel": "Error %"}),
423            })
424            .add_widget(Widget {
425                id: "latency-heatmap".to_string(),
426                widget_type: WidgetType::Heatmap,
427                title: "Latency Distribution".to_string(),
428                position: WidgetPosition {
429                    x: 0,
430                    y: 4,
431                    width: 12,
432                    height: 4,
433                },
434                data_source: DataSource {
435                    source_type: DataSourceType::OpenTelemetry,
436                    query: "histogram_quantile(0.95, latency)".to_string(),
437                    aggregation: Some(AggregationType::P95),
438                    time_range: TimeRange {
439                        range_type: TimeRangeType::Last6Hours,
440                        value: None,
441                    },
442                },
443                refresh_interval_seconds: Some(30),
444                config: serde_json::json!({"colorScheme": "RdYlGn"}),
445            })
446            .build()
447    }
448
449    /// Create a service performance dashboard
450    pub fn service_performance() -> DashboardLayout {
451        DashboardLayoutBuilder::new("Service Performance", "MockForge")
452            .description("Detailed service performance metrics")
453            .tag("performance")
454            .tag("services")
455            .public(true)
456            .add_widget(Widget {
457                id: "request-rate".to_string(),
458                widget_type: WidgetType::LineChart,
459                title: "Request Rate".to_string(),
460                position: WidgetPosition { x: 0, y: 0, width: 6, height: 4 },
461                data_source: DataSource {
462                    source_type: DataSourceType::Prometheus,
463                    query: "rate(http_requests_total[5m])".to_string(),
464                    aggregation: None,
465                    time_range: TimeRange {
466                        range_type: TimeRangeType::Last1Hour,
467                        value: None,
468                    },
469                },
470                refresh_interval_seconds: Some(10),
471                config: serde_json::json!({}),
472            })
473            .add_widget(Widget {
474                id: "p95-latency".to_string(),
475                widget_type: WidgetType::Gauge,
476                title: "P95 Latency".to_string(),
477                position: WidgetPosition { x: 6, y: 0, width: 3, height: 4 },
478                data_source: DataSource {
479                    source_type: DataSourceType::Prometheus,
480                    query: "histogram_quantile(0.95, latency_seconds)".to_string(),
481                    aggregation: Some(AggregationType::P95),
482                    time_range: TimeRange {
483                        range_type: TimeRangeType::Last15Minutes,
484                        value: None,
485                    },
486                },
487                refresh_interval_seconds: Some(5),
488                config: serde_json::json!({"max": 1000, "thresholds": [{"value": 500, "color": "yellow"}, {"value": 800, "color": "red"}]}),
489            })
490            .build()
491    }
492
493    /// Create a resilience testing dashboard
494    pub fn resilience_testing() -> DashboardLayout {
495        DashboardLayoutBuilder::new("Resilience Testing", "MockForge")
496            .description("Monitor resilience patterns and circuit breaker status")
497            .tag("resilience")
498            .tag("testing")
499            .public(true)
500            .add_widget(Widget {
501                id: "circuit-breaker-status".to_string(),
502                widget_type: WidgetType::Table,
503                title: "Circuit Breaker Status".to_string(),
504                position: WidgetPosition { x: 0, y: 0, width: 6, height: 4 },
505                data_source: DataSource {
506                    source_type: DataSourceType::ChaosMetrics,
507                    query: "circuit_breaker_status".to_string(),
508                    aggregation: None,
509                    time_range: TimeRange {
510                        range_type: TimeRangeType::Last15Minutes,
511                        value: None,
512                    },
513                },
514                refresh_interval_seconds: Some(5),
515                config: serde_json::json!({"columns": ["service", "status", "failures", "last_failure"]}),
516            })
517            .add_widget(Widget {
518                id: "retry-success-rate".to_string(),
519                widget_type: WidgetType::BarChart,
520                title: "Retry Success Rate".to_string(),
521                position: WidgetPosition { x: 6, y: 0, width: 6, height: 4 },
522                data_source: DataSource {
523                    source_type: DataSourceType::ChaosMetrics,
524                    query: "retry_success_rate".to_string(),
525                    aggregation: Some(AggregationType::Average),
526                    time_range: TimeRange {
527                        range_type: TimeRangeType::Last1Hour,
528                        value: None,
529                    },
530                },
531                refresh_interval_seconds: Some(15),
532                config: serde_json::json!({}),
533            })
534            .build()
535    }
536}
537
538// Add uuid dependency
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use tempfile::tempdir;
544
545    #[test]
546    fn test_dashboard_layout_builder() {
547        let layout = DashboardLayoutBuilder::new("Test Dashboard", "test-user")
548            .description("Test description")
549            .tag("test")
550            .public(true)
551            .build();
552
553        assert_eq!(layout.name, "Test Dashboard");
554        assert_eq!(layout.author, "test-user");
555        assert!(layout.is_public);
556        assert_eq!(layout.tags, vec!["test"]);
557    }
558
559    #[test]
560    fn test_layout_manager_save_and_load() {
561        let temp_dir = tempdir().unwrap();
562        let manager =
563            DashboardLayoutManager::new(temp_dir.path().to_str().unwrap().to_string()).unwrap();
564
565        let layout = DashboardLayoutBuilder::new("Test", "author").build();
566
567        // Save
568        manager.save_layout(&layout).unwrap();
569
570        // Load
571        let loaded = manager.load_layout(&layout.id).unwrap();
572        assert_eq!(loaded.id, layout.id);
573        assert_eq!(loaded.name, layout.name);
574    }
575
576    #[test]
577    fn test_layout_templates() {
578        let chaos_layout = DashboardTemplates::chaos_overview();
579        assert_eq!(chaos_layout.name, "Chaos Engineering Overview");
580        assert!(!chaos_layout.widgets.is_empty());
581
582        let perf_layout = DashboardTemplates::service_performance();
583        assert_eq!(perf_layout.name, "Service Performance");
584    }
585}