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#[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#[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#[derive(Clone, Debug)]
64pub struct GrafanaConfig {
65 pub url: String,
67
68 pub api_key: Option<String>,
70
71 pub admin_user: Option<String>,
73
74 pub admin_password: Option<String>,
76
77 pub org_id: Option<u64>,
79
80 pub folder: Option<String>,
82
83 pub loki_config: Option<LokiConfig>,
85
86 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
106pub struct GrafanaClient {
113 client: Client,
115
116 config: GrafanaConfig,
118}
119
120#[derive(Serialize, Deserialize, Clone, Debug)]
122pub struct Dashboard {
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub id: Option<u64>,
126
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub uid: Option<String>,
130
131 pub title: String,
133
134 #[serde(default)]
136 pub tags: Vec<String>,
137
138 #[serde(default = "default_timezone")]
140 pub timezone: String,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub refresh: Option<String>,
145
146 #[serde(default = "default_schema_version")]
148 pub schema_version: u64,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub version: Option<u64>,
153
154 #[serde(default)]
156 pub panels: Vec<Panel>,
157}
158
159fn default_timezone() -> String {
161 "browser".to_string()
162}
163
164fn default_schema_version() -> u64 {
166 36
167}
168
169#[derive(Serialize, Deserialize, Clone, Debug)]
171pub struct Panel {
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub id: Option<u64>,
175
176 pub title: String,
178
179 #[serde(rename = "type")]
181 pub panel_type: String,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub datasource: Option<DataSource>,
186
187 pub grid_pos: GridPos,
189
190 #[serde(default)]
192 pub targets: Vec<Target>,
193
194 #[serde(default)]
196 pub options: HashMap<String, serde_json::Value>,
197
198 #[serde(default)]
200 pub field_config: FieldConfig,
201}
202
203#[derive(Serialize, Deserialize, Clone, Debug)]
205pub struct DataSource {
206 #[serde(rename = "type")]
208 pub ds_type: String,
209
210 pub uid: String,
212}
213
214#[derive(Serialize, Deserialize, Clone, Debug)]
216pub struct GridPos {
217 pub x: u64,
219
220 pub y: u64,
222
223 pub w: u64,
225
226 pub h: u64,
228}
229
230#[derive(Serialize, Deserialize, Clone, Debug)]
232pub struct Target {
233 pub ref_id: String,
235
236 pub expr: String,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub datasource: Option<DataSource>,
242}
243
244#[derive(Serialize, Deserialize, Clone, Debug, Default)]
246pub struct FieldConfig {
247 #[serde(default)]
249 pub defaults: FieldDefaults,
250
251 #[serde(default)]
253 pub overrides: Vec<FieldOverride>,
254}
255
256#[derive(Serialize, Deserialize, Clone, Debug, Default)]
258pub struct FieldDefaults {
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub unit: Option<String>,
262
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub decimals: Option<u64>,
266
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub min: Option<f64>,
270
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub max: Option<f64>,
274
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub thresholds: Option<Thresholds>,
278
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub color: Option<HashMap<String, serde_json::Value>>,
282}
283
284#[derive(Serialize, Deserialize, Clone, Debug)]
286pub struct FieldOverride {
287 pub matcher: Matcher,
289
290 pub properties: Vec<Property>,
292}
293
294#[derive(Serialize, Deserialize, Clone, Debug)]
296pub struct Matcher {
297 pub id: String,
299
300 pub options: serde_json::Value,
302}
303
304#[derive(Serialize, Deserialize, Clone, Debug)]
306pub struct Property {
307 pub id: String,
309
310 pub value: serde_json::Value,
312}
313
314#[derive(Serialize, Deserialize, Clone, Debug)]
316pub struct Thresholds {
317 pub mode: String,
319
320 pub steps: Vec<ThresholdStep>,
322}
323
324#[derive(Serialize, Deserialize, Clone, Debug)]
326pub struct ThresholdStep {
327 pub color: String,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub value: Option<f64>,
333}
334
335#[derive(Serialize, Deserialize, Clone, Debug)]
337struct DashboardCreateRequest {
338 dashboard: Dashboard,
340
341 #[serde(skip_serializing_if = "Option::is_none")]
343 folder_id: Option<u64>,
344
345 #[serde(skip_serializing_if = "Option::is_none")]
347 folder_uid: Option<String>,
348
349 message: String,
351
352 overwrite: bool,
354}
355
356#[derive(Deserialize, Debug)]
358#[allow(dead_code)]
359struct DashboardCreateResponse {
360 id: u64,
362
363 url: String,
365
366 status: String,
368
369 version: u64,
371}
372
373#[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 #[must_use]
431 pub fn prometheus_datasource_url(&self) -> Option<&String> {
432 self.config.prometheus_datasource_url.as_ref()
433 }
434
435 #[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 #[must_use]
465 pub fn config(&self) -> &GrafanaConfig {
466 &self.config
467 }
468
469 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 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 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 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 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 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 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 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 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 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 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 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 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 self.create_dashboard(dashboard, folder_id, "Create Blueprint Dashboard")
916 .await
917 }
918
919 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 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 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 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 #[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 #[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}