blueprint_qos/logging/
grafana.rs

1use blueprint_core::{debug, error, info, warn};
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use super::loki::LokiConfig;
7use crate::error::{Error, Result};
8
9const DEFAULT_ADMIN_PASSWORD: &str = "please_change_this_default_password";
10const DEFAULT_GRAFANA_URL: &str = "http://localhost:3000";
11const DEFAULT_GRAFANA_ADMIN_USER: &str = "admin";
12const DEFAULT_GRAFANA_PROMETHEUS_DATASOURCE_URL: &str = "http://localhost:9090";
13
14// Health check response structures
15#[derive(Debug, Deserialize, Clone)]
16#[serde(rename_all = "camelCase")]
17pub struct DatasourceHealthDetails {
18    #[serde(flatten)]
19    pub extra: std::collections::HashMap<String, serde_json::Value>,
20}
21
22#[derive(Debug, Deserialize, Clone)]
23#[serde(rename_all = "camelCase")]
24pub struct DatasourceHealthResponse {
25    pub message: String,
26    pub status: String,
27    pub details: Option<DatasourceHealthDetails>,
28}
29
30// For parsing generic Grafana JSON error responses
31#[derive(Debug, Deserialize, Clone)]
32#[serde(rename_all = "camelCase")]
33pub struct GrafanaApiErrorBody {
34    pub message: String,
35    #[serde(alias = "statusCode")]
36    pub status_code: Option<u16>,
37    #[serde(alias = "messageId")]
38    pub error_code: Option<String>,
39    pub trace_id: Option<String>,
40    #[serde(flatten)]
41    pub extra: std::collections::HashMap<String, serde_json::Value>,
42}
43
44#[derive(Serialize, Debug)]
45#[serde(rename_all = "camelCase")]
46pub struct LokiJsonData {
47    pub max_lines: Option<u32>,
48}
49
50#[derive(Serialize, Debug)]
51#[serde(rename_all = "camelCase")]
52pub struct PrometheusJsonData {
53    pub http_method: String,
54    pub timeout: u32,
55}
56
57/// Configuration for connecting to and interacting with a Grafana server.
58///
59/// This structure encapsulates the connection details, authentication credentials,
60/// and integration settings needed to communicate with a Grafana instance. It supports
61/// both `API` key authentication (preferred) and basic authentication as a fallback.
62/// The configuration also includes references to related data sources like `Prometheus` and `Loki`.
63#[derive(Clone, Debug)]
64pub struct GrafanaConfig {
65    /// The base URL for the Grafana server (e.g., "<http://localhost:3000>").
66    pub url: String,
67
68    /// API key for Grafana, if used. This is the preferred authentication method.
69    pub api_key: Option<String>,
70
71    /// Optional admin username for basic authentication (fallback if API key is not provided).
72    pub admin_user: Option<String>,
73
74    /// Optional admin password for basic authentication.
75    pub admin_password: Option<String>,
76
77    /// Default organization ID
78    pub org_id: Option<u64>,
79
80    /// Default dashboard folder
81    pub folder: Option<String>,
82
83    /// Configuration for the Loki datasource, if Grafana is expected to use one.
84    pub loki_config: Option<LokiConfig>,
85
86    /// The URL for the Prometheus datasource that Grafana should use.
87    /// If not provided, a default may be assumed.
88    pub prometheus_datasource_url: Option<String>,
89}
90
91impl Default for GrafanaConfig {
92    fn default() -> Self {
93        Self {
94            url: DEFAULT_GRAFANA_URL.to_string(),
95            api_key: None,
96            admin_user: Some(DEFAULT_GRAFANA_ADMIN_USER.to_string()),
97            admin_password: Some(DEFAULT_ADMIN_PASSWORD.to_string()),
98            org_id: None,
99            folder: None,
100            loki_config: None,
101            prometheus_datasource_url: Some(DEFAULT_GRAFANA_PROMETHEUS_DATASOURCE_URL.to_string()),
102        }
103    }
104}
105
106/// Client for interacting with the Grafana HTTP API.
107///
108/// This client provides methods to create and manage Grafana resources including
109/// dashboards, folders, and data sources. It handles authentication, request formatting,
110/// and response parsing for the Grafana API. The client is designed to support Blueprint
111/// monitoring by creating pre-configured dashboards that visualize metrics and logs.
112pub struct GrafanaClient {
113    /// HTTP client
114    client: Client,
115
116    /// Configuration
117    config: GrafanaConfig,
118}
119
120/// Dashboard model for Grafana
121#[derive(Serialize, Deserialize, Clone, Debug)]
122pub struct Dashboard {
123    /// Dashboard ID
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub id: Option<u64>,
126
127    /// Dashboard UID
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub uid: Option<String>,
130
131    /// Dashboard title
132    pub title: String,
133
134    /// Dashboard tags
135    #[serde(default)]
136    pub tags: Vec<String>,
137
138    /// Dashboard timezone
139    #[serde(default = "default_timezone")]
140    pub timezone: String,
141
142    /// Dashboard refresh interval
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub refresh: Option<String>,
145
146    /// Dashboard schema version
147    #[serde(default = "default_schema_version")]
148    pub schema_version: u64,
149
150    /// Dashboard version
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub version: Option<u64>,
153
154    /// Dashboard panels
155    #[serde(default)]
156    pub panels: Vec<Panel>,
157}
158
159/// Default timezone for dashboards
160fn default_timezone() -> String {
161    "browser".to_string()
162}
163
164/// Default schema version for dashboards
165fn default_schema_version() -> u64 {
166    36
167}
168
169/// Panel model for Grafana dashboards
170#[derive(Serialize, Deserialize, Clone, Debug)]
171pub struct Panel {
172    /// Panel ID
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub id: Option<u64>,
175
176    /// Panel title
177    pub title: String,
178
179    /// Panel type
180    #[serde(rename = "type")]
181    pub panel_type: String,
182
183    /// Panel datasource
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub datasource: Option<DataSource>,
186
187    /// Panel grid position
188    pub grid_pos: GridPos,
189
190    /// Panel targets (queries)
191    #[serde(default)]
192    pub targets: Vec<Target>,
193
194    /// Panel options
195    #[serde(default)]
196    pub options: HashMap<String, serde_json::Value>,
197
198    /// Panel field config
199    #[serde(default)]
200    pub field_config: FieldConfig,
201}
202
203/// Data source model for Grafana panels
204#[derive(Serialize, Deserialize, Clone, Debug)]
205pub struct DataSource {
206    /// Data source type
207    #[serde(rename = "type")]
208    pub ds_type: String,
209
210    /// Data source UID
211    pub uid: String,
212}
213
214/// Grid position model for Grafana panels
215#[derive(Serialize, Deserialize, Clone, Debug)]
216pub struct GridPos {
217    /// X position
218    pub x: u64,
219
220    /// Y position
221    pub y: u64,
222
223    /// Width
224    pub w: u64,
225
226    /// Height
227    pub h: u64,
228}
229
230/// Target model for Grafana panels
231#[derive(Serialize, Deserialize, Clone, Debug)]
232pub struct Target {
233    /// Target reference ID
234    pub ref_id: String,
235
236    /// Target expression (query)
237    pub expr: String,
238
239    /// Target data source
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub datasource: Option<DataSource>,
242}
243
244/// Field config model for Grafana panels
245#[derive(Serialize, Deserialize, Clone, Debug, Default)]
246pub struct FieldConfig {
247    /// Default field config
248    #[serde(default)]
249    pub defaults: FieldDefaults,
250
251    /// Field config overrides
252    #[serde(default)]
253    pub overrides: Vec<FieldOverride>,
254}
255
256/// Field defaults model for Grafana panels
257#[derive(Serialize, Deserialize, Clone, Debug, Default)]
258pub struct FieldDefaults {
259    /// Field unit
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub unit: Option<String>,
262
263    /// Field decimals
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub decimals: Option<u64>,
266
267    /// Field min value
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub min: Option<f64>,
270
271    /// Field max value
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub max: Option<f64>,
274
275    /// Field thresholds
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub thresholds: Option<Thresholds>,
278
279    /// Field color
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub color: Option<HashMap<String, serde_json::Value>>,
282}
283
284/// Field override model for Grafana panels
285#[derive(Serialize, Deserialize, Clone, Debug)]
286pub struct FieldOverride {
287    /// Override matcher
288    pub matcher: Matcher,
289
290    /// Override properties
291    pub properties: Vec<Property>,
292}
293
294/// Matcher model for Grafana field overrides
295#[derive(Serialize, Deserialize, Clone, Debug)]
296pub struct Matcher {
297    /// Matcher ID
298    pub id: String,
299
300    /// Matcher options
301    pub options: serde_json::Value,
302}
303
304/// Property model for Grafana field overrides
305#[derive(Serialize, Deserialize, Clone, Debug)]
306pub struct Property {
307    /// Property ID
308    pub id: String,
309
310    /// Property value
311    pub value: serde_json::Value,
312}
313
314/// Thresholds model for Grafana panels
315#[derive(Serialize, Deserialize, Clone, Debug)]
316pub struct Thresholds {
317    /// Threshold mode
318    pub mode: String,
319
320    /// Threshold steps
321    pub steps: Vec<ThresholdStep>,
322}
323
324/// Threshold step model for Grafana panels
325#[derive(Serialize, Deserialize, Clone, Debug)]
326pub struct ThresholdStep {
327    /// Step color
328    pub color: String,
329
330    /// Step value
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub value: Option<f64>,
333}
334
335/// Dashboard creation request for Grafana API
336#[derive(Serialize, Deserialize, Clone, Debug)]
337struct DashboardCreateRequest {
338    /// Dashboard
339    dashboard: Dashboard,
340
341    /// Folder ID
342    #[serde(skip_serializing_if = "Option::is_none")]
343    folder_id: Option<u64>,
344
345    /// Folder UID
346    #[serde(skip_serializing_if = "Option::is_none")]
347    folder_uid: Option<String>,
348
349    /// Message
350    message: String,
351
352    /// Overwrite
353    overwrite: bool,
354}
355
356/// Dashboard creation response from Grafana API
357#[derive(Deserialize, Debug)]
358#[allow(dead_code)]
359struct DashboardCreateResponse {
360    /// Dashboard ID
361    id: u64,
362
363    /// Dashboard URL
364    url: String,
365
366    /// Dashboard status
367    status: String,
368
369    /// Dashboard version
370    version: u64,
371}
372
373// Helper for parsing Grafana API error responses
374#[derive(Deserialize, Debug)]
375struct GrafanaApiError {
376    message: String,
377}
378
379#[derive(Serialize, Debug)]
380#[serde(rename_all = "camelCase")]
381pub struct CreateDataSourceRequest {
382    pub name: String,
383    #[serde(rename = "type")]
384    pub ds_type: String,
385    pub url: String,
386    pub access: String,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub uid: Option<String>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub is_default: Option<bool>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub json_data: Option<serde_json::Value>,
393}
394
395#[derive(Deserialize, Debug)]
396#[serde(rename_all = "camelCase")]
397pub struct CreateDataSourceResponse {
398    pub id: u64,
399    pub name: String,
400    pub message: String,
401    #[serde(rename = "datasource")]
402    pub datasource: DataSourceDetails,
403}
404
405#[derive(Deserialize, Debug)]
406#[serde(rename_all = "camelCase")]
407pub struct DataSourceDetails {
408    pub id: u64,
409    pub uid: String,
410    pub org_id: u64,
411    pub name: String,
412    #[serde(rename = "type")]
413    pub ds_type: String,
414    pub type_logo_url: String,
415    pub access: String,
416    pub url: String,
417    pub is_default: bool,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub json_data: Option<serde_json::Value>,
420    pub version: u64,
421    pub read_only: bool,
422}
423
424impl GrafanaClient {
425    /// Returns the configured Prometheus datasource URL, if any.
426    ///
427    /// This method provides access to the Prometheus URL that has been configured
428    /// for use with Grafana dashboards. It's used when setting up Prometheus
429    /// as a data source for created dashboards.
430    #[must_use]
431    pub fn prometheus_datasource_url(&self) -> Option<&String> {
432        self.config.prometheus_datasource_url.as_ref()
433    }
434
435    /// Creates a new Grafana client with the specified configuration.
436    ///
437    /// Initializes an HTTP client with appropriate authentication headers based on
438    /// the provided configuration. The client will use API key authentication if available,
439    /// falling back to basic authentication if credentials are provided.
440    #[must_use]
441    pub fn new(config: GrafanaConfig) -> Self {
442        let client = Client::builder()
443            .timeout(std::time::Duration::from_secs(10))
444            .build()
445            .unwrap_or_default();
446
447        if config.api_key.as_deref().unwrap_or("").is_empty() {
448            if let Some(pass) = &config.admin_password {
449                if pass == DEFAULT_ADMIN_PASSWORD {
450                    warn!(
451                        "GrafanaClient is configured to use basic authentication with the default insecure password. Please change it or provide an API key."
452                    );
453                }
454            }
455        }
456
457        Self { client, config }
458    }
459
460    /// Returns a reference to the Grafana client's configuration.
461    ///
462    /// Provides access to the underlying configuration settings that this client
463    /// was initialized with, including connection URLs and authentication details.
464    #[must_use]
465    pub fn config(&self) -> &GrafanaConfig {
466        &self.config
467    }
468
469    /// Creates or updates a Grafana dashboard.
470    ///
471    /// This method sends a dashboard configuration to the Grafana API, either creating
472    /// a new dashboard or updating an existing one if the dashboard UID already exists.
473    /// It handles the proper JSON formatting required by the Grafana API and processes
474    /// the response.
475    ///
476    /// # Parameters
477    /// * `dashboard` - The dashboard configuration to create or update
478    /// * `folder_id` - Optional folder ID to organize the dashboard in
479    /// * `message` - Commit message for the dashboard change
480    ///
481    /// # Returns
482    /// The dashboard URL on success
483    ///
484    /// # Errors
485    /// Returns an error if the Grafana API request fails or returns an error response
486    pub async fn create_dashboard(
487        &self,
488        dashboard: Dashboard,
489        folder_id: Option<u64>,
490        message: &str,
491    ) -> Result<String> {
492        let url = format!(
493            "{}/api/dashboards/db",
494            self.config.url.trim_end_matches('/')
495        );
496
497        let request = DashboardCreateRequest {
498            dashboard,
499            folder_id: folder_id.or(self.config.org_id),
500            folder_uid: None,
501            message: message.to_string(),
502            overwrite: true,
503        };
504
505        let dashboard_payload_json = serde_json::to_string_pretty(&request)
506            .unwrap_or_else(|e| format!("Failed to serialize dashboard request payload: {}", e));
507        info!(
508            "Grafana create_dashboard payload:\n{}",
509            dashboard_payload_json
510        );
511
512        let mut request_builder = self.client.post(&url);
513
514        if let Some(api_key) = &self.config.api_key {
515            if !api_key.is_empty() {
516                request_builder = request_builder.bearer_auth(api_key);
517            }
518        } else if let (Some(user), Some(pass)) =
519            (&self.config.admin_user, &self.config.admin_password)
520        {
521            if !user.is_empty() && !pass.is_empty() {
522                if pass == DEFAULT_ADMIN_PASSWORD {
523                    warn!(
524                        "Grafana basic authentication is using the default insecure password. Please change it."
525                    );
526                }
527                request_builder = request_builder.basic_auth(user, Some(pass.clone()));
528            }
529        }
530
531        let response = request_builder
532            .header("Content-Type", "application/json")
533            .json(&request)
534            .send()
535            .await
536            .map_err(|e| Error::Other(format!("Failed to create dashboard: {}", e)))?;
537
538        if !response.status().is_success() {
539            let error_text = response
540                .text()
541                .await
542                .unwrap_or_else(|_| "Unknown error".to_string());
543            return Err(Error::Other(format!(
544                "Failed to create dashboard: {}",
545                error_text
546            )));
547        }
548
549        let dashboard_response: DashboardCreateResponse = response
550            .json()
551            .await
552            .map_err(|e| Error::Other(format!("Failed to parse dashboard response: {}", e)))?;
553
554        info!("Created dashboard: {}", dashboard_response.url);
555
556        Ok(dashboard_response.url)
557    }
558
559    /// Creates a folder in Grafana for organizing dashboards.
560    ///
561    /// Folders help organize dashboards in the Grafana UI. This method attempts
562    /// to create a new folder with the specified title and optional UID.
563    /// If a folder with the same title already exists, it returns the existing
564    /// folder's ID rather than creating a duplicate.
565    ///
566    /// # Parameters
567    /// * `title` - The display name for the folder
568    /// * `uid` - Optional unique identifier for the folder
569    ///
570    /// # Returns
571    /// The folder ID (either newly created or existing)
572    ///
573    /// # Errors
574    /// Returns an error if the Grafana API request fails or returns an error response
575    pub async fn create_folder(&self, title: &str, uid: Option<&str>) -> Result<u64> {
576        let url = format!("{}/api/folders", self.config.url.trim_end_matches('/'));
577
578        let mut request = HashMap::new();
579        request.insert("title", title.to_string());
580
581        if let Some(uid) = uid {
582            request.insert("uid", uid.to_string());
583        }
584
585        let mut request_builder = self.client.post(&url);
586
587        if let Some(api_key) = &self.config.api_key {
588            if !api_key.is_empty() {
589                request_builder = request_builder.bearer_auth(api_key);
590            }
591        } else if let (Some(user), Some(pass)) =
592            (&self.config.admin_user, &self.config.admin_password)
593        {
594            if !user.is_empty() && !pass.is_empty() {
595                if pass == DEFAULT_ADMIN_PASSWORD {
596                    warn!(
597                        "Grafana basic authentication is using the default insecure password. Please change it."
598                    );
599                }
600                request_builder = request_builder.basic_auth(user, Some(pass.clone()));
601            }
602        }
603
604        let response = request_builder
605            .header("Content-Type", "application/json")
606            .json(&request)
607            .send()
608            .await
609            .map_err(|e| Error::Other(format!("Failed to create folder: {}", e)))?;
610
611        if !response.status().is_success() {
612            let error_text = response
613                .text()
614                .await
615                .unwrap_or_else(|_| "Unknown error".to_string());
616            return Err(Error::Other(format!(
617                "Failed to create folder: {}",
618                error_text
619            )));
620        }
621
622        let folder: serde_json::Value = response
623            .json()
624            .await
625            .map_err(|e| Error::Other(format!("Failed to parse folder response: {}", e)))?;
626
627        let folder_id = folder["id"]
628            .as_u64()
629            .ok_or_else(|| Error::Other("Failed to get folder ID".to_string()))?;
630
631        info!("Created folder: {} (ID: {})", title, folder_id);
632
633        Ok(folder_id)
634    }
635
636    /// Creates a pre-configured dashboard for monitoring a Blueprint service.
637    ///
638    /// Generates a comprehensive dashboard with panels for system metrics,
639    /// application metrics, and logs specific to the identified Blueprint service.
640    /// The dashboard includes panels for CPU usage, memory usage, job execution metrics,
641    /// and log streams from the specified Loki datasource.
642    ///
643    /// # Parameters
644    /// * `service_id` - The service ID to monitor
645    /// * `blueprint_id` - The blueprint ID to monitor
646    /// * `prometheus_datasource` - Name of the Prometheus datasource to use
647    /// * `loki_datasource` - Name of the Loki datasource to use for logs
648    ///
649    /// # Returns
650    /// The URL of the created dashboard
651    ///
652    /// # Errors
653    /// Returns an error if the dashboard creation fails or if the Grafana API returns an error
654    pub async fn create_blueprint_dashboard(
655        &self,
656        service_id: u64,
657        blueprint_id: u64,
658        prometheus_datasource: &str,
659        loki_datasource: &str,
660    ) -> Result<String> {
661        // Create a dashboard for the Blueprint
662        let mut dashboard = Dashboard {
663            id: None,
664            uid: Some(format!("blueprint-{}-{}", service_id, blueprint_id)),
665            title: format!("Blueprint Service {} - {}", service_id, blueprint_id),
666            tags: vec!["blueprint".to_string(), "tangle".to_string()],
667            timezone: "browser".to_string(),
668            refresh: Some("10s".to_string()),
669            schema_version: 36,
670            version: None,
671            panels: Vec::new(),
672        };
673
674        // Add system metrics panel
675        let system_metrics_panel = Panel {
676            id: Some(1),
677            title: "System Metrics".to_string(),
678            panel_type: "timeseries".to_string(),
679            datasource: Some(DataSource {
680                ds_type: "prometheus".to_string(),
681                uid: prometheus_datasource.to_string(),
682            }),
683            grid_pos: GridPos {
684                x: 0,
685                y: 0,
686                w: 12,
687                h: 8,
688            },
689            targets: vec![
690                Target {
691                    ref_id: "A".to_string(),
692                    expr: format!(
693                        "blueprint_cpu_usage{{service_id=\"{}\",blueprint_id=\"{}\"}}",
694                        service_id, blueprint_id
695                    ),
696                    datasource: None,
697                },
698                Target {
699                    ref_id: "B".to_string(),
700                    expr: format!(
701                        "blueprint_memory_usage{{service_id=\"{}\",blueprint_id=\"{}\"}}",
702                        service_id, blueprint_id
703                    ),
704                    datasource: None,
705                },
706            ],
707            options: HashMap::new(),
708            field_config: FieldConfig::default(),
709        };
710
711        // Add job metrics panel
712        let job_metrics_panel = Panel {
713            id: Some(2),
714            title: "Job Executions".to_string(),
715            panel_type: "timeseries".to_string(),
716            datasource: Some(DataSource {
717                ds_type: "prometheus".to_string(),
718                uid: prometheus_datasource.to_string(),
719            }),
720            grid_pos: GridPos {
721                x: 12,
722                y: 0,
723                w: 12,
724                h: 8,
725            },
726            targets: vec![
727                Target {
728                    ref_id: "A".to_string(),
729                    expr: format!(
730                        "otel_job_executions_total{{service_id=\"{}\",blueprint_id=\"{}\"}}",
731                        service_id, blueprint_id
732                    ),
733                    datasource: None,
734                },
735                Target {
736                    ref_id: "B".to_string(),
737                    expr: format!(
738                        "blueprint_job_errors{{service_id=\"{}\",blueprint_id=\"{}\"}}",
739                        service_id, blueprint_id
740                    ),
741                    datasource: None,
742                },
743            ],
744            options: HashMap::new(),
745            field_config: FieldConfig::default(),
746        };
747
748        // Add logs panel
749        let logs_panel = Panel {
750            id: Some(3),
751            title: "Logs".to_string(),
752            panel_type: "logs".to_string(),
753            datasource: Some(DataSource {
754                ds_type: "loki".to_string(),
755                uid: loki_datasource.to_string(),
756            }),
757            grid_pos: GridPos {
758                x: 0,
759                y: 8,
760                w: 24,
761                h: 8,
762            },
763            targets: vec![Target {
764                ref_id: "A".to_string(),
765                expr: format!(
766                    "{{service=\"blueprint\",service_id=\"{}\",blueprint_id=\"{}\"}}",
767                    service_id, blueprint_id
768                ),
769                datasource: None,
770            }],
771            options: HashMap::new(),
772            field_config: FieldConfig::default(),
773        };
774
775        // Add heartbeat panel
776        let heartbeat_panel = Panel {
777            id: Some(4),
778            title: "Heartbeats".to_string(),
779            panel_type: "stat".to_string(),
780            datasource: Some(DataSource {
781                ds_type: "prometheus".to_string(),
782                uid: prometheus_datasource.to_string(),
783            }),
784            grid_pos: GridPos {
785                x: 0,
786                y: 16,
787                w: 8,
788                h: 4,
789            },
790            targets: vec![Target {
791                ref_id: "A".to_string(),
792                expr: format!(
793                    "blueprint_last_heartbeat{{service_id=\"{}\",blueprint_id=\"{}\"}}",
794                    service_id, blueprint_id
795                ),
796                datasource: None,
797            }],
798            options: HashMap::new(),
799            field_config: FieldConfig::default(),
800        };
801
802        // Add status panel
803        let status_panel = Panel {
804            id: Some(5),
805            title: "Status".to_string(),
806            panel_type: "stat".to_string(),
807            datasource: Some(DataSource {
808                ds_type: "prometheus".to_string(),
809                uid: prometheus_datasource.to_string(),
810            }),
811            grid_pos: GridPos {
812                x: 8,
813                y: 16,
814                w: 8,
815                h: 4,
816            },
817            targets: vec![Target {
818                ref_id: "A".to_string(),
819                expr: format!(
820                    "blueprint_status_code{{service_id=\"{}\",blueprint_id=\"{}\"}}",
821                    service_id, blueprint_id
822                ),
823                datasource: None,
824            }],
825            options: HashMap::new(),
826            field_config: FieldConfig::default(),
827        };
828
829        // Add uptime panel
830        let uptime_panel = Panel {
831            id: Some(6),
832            title: "Uptime".to_string(),
833            panel_type: "stat".to_string(),
834            datasource: Some(DataSource {
835                ds_type: "prometheus".to_string(),
836                uid: prometheus_datasource.to_string(),
837            }),
838            grid_pos: GridPos {
839                x: 16,
840                y: 16,
841                w: 8,
842                h: 4,
843            },
844            targets: vec![Target {
845                ref_id: "A".to_string(),
846                expr: format!(
847                    "blueprint_uptime{{service_id=\"{}\",blueprint_id=\"{}\"}}",
848                    service_id, blueprint_id
849                ),
850                datasource: None,
851            }],
852            options: HashMap::new(),
853            field_config: FieldConfig::default(),
854        };
855
856        // Add custom test metrics panel
857        let test_metrics_panel = Panel {
858            id: Some(7),
859            title: "Custom Test Metrics".to_string(),
860            panel_type: "timeseries".to_string(),
861            datasource: Some(DataSource {
862                ds_type: "prometheus".to_string(),
863                uid: prometheus_datasource.to_string(),
864            }),
865            grid_pos: GridPos {
866                x: 0,
867                y: 20,
868                w: 24,
869                h: 8,
870            },
871            targets: vec![
872                Target {
873                    ref_id: "A".to_string(),
874                    expr: "test_blueprint_job_executions".to_string(),
875                    datasource: None,
876                },
877                Target {
878                    ref_id: "B".to_string(),
879                    expr: "test_blueprint_job_success".to_string(),
880                    datasource: None,
881                },
882                Target {
883                    ref_id: "C".to_string(),
884                    expr: "test_blueprint_job_latency_ms".to_string(),
885                    datasource: None,
886                },
887            ],
888            options: HashMap::new(),
889            field_config: FieldConfig::default(),
890        };
891
892        // Add panels to dashboard
893        dashboard.panels.push(system_metrics_panel);
894        dashboard.panels.push(job_metrics_panel);
895        dashboard.panels.push(logs_panel);
896        dashboard.panels.push(heartbeat_panel);
897        dashboard.panels.push(status_panel);
898        dashboard.panels.push(uptime_panel);
899        dashboard.panels.push(test_metrics_panel);
900
901        // Create folder if needed
902        let folder_id = if let Some(folder) = &self.config.folder {
903            match self.create_folder(folder, None).await {
904                Ok(id) => Some(id),
905                Err(e) => {
906                    error!("Failed to create folder: {}", e);
907                    None
908                }
909            }
910        } else {
911            None
912        };
913
914        // Create dashboard
915        self.create_dashboard(dashboard, folder_id, "Create Blueprint Dashboard")
916            .await
917    }
918
919    /// Performs a health check for a Grafana datasource.
920    ///
921    /// # Errors
922    /// Returns an error if the health check fails or the API request is unsuccessful.
923    pub async fn check_datasource_health(&self, uid: &str) -> Result<DatasourceHealthResponse> {
924        let url = format!(
925            "{}/api/datasources/uid/{}/health",
926            self.config.url.trim_end_matches('/'),
927            uid
928        );
929        debug!(
930            "Performing health check for datasource UID {} at URL: {}",
931            uid, url
932        );
933
934        let mut request_builder = self.client.get(&url);
935
936        if let Some(api_key) = &self.config.api_key {
937            if !api_key.is_empty() {
938                request_builder = request_builder.bearer_auth(api_key);
939            }
940        } else if let (Some(user), Some(pass)) =
941            (&self.config.admin_user, &self.config.admin_password)
942        {
943            if !user.is_empty() && !pass.is_empty() {
944                if pass == DEFAULT_ADMIN_PASSWORD {
945                    warn!(
946                        "Grafana basic authentication is using the default insecure password. Please change it."
947                    );
948                }
949                request_builder = request_builder.basic_auth(user, Some(pass.clone()));
950            }
951        }
952
953        let response = request_builder.send().await.map_err(|e| {
954            Error::GrafanaApi(format!(
955                "Failed to send health check request for UID {}: {}",
956                uid, e
957            ))
958        })?;
959
960        let status = response.status();
961        let response_text = response.text().await.map_err(|e| {
962            Error::GrafanaApi(format!(
963                "Failed to read health check response body for UID {}: {}",
964                uid, e
965            ))
966        })?;
967
968        if status.is_success() {
969            serde_json::from_str::<DatasourceHealthResponse>(&response_text).map_err(|e| {
970                Error::GrafanaApi(format!(
971                    "Failed to parse health check response for UID {}: {}. Body: {}",
972                    uid, e, response_text
973                ))
974            })
975        } else {
976            Err(Error::GrafanaApi(format!(
977                "Health check for UID {} failed with status {}. Body: {}",
978                uid, status, response_text
979            )))
980        }
981    }
982
983    /// # Errors
984    /// Returns an error if the Grafana API request fails or returns an error response
985    pub async fn get_datasource(&self, uid: &str) -> Result<Option<DataSourceDetails>> {
986        let url = format!(
987            "{}/api/datasources/uid/{}",
988            self.config.url.trim_end_matches('/'),
989            uid
990        );
991        debug!("Getting datasource UID {} at URL: {}", uid, url);
992
993        let mut request_builder = self.client.get(&url);
994
995        if let Some(api_key) = &self.config.api_key {
996            if !api_key.is_empty() {
997                request_builder = request_builder.bearer_auth(api_key);
998            }
999        } else if let (Some(user), Some(pass)) =
1000            (&self.config.admin_user, &self.config.admin_password)
1001        {
1002            if !user.is_empty() && !pass.is_empty() {
1003                if pass == DEFAULT_ADMIN_PASSWORD {
1004                    warn!(
1005                        "Grafana basic authentication is using the default insecure password. Please change it."
1006                    );
1007                }
1008                request_builder = request_builder.basic_auth(user, Some(pass.clone()));
1009            }
1010        }
1011
1012        let response = request_builder.send().await.map_err(|e| {
1013            Error::GrafanaApi(format!(
1014                "Failed to send get datasource request for UID {}: {}",
1015                uid, e
1016            ))
1017        })?;
1018
1019        let status = response.status();
1020        if status.is_success() {
1021            let ds = response.json::<DataSourceDetails>().await.map_err(|e| {
1022                Error::GrafanaApi(format!(
1023                    "Failed to parse datasource response for UID {}: {}",
1024                    uid, e
1025                ))
1026            })?;
1027            Ok(Some(ds))
1028        } else if status == reqwest::StatusCode::NOT_FOUND {
1029            Ok(None)
1030        } else {
1031            let error_body = response
1032                .text()
1033                .await
1034                .unwrap_or_else(|_| "Unknown error".to_string());
1035            Err(Error::GrafanaApi(format!(
1036                "Failed to get datasource UID {}. Status: {}. Body: {}",
1037                uid, status, error_body
1038            )))
1039        }
1040    }
1041
1042    /// # Errors
1043    ///
1044    /// Returns an error if the request to Grafana fails, if the response cannot be parsed,
1045    /// or if Grafana returns a non-success status code.
1046    pub async fn create_or_update_datasource(
1047        &self,
1048        payload: CreateDataSourceRequest,
1049    ) -> Result<CreateDataSourceResponse> {
1050        let url = format!("{}/api/datasources", self.config.url.trim_end_matches('/'));
1051        info!(
1052            "Attempting to create/update Grafana datasource: {} (UID: {:?}) at URL: {}",
1053            payload.name, payload.uid, payload.url
1054        );
1055
1056        let mut request_builder = self.client.post(&url);
1057        if let Some(api_key) = &self.config.api_key {
1058            if !api_key.is_empty() {
1059                request_builder = request_builder.bearer_auth(api_key);
1060            }
1061        } else if let (Some(user), Some(pass)) =
1062            (&self.config.admin_user, &self.config.admin_password)
1063        {
1064            if !user.is_empty() && !pass.is_empty() {
1065                if pass == DEFAULT_ADMIN_PASSWORD {
1066                    warn!(
1067                        "Grafana basic authentication is using the default insecure password. Please change it."
1068                    );
1069                }
1070                request_builder = request_builder.basic_auth(user, Some(pass.clone()));
1071            }
1072        }
1073
1074        let response = request_builder.json(&payload).send().await.map_err(|e| {
1075            Error::GrafanaApi(format!("Failed to send create datasource request: {}", e))
1076        })?;
1077
1078        if response.status().is_success() {
1079            let response_text = response.text().await.map_err(|e| {
1080                Error::GrafanaApi(format!(
1081                    "Failed to read success response body as text: {}",
1082                    e
1083                ))
1084            })?;
1085            info!(
1086                "Grafana datasource creation/update successful. Raw response body: {}",
1087                response_text
1088            );
1089
1090            let response_body: CreateDataSourceResponse = serde_json::from_str(&response_text)
1091                .map_err(|e| {
1092                    Error::GrafanaApi(format!(
1093                        "Failed to parse create datasource response from text ({}): {}",
1094                        response_text, e
1095                    ))
1096                })?;
1097            info!(
1098                "Successfully created/updated Grafana datasource: {} (ID: {}, UID: {})",
1099                response_body.name, response_body.id, response_body.datasource.uid
1100            );
1101            Ok(response_body)
1102        } else {
1103            let status = response.status();
1104            let error_body = response
1105                .text()
1106                .await
1107                .unwrap_or_else(|_| "Unknown error".to_string());
1108            error!(
1109                "Failed to create/update Grafana datasource. Status: {}. Body: {}",
1110                status, error_body
1111            );
1112            // Attempt to parse Grafana's specific error message format
1113            let grafana_error_message = serde_json::from_str::<GrafanaApiError>(&error_body)
1114                .map_or_else(|_| error_body.clone(), |e| e.message);
1115
1116            Err(Error::GrafanaApi(format!(
1117                "Grafana API error ({}) creating/updating datasource '{}': {}",
1118                status, payload.name, grafana_error_message
1119            )))
1120        }
1121    }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127    /// Tests that the `GrafanaConfig` default implementation returns a valid configuration.
1128    ///
1129    /// ```
1130    /// GrafanaConfig::default() -> Valid config
1131    /// ```
1132    ///
1133    /// Expected outcome: Default config has reasonable values
1134    #[test]
1135    fn test_grafana_config_default() {
1136        let config = GrafanaConfig::default();
1137        assert_eq!(config.url, "http://localhost:3000");
1138        assert_eq!(config.api_key, None);
1139        assert_eq!(config.org_id, None);
1140        assert_eq!(config.folder, None);
1141        assert_eq!(
1142            config.prometheus_datasource_url.unwrap(),
1143            "http://localhost:9090"
1144        );
1145    }
1146
1147    /// Tests that a new `GrafanaClient` can be created with a valid configuration.
1148    ///
1149    /// ```
1150    /// GrafanaConfig -> GrafanaClient
1151    /// ```
1152    ///
1153    /// Expected outcome: `GrafanaClient` is created with the provided config
1154    #[test]
1155    fn test_grafana_client_creation() {
1156        let config = GrafanaConfig {
1157            url: "http://localhost:3000".to_string(),
1158            api_key: Some("test_api_key".to_string()),
1159            admin_user: Some("admin".to_string()),
1160            admin_password: Some("password".to_string()),
1161            org_id: Some(1),
1162            folder: None,
1163            loki_config: None,
1164            prometheus_datasource_url: Some("http://localhost:9090".to_string()),
1165        };
1166
1167        let _client = GrafanaClient::new(config.clone());
1168    }
1169}