Skip to main content

batuta/viz/
dashboard.rs

1//! Presentar Dashboard Configuration Generator
2//!
3//! Generates YAML configurations for Presentar monitoring dashboards
4//! with Trueno-DB and Prometheus data source support.
5
6use serde::{Deserialize, Serialize};
7
8/// Dashboard configuration
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct DashboardConfig {
11    /// Application metadata
12    pub app: AppConfig,
13    /// Data source configuration
14    pub data_source: DataSourceConfig,
15    /// Dashboard panels
16    pub panels: Vec<Panel>,
17    /// Layout configuration
18    pub layout: Layout,
19}
20
21/// Application configuration
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct AppConfig {
24    /// Dashboard name
25    pub name: String,
26    /// Version
27    pub version: String,
28    /// Server port
29    pub port: u16,
30    /// Theme (light/dark)
31    pub theme: String,
32}
33
34/// Data source configuration
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DataSourceConfig {
37    /// Source type (trueno-db, prometheus, file)
38    #[serde(rename = "type")]
39    pub source_type: String,
40    /// Connection path/URL
41    pub path: String,
42    /// Refresh interval in milliseconds
43    pub refresh_interval_ms: u64,
44}
45
46/// Dashboard panel
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct Panel {
49    /// Panel ID
50    pub id: String,
51    /// Display title
52    pub title: String,
53    /// Panel type (timeseries, gauge, bar, stat, table)
54    #[serde(rename = "type")]
55    pub panel_type: String,
56    /// Query to execute
57    pub query: String,
58    /// Y-axis label (for timeseries)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub y_axis: Option<String>,
61    /// Max value (for gauge)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub max: Option<u64>,
64    /// Unit (for stat)
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub unit: Option<String>,
67    /// Thresholds for coloring
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub thresholds: Option<Vec<Threshold>>,
70}
71
72/// Threshold for panel coloring
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct Threshold {
75    /// Value at which threshold activates
76    pub value: u64,
77    /// Color to use (red, yellow, green)
78    pub color: String,
79}
80
81/// Dashboard layout
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct Layout {
84    /// Row configurations
85    pub rows: Vec<LayoutRow>,
86}
87
88/// Layout row
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct LayoutRow {
91    /// Row height (CSS)
92    pub height: String,
93    /// Panel IDs in this row
94    pub panels: Vec<String>,
95}
96
97/// Builder for dashboard configuration
98#[derive(Debug, Default)]
99pub struct DashboardBuilder {
100    name: String,
101    version: String,
102    port: u16,
103    theme: String,
104    source_type: String,
105    source_path: String,
106    refresh_ms: u64,
107    panels: Vec<Panel>,
108    rows: Vec<LayoutRow>,
109}
110
111impl DashboardBuilder {
112    /// Create a new dashboard builder
113    #[must_use]
114    pub fn new(name: &str) -> Self {
115        Self {
116            name: name.to_string(),
117            version: "1.0.0".to_string(),
118            port: 3000,
119            theme: "dark".to_string(),
120            source_type: "trueno-db".to_string(),
121            source_path: "metrics".to_string(),
122            refresh_ms: 1000,
123            panels: Vec::new(),
124            rows: Vec::new(),
125        }
126    }
127
128    /// Set port
129    #[must_use]
130    pub fn port(mut self, port: u16) -> Self {
131        self.port = port;
132        self
133    }
134
135    /// Set theme
136    #[must_use]
137    pub fn theme(mut self, theme: &str) -> Self {
138        self.theme = theme.to_string();
139        self
140    }
141
142    /// Set data source
143    #[must_use]
144    pub fn data_source(mut self, source_type: &str, path: &str) -> Self {
145        self.source_type = source_type.to_string();
146        self.source_path = path.to_string();
147        self
148    }
149
150    /// Set refresh interval
151    #[must_use]
152    pub fn refresh_interval_ms(mut self, ms: u64) -> Self {
153        self.refresh_ms = ms;
154        self
155    }
156
157    /// Add a timeseries panel
158    #[must_use]
159    pub fn add_timeseries(mut self, id: &str, title: &str, query: &str, y_axis: &str) -> Self {
160        self.panels.push(Panel {
161            id: id.to_string(),
162            title: title.to_string(),
163            panel_type: "timeseries".to_string(),
164            query: query.to_string(),
165            y_axis: Some(y_axis.to_string()),
166            max: None,
167            unit: None,
168            thresholds: None,
169        });
170        self
171    }
172
173    /// Add a gauge panel
174    #[must_use]
175    pub fn add_gauge(mut self, id: &str, title: &str, query: &str, max: u64) -> Self {
176        self.panels.push(Panel {
177            id: id.to_string(),
178            title: title.to_string(),
179            panel_type: "gauge".to_string(),
180            query: query.to_string(),
181            y_axis: None,
182            max: Some(max),
183            unit: None,
184            thresholds: None,
185        });
186        self
187    }
188
189    /// Add a stat panel
190    #[must_use]
191    pub fn add_stat(mut self, id: &str, title: &str, query: &str, unit: &str) -> Self {
192        self.panels.push(Panel {
193            id: id.to_string(),
194            title: title.to_string(),
195            panel_type: "stat".to_string(),
196            query: query.to_string(),
197            y_axis: None,
198            max: None,
199            unit: Some(unit.to_string()),
200            thresholds: None,
201        });
202        self
203    }
204
205    /// Add a table panel
206    #[must_use]
207    pub fn add_table(mut self, id: &str, title: &str, query: &str) -> Self {
208        self.panels.push(Panel {
209            id: id.to_string(),
210            title: title.to_string(),
211            panel_type: "table".to_string(),
212            query: query.to_string(),
213            y_axis: None,
214            max: None,
215            unit: None,
216            thresholds: None,
217        });
218        self
219    }
220
221    /// Add a layout row
222    #[must_use]
223    pub fn add_row(mut self, height: &str, panels: &[&str]) -> Self {
224        self.rows.push(LayoutRow {
225            height: height.to_string(),
226            panels: panels.iter().map(|s| (*s).to_string()).collect(),
227        });
228        self
229    }
230
231    /// Build the dashboard configuration
232    #[must_use]
233    pub fn build(self) -> DashboardConfig {
234        DashboardConfig {
235            app: AppConfig {
236                name: self.name,
237                version: self.version,
238                port: self.port,
239                theme: self.theme,
240            },
241            data_source: DataSourceConfig {
242                source_type: self.source_type,
243                path: self.source_path,
244                refresh_interval_ms: self.refresh_ms,
245            },
246            panels: self.panels,
247            layout: Layout { rows: self.rows },
248        }
249    }
250}
251
252/// Create a default monitoring dashboard for Realizar
253#[must_use]
254pub fn default_realizar_dashboard() -> DashboardConfig {
255    DashboardBuilder::new("Realizar Monitoring")
256        .port(3000)
257        .theme("dark")
258        .data_source("trueno-db", "metrics")
259        .refresh_interval_ms(1000)
260        .add_timeseries(
261            "inference_latency",
262            "Inference Latency",
263            "SELECT time, p50, p95, p99 FROM realizar_metrics WHERE metric = 'inference_latency_ms'",
264            "Latency (ms)",
265        )
266        .add_gauge(
267            "throughput",
268            "Token Throughput",
269            "SELECT avg(tokens_per_second) FROM realizar_metrics WHERE metric = 'throughput'",
270            1000,
271        )
272        .add_stat(
273            "error_rate",
274            "Error Rate",
275            "SELECT (count(*) FILTER (WHERE status = 'error')) * 100.0 / count(*) FROM realizar_metrics",
276            "%",
277        )
278        .add_table(
279            "ab_tests",
280            "A/B Test Results",
281            "SELECT test_name, variant, requests, success_rate FROM ab_test_results",
282        )
283        .add_row("300px", &["inference_latency", "throughput"])
284        .add_row("200px", &["error_rate", "ab_tests"])
285        .build()
286}
287
288// ============================================================================
289// Tests
290// ============================================================================
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_dashboard_builder() {
298        let dashboard = DashboardBuilder::new("Test Dashboard")
299            .port(8080)
300            .theme("light")
301            .data_source("prometheus", "localhost:9090")
302            .build();
303
304        assert_eq!(dashboard.app.name, "Test Dashboard");
305        assert_eq!(dashboard.app.port, 8080);
306        assert_eq!(dashboard.app.theme, "light");
307        assert_eq!(dashboard.data_source.source_type, "prometheus");
308        assert_eq!(dashboard.data_source.path, "localhost:9090");
309    }
310
311    #[test]
312    fn test_dashboard_builder_defaults() {
313        let dashboard = DashboardBuilder::new("Default").build();
314
315        assert_eq!(dashboard.app.port, 3000);
316        assert_eq!(dashboard.app.theme, "dark");
317        assert_eq!(dashboard.data_source.source_type, "trueno-db");
318        assert_eq!(dashboard.data_source.refresh_interval_ms, 1000);
319    }
320
321    #[test]
322    fn test_add_timeseries_panel() {
323        let dashboard = DashboardBuilder::new("Test")
324            .add_timeseries("latency", "Latency", "SELECT * FROM metrics", "ms")
325            .build();
326
327        assert_eq!(dashboard.panels.len(), 1);
328        assert_eq!(dashboard.panels[0].id, "latency");
329        assert_eq!(dashboard.panels[0].panel_type, "timeseries");
330        assert_eq!(dashboard.panels[0].y_axis, Some("ms".to_string()));
331    }
332
333    #[test]
334    fn test_add_gauge_panel() {
335        let dashboard = DashboardBuilder::new("Test")
336            .add_gauge("throughput", "Throughput", "SELECT avg(tps) FROM metrics", 1000)
337            .build();
338
339        assert_eq!(dashboard.panels.len(), 1);
340        assert_eq!(dashboard.panels[0].panel_type, "gauge");
341        assert_eq!(dashboard.panels[0].max, Some(1000));
342    }
343
344    #[test]
345    fn test_add_stat_panel() {
346        let dashboard = DashboardBuilder::new("Test")
347            .add_stat("errors", "Error Rate", "SELECT error_pct FROM metrics", "%")
348            .build();
349
350        assert_eq!(dashboard.panels.len(), 1);
351        assert_eq!(dashboard.panels[0].panel_type, "stat");
352        assert_eq!(dashboard.panels[0].unit, Some("%".to_string()));
353    }
354
355    #[test]
356    fn test_add_table_panel() {
357        let dashboard = DashboardBuilder::new("Test")
358            .add_table("results", "Results", "SELECT * FROM results")
359            .build();
360
361        assert_eq!(dashboard.panels.len(), 1);
362        assert_eq!(dashboard.panels[0].panel_type, "table");
363    }
364
365    #[test]
366    fn test_layout_rows() {
367        let dashboard = DashboardBuilder::new("Test")
368            .add_timeseries("a", "A", "SELECT 1", "y")
369            .add_gauge("b", "B", "SELECT 2", 100)
370            .add_row("300px", &["a", "b"])
371            .build();
372
373        assert_eq!(dashboard.layout.rows.len(), 1);
374        assert_eq!(dashboard.layout.rows[0].height, "300px");
375        assert_eq!(dashboard.layout.rows[0].panels, vec!["a", "b"]);
376    }
377
378    #[test]
379    fn test_default_realizar_dashboard() {
380        let dashboard = default_realizar_dashboard();
381
382        assert_eq!(dashboard.app.name, "Realizar Monitoring");
383        assert_eq!(dashboard.panels.len(), 4);
384        assert_eq!(dashboard.layout.rows.len(), 2);
385
386        // Check panel types
387        let panel_types: Vec<_> = dashboard.panels.iter().map(|p| p.panel_type.as_str()).collect();
388        assert!(panel_types.contains(&"timeseries"));
389        assert!(panel_types.contains(&"gauge"));
390        assert!(panel_types.contains(&"stat"));
391        assert!(panel_types.contains(&"table"));
392    }
393
394    #[test]
395    fn test_dashboard_serialization() {
396        let dashboard = DashboardBuilder::new("Test")
397            .add_timeseries("m1", "Metric 1", "SELECT 1", "value")
398            .add_row("200px", &["m1"])
399            .build();
400
401        let yaml = serde_yaml_ng::to_string(&dashboard).expect("yaml serialize failed");
402        assert!(yaml.contains("name: Test"));
403        assert!(yaml.contains("type: timeseries"));
404        assert!(yaml.contains("height: 200px"));
405    }
406
407    #[test]
408    fn test_dashboard_deserialization() {
409        let yaml = r#"
410app:
411  name: "Deserialized"
412  version: "1.0.0"
413  port: 9000
414  theme: light
415data_source:
416  type: file
417  path: /tmp/metrics.db
418  refresh_interval_ms: 5000
419panels: []
420layout:
421  rows: []
422"#;
423
424        let dashboard: DashboardConfig =
425            serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
426        assert_eq!(dashboard.app.name, "Deserialized");
427        assert_eq!(dashboard.app.port, 9000);
428        assert_eq!(dashboard.data_source.source_type, "file");
429        assert_eq!(dashboard.data_source.refresh_interval_ms, 5000);
430    }
431
432    #[test]
433    fn test_threshold_serialization() {
434        let threshold = Threshold { value: 50, color: "yellow".to_string() };
435
436        let json = serde_json::to_string(&threshold).expect("json serialize failed");
437        assert!(json.contains("50"));
438        assert!(json.contains("yellow"));
439    }
440
441    #[test]
442    fn test_multiple_panels_and_rows() {
443        let dashboard = DashboardBuilder::new("Complex")
444            .add_timeseries("ts1", "Time Series 1", "Q1", "Y1")
445            .add_timeseries("ts2", "Time Series 2", "Q2", "Y2")
446            .add_gauge("g1", "Gauge 1", "Q3", 100)
447            .add_stat("s1", "Stat 1", "Q4", "units")
448            .add_table("t1", "Table 1", "Q5")
449            .add_row("300px", &["ts1", "ts2"])
450            .add_row("200px", &["g1", "s1"])
451            .add_row("250px", &["t1"])
452            .build();
453
454        assert_eq!(dashboard.panels.len(), 5);
455        assert_eq!(dashboard.layout.rows.len(), 3);
456        assert_eq!(dashboard.layout.rows[0].panels.len(), 2);
457        assert_eq!(dashboard.layout.rows[1].panels.len(), 2);
458        assert_eq!(dashboard.layout.rows[2].panels.len(), 1);
459    }
460
461    #[test]
462    fn test_refresh_interval() {
463        let dashboard = DashboardBuilder::new("Refresh Test").refresh_interval_ms(500).build();
464
465        assert_eq!(dashboard.data_source.refresh_interval_ms, 500);
466    }
467
468    #[test]
469    fn test_data_source_types() {
470        let trueno = DashboardBuilder::new("TruenoDB").data_source("trueno-db", "metrics").build();
471        assert_eq!(trueno.data_source.source_type, "trueno-db");
472
473        let prometheus =
474            DashboardBuilder::new("Prometheus").data_source("prometheus", "localhost:9090").build();
475        assert_eq!(prometheus.data_source.source_type, "prometheus");
476
477        let file = DashboardBuilder::new("File").data_source("file", "/path/to/db").build();
478        assert_eq!(file.data_source.source_type, "file");
479    }
480
481    #[test]
482    fn test_dashboard_builder_default_trait() {
483        let builder = DashboardBuilder::default();
484        let dashboard = builder.build();
485        // Default builder has empty name
486        assert_eq!(dashboard.app.name, "");
487        assert_eq!(dashboard.app.version, "");
488    }
489
490    #[test]
491    fn test_dashboard_config_clone() {
492        let dashboard = DashboardBuilder::new("Clone Test").build();
493        let cloned = dashboard.clone();
494        assert_eq!(dashboard, cloned);
495    }
496
497    #[test]
498    fn test_dashboard_config_debug() {
499        let dashboard = DashboardBuilder::new("Debug Test").build();
500        let debug_str = format!("{:?}", dashboard);
501        assert!(debug_str.contains("Debug Test"));
502        assert!(debug_str.contains("DashboardConfig"));
503    }
504
505    #[test]
506    fn test_app_config_clone() {
507        let app = AppConfig {
508            name: "Test".to_string(),
509            version: "1.0".to_string(),
510            port: 8080,
511            theme: "dark".to_string(),
512        };
513        let cloned = app.clone();
514        assert_eq!(app, cloned);
515    }
516
517    #[test]
518    fn test_app_config_debug() {
519        let app = AppConfig {
520            name: "Debug".to_string(),
521            version: "2.0".to_string(),
522            port: 3000,
523            theme: "light".to_string(),
524        };
525        let debug_str = format!("{:?}", app);
526        assert!(debug_str.contains("Debug"));
527        assert!(debug_str.contains("AppConfig"));
528    }
529
530    #[test]
531    fn test_data_source_config_clone() {
532        let ds = DataSourceConfig {
533            source_type: "prometheus".to_string(),
534            path: "localhost:9090".to_string(),
535            refresh_interval_ms: 2000,
536        };
537        let cloned = ds.clone();
538        assert_eq!(ds, cloned);
539    }
540
541    #[test]
542    fn test_data_source_config_debug() {
543        let ds = DataSourceConfig {
544            source_type: "trueno-db".to_string(),
545            path: "metrics".to_string(),
546            refresh_interval_ms: 1000,
547        };
548        let debug_str = format!("{:?}", ds);
549        assert!(debug_str.contains("trueno-db"));
550    }
551
552    #[test]
553    fn test_panel_clone() {
554        let panel = Panel {
555            id: "test_id".to_string(),
556            title: "Test Panel".to_string(),
557            panel_type: "gauge".to_string(),
558            query: "SELECT 1".to_string(),
559            y_axis: None,
560            max: Some(100),
561            unit: None,
562            thresholds: Some(vec![Threshold { value: 50, color: "yellow".to_string() }]),
563        };
564        let cloned = panel.clone();
565        assert_eq!(panel, cloned);
566    }
567
568    #[test]
569    fn test_panel_debug() {
570        let panel = Panel {
571            id: "debug_panel".to_string(),
572            title: "Debug".to_string(),
573            panel_type: "stat".to_string(),
574            query: "Q".to_string(),
575            y_axis: None,
576            max: None,
577            unit: Some("ms".to_string()),
578            thresholds: None,
579        };
580        let debug_str = format!("{:?}", panel);
581        assert!(debug_str.contains("debug_panel"));
582    }
583
584    #[test]
585    fn test_threshold_clone() {
586        let threshold = Threshold { value: 75, color: "red".to_string() };
587        let cloned = threshold.clone();
588        assert_eq!(threshold, cloned);
589    }
590
591    #[test]
592    fn test_threshold_debug() {
593        let threshold = Threshold { value: 100, color: "green".to_string() };
594        let debug_str = format!("{:?}", threshold);
595        assert!(debug_str.contains("100"));
596        assert!(debug_str.contains("green"));
597    }
598
599    #[test]
600    fn test_layout_clone() {
601        let layout = Layout {
602            rows: vec![LayoutRow {
603                height: "300px".to_string(),
604                panels: vec!["a".to_string(), "b".to_string()],
605            }],
606        };
607        let cloned = layout.clone();
608        assert_eq!(layout, cloned);
609    }
610
611    #[test]
612    fn test_layout_debug() {
613        let layout = Layout { rows: vec![] };
614        let debug_str = format!("{:?}", layout);
615        assert!(debug_str.contains("Layout"));
616    }
617
618    #[test]
619    fn test_layout_row_clone() {
620        let row = LayoutRow { height: "200px".to_string(), panels: vec!["panel1".to_string()] };
621        let cloned = row.clone();
622        assert_eq!(row, cloned);
623    }
624
625    #[test]
626    fn test_layout_row_debug() {
627        let row = LayoutRow { height: "150px".to_string(), panels: vec!["x".to_string()] };
628        let debug_str = format!("{:?}", row);
629        assert!(debug_str.contains("150px"));
630    }
631
632    #[test]
633    fn test_dashboard_builder_debug() {
634        let builder = DashboardBuilder::new("Builder Debug");
635        let debug_str = format!("{:?}", builder);
636        assert!(debug_str.contains("DashboardBuilder"));
637        assert!(debug_str.contains("Builder Debug"));
638    }
639
640    #[test]
641    fn test_dashboard_config_equality() {
642        let d1 = DashboardBuilder::new("Test").port(8080).build();
643        let d2 = DashboardBuilder::new("Test").port(8080).build();
644        let d3 = DashboardBuilder::new("Test").port(9090).build();
645        assert_eq!(d1, d2);
646        assert_ne!(d1, d3);
647    }
648
649    #[test]
650    fn test_panel_with_thresholds() {
651        let panel = Panel {
652            id: "threshold_panel".to_string(),
653            title: "With Thresholds".to_string(),
654            panel_type: "gauge".to_string(),
655            query: "SELECT value FROM metrics".to_string(),
656            y_axis: None,
657            max: Some(100),
658            unit: None,
659            thresholds: Some(vec![
660                Threshold { value: 25, color: "green".to_string() },
661                Threshold { value: 50, color: "yellow".to_string() },
662                Threshold { value: 75, color: "red".to_string() },
663            ]),
664        };
665        assert_eq!(panel.thresholds.as_ref().expect("unexpected failure").len(), 3);
666    }
667
668    #[test]
669    fn test_dashboard_roundtrip_serialization() {
670        let original = DashboardBuilder::new("Roundtrip")
671            .port(4000)
672            .theme("light")
673            .data_source("file", "/data/metrics.db")
674            .refresh_interval_ms(2000)
675            .add_timeseries("ts", "Time Series", "SELECT * FROM ts", "Y")
676            .add_gauge("g", "Gauge", "SELECT val", 500)
677            .add_row("250px", &["ts", "g"])
678            .build();
679
680        let yaml = serde_yaml_ng::to_string(&original).expect("yaml serialize failed");
681        let deserialized: DashboardConfig =
682            serde_yaml_ng::from_str(&yaml).expect("yaml deserialize failed");
683        assert_eq!(original, deserialized);
684    }
685
686    #[test]
687    fn test_panel_json_roundtrip() {
688        let panel = Panel {
689            id: "json_panel".to_string(),
690            title: "JSON Test".to_string(),
691            panel_type: "table".to_string(),
692            query: "SELECT * FROM data".to_string(),
693            y_axis: None,
694            max: None,
695            unit: None,
696            thresholds: None,
697        };
698        let json = serde_json::to_string(&panel).expect("json serialize failed");
699        let deserialized: Panel = serde_json::from_str(&json).expect("json deserialize failed");
700        assert_eq!(panel, deserialized);
701    }
702
703    #[test]
704    fn test_empty_dashboard() {
705        let dashboard = DashboardBuilder::new("Empty").build();
706        assert!(dashboard.panels.is_empty());
707        assert!(dashboard.layout.rows.is_empty());
708    }
709
710    #[test]
711    fn test_dashboard_version_default() {
712        let dashboard = DashboardBuilder::new("Version Test").build();
713        assert_eq!(dashboard.app.version, "1.0.0");
714    }
715}