1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct DashboardConfig {
11 pub app: AppConfig,
13 pub data_source: DataSourceConfig,
15 pub panels: Vec<Panel>,
17 pub layout: Layout,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct AppConfig {
24 pub name: String,
26 pub version: String,
28 pub port: u16,
30 pub theme: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DataSourceConfig {
37 #[serde(rename = "type")]
39 pub source_type: String,
40 pub path: String,
42 pub refresh_interval_ms: u64,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct Panel {
49 pub id: String,
51 pub title: String,
53 #[serde(rename = "type")]
55 pub panel_type: String,
56 pub query: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub y_axis: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub max: Option<u64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub unit: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub thresholds: Option<Vec<Threshold>>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct Threshold {
75 pub value: u64,
77 pub color: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct Layout {
84 pub rows: Vec<LayoutRow>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct LayoutRow {
91 pub height: String,
93 pub panels: Vec<String>,
95}
96
97#[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 #[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 #[must_use]
130 pub fn port(mut self, port: u16) -> Self {
131 self.port = port;
132 self
133 }
134
135 #[must_use]
137 pub fn theme(mut self, theme: &str) -> Self {
138 self.theme = theme.to_string();
139 self
140 }
141
142 #[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 #[must_use]
152 pub fn refresh_interval_ms(mut self, ms: u64) -> Self {
153 self.refresh_ms = ms;
154 self
155 }
156
157 #[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 #[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 #[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 #[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 #[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 #[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#[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#[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 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 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}