feat: monitoring clients — Prometheus, Loki, Grafana (57 endpoints)
PrometheusClient (18 endpoints): query, metadata, targets, status. LokiClient (11 endpoints): query, labels, series, push, index. GrafanaClient (29 endpoints): dashboards, datasources, folders, annotations, alerts, org. Bump: sunbeam-sdk v0.10.0
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3591,7 +3591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sunbeam-sdk"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sunbeam-sdk"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
description = "Sunbeam SDK — reusable library for cluster management"
|
||||
repository = "https://src.sunbeam.pt/studio/cli"
|
||||
|
||||
425
sunbeam-sdk/src/monitoring/grafana.rs
Normal file
425
sunbeam-sdk/src/monitoring/grafana.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! Grafana API client.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::{self, *};
|
||||
|
||||
/// Client for the Grafana HTTP API (`/api`).
|
||||
pub struct GrafanaClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for GrafanaClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"grafana"
|
||||
}
|
||||
|
||||
fn base_url(&self) -> &str {
|
||||
&self.transport.base_url
|
||||
}
|
||||
|
||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
||||
Self {
|
||||
transport: HttpTransport::new(&base_url, auth),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GrafanaClient {
|
||||
/// Build a GrafanaClient from domain (e.g. `https://grafana.{domain}/api`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://grafana.{domain}/api");
|
||||
Self::from_parts(base_url, AuthMethod::None)
|
||||
}
|
||||
|
||||
// -- Dashboards ---------------------------------------------------------
|
||||
|
||||
/// Create a new dashboard.
|
||||
pub async fn create_dashboard(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<DashboardResponse> {
|
||||
self.transport
|
||||
.json(Method::POST, "dashboards/db", Some(body), "grafana create dashboard")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a dashboard by UID.
|
||||
pub async fn get_dashboard(&self, uid: &str) -> Result<DashboardResponse> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("dashboards/uid/{uid}"),
|
||||
Option::<&()>::None,
|
||||
"grafana get dashboard",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update an existing dashboard (same endpoint as create).
|
||||
pub async fn update_dashboard(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<DashboardResponse> {
|
||||
self.transport
|
||||
.json(Method::POST, "dashboards/db", Some(body), "grafana update dashboard")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a dashboard by UID.
|
||||
pub async fn delete_dashboard(&self, uid: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("dashboards/uid/{uid}"),
|
||||
Option::<&()>::None,
|
||||
"grafana delete dashboard",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// List all dashboards.
|
||||
pub async fn list_dashboards(&self) -> Result<Vec<DashboardSearchResult>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
"search?type=dash-db",
|
||||
Option::<&()>::None,
|
||||
"grafana list dashboards",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Search dashboards by query.
|
||||
pub async fn search_dashboards(
|
||||
&self,
|
||||
query: &str,
|
||||
) -> Result<Vec<DashboardSearchResult>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("search?query={}", types::urlencode(query)),
|
||||
Option::<&()>::None,
|
||||
"grafana search dashboards",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Datasources --------------------------------------------------------
|
||||
|
||||
/// List all datasources.
|
||||
pub async fn list_datasources(&self) -> Result<Vec<Datasource>> {
|
||||
self.transport
|
||||
.json(Method::GET, "datasources", Option::<&()>::None, "grafana list datasources")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a datasource by numeric ID.
|
||||
pub async fn get_datasource(&self, id: u64) -> Result<Datasource> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("datasources/{id}"),
|
||||
Option::<&()>::None,
|
||||
"grafana get datasource",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a datasource by UID.
|
||||
pub async fn get_datasource_by_uid(&self, uid: &str) -> Result<Datasource> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("datasources/uid/{uid}"),
|
||||
Option::<&()>::None,
|
||||
"grafana get datasource by uid",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new datasource.
|
||||
pub async fn create_datasource(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Datasource> {
|
||||
// Grafana wraps create response in {"datasource": {...}}
|
||||
let resp: serde_json::Value = self
|
||||
.transport
|
||||
.json(Method::POST, "datasources", Some(body), "grafana create datasource")
|
||||
.await?;
|
||||
if let Some(inner) = resp.get("datasource") {
|
||||
Ok(serde_json::from_value(inner.clone())?)
|
||||
} else {
|
||||
Ok(serde_json::from_value(resp)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing datasource by numeric ID.
|
||||
pub async fn update_datasource(
|
||||
&self,
|
||||
id: u64,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Datasource> {
|
||||
// Grafana wraps update response in {"datasource": {...}}
|
||||
let resp: serde_json::Value = self
|
||||
.transport
|
||||
.json(
|
||||
Method::PUT,
|
||||
&format!("datasources/{id}"),
|
||||
Some(body),
|
||||
"grafana update datasource",
|
||||
)
|
||||
.await?;
|
||||
if let Some(inner) = resp.get("datasource") {
|
||||
Ok(serde_json::from_value(inner.clone())?)
|
||||
} else {
|
||||
Ok(serde_json::from_value(resp)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a datasource by numeric ID.
|
||||
pub async fn delete_datasource(&self, id: u64) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("datasources/{id}"),
|
||||
Option::<&()>::None,
|
||||
"grafana delete datasource",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Proxy a request to a datasource.
|
||||
pub async fn proxy_datasource(
|
||||
&self,
|
||||
id: u64,
|
||||
path: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("datasources/proxy/{id}/{}", path.trim_start_matches('/')),
|
||||
Option::<&()>::None,
|
||||
"grafana proxy datasource",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Folders ------------------------------------------------------------
|
||||
|
||||
/// List all folders.
|
||||
pub async fn list_folders(&self) -> Result<Vec<Folder>> {
|
||||
self.transport
|
||||
.json(Method::GET, "folders", Option::<&()>::None, "grafana list folders")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a folder.
|
||||
pub async fn create_folder(&self, body: &serde_json::Value) -> Result<Folder> {
|
||||
self.transport
|
||||
.json(Method::POST, "folders", Some(body), "grafana create folder")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a folder by UID.
|
||||
pub async fn get_folder(&self, uid: &str) -> Result<Folder> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("folders/{uid}"),
|
||||
Option::<&()>::None,
|
||||
"grafana get folder",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a folder by UID.
|
||||
pub async fn update_folder(
|
||||
&self,
|
||||
uid: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Folder> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::PUT,
|
||||
&format!("folders/{uid}"),
|
||||
Some(body),
|
||||
"grafana update folder",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a folder by UID.
|
||||
pub async fn delete_folder(&self, uid: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("folders/{uid}"),
|
||||
Option::<&()>::None,
|
||||
"grafana delete folder",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Annotations --------------------------------------------------------
|
||||
|
||||
/// List annotations with optional filter params.
|
||||
pub async fn list_annotations(
|
||||
&self,
|
||||
params: Option<&str>,
|
||||
) -> Result<Vec<Annotation>> {
|
||||
let path = match params {
|
||||
Some(p) => format!("annotations?{p}"),
|
||||
None => "annotations".to_string(),
|
||||
};
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "grafana list annotations")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create an annotation.
|
||||
pub async fn create_annotation(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<AnnotationResponse> {
|
||||
self.transport
|
||||
.json(Method::POST, "annotations", Some(body), "grafana create annotation")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get an annotation by ID.
|
||||
pub async fn get_annotation(&self, id: u64) -> Result<Annotation> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
&format!("annotations/{id}"),
|
||||
Option::<&()>::None,
|
||||
"grafana get annotation",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update an annotation by ID.
|
||||
pub async fn update_annotation(
|
||||
&self,
|
||||
id: u64,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<AnnotationResponse> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::PUT,
|
||||
&format!("annotations/{id}"),
|
||||
Some(body),
|
||||
"grafana update annotation",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete an annotation by ID.
|
||||
pub async fn delete_annotation(&self, id: u64) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("annotations/{id}"),
|
||||
Option::<&()>::None,
|
||||
"grafana delete annotation",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Alerts -------------------------------------------------------------
|
||||
|
||||
/// Get all provisioned alert rules.
|
||||
pub async fn get_alert_rules(&self) -> Result<Vec<AlertRule>> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::GET,
|
||||
"v1/provisioning/alert-rules",
|
||||
Option::<&()>::None,
|
||||
"grafana get alert rules",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a provisioned alert rule.
|
||||
pub async fn create_alert_rule(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<AlertRule> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::POST,
|
||||
"v1/provisioning/alert-rules",
|
||||
Some(body),
|
||||
"grafana create alert rule",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a provisioned alert rule by UID.
|
||||
pub async fn update_alert_rule(
|
||||
&self,
|
||||
uid: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<AlertRule> {
|
||||
self.transport
|
||||
.json(
|
||||
Method::PUT,
|
||||
&format!("v1/provisioning/alert-rules/{uid}"),
|
||||
Some(body),
|
||||
"grafana update alert rule",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a provisioned alert rule by UID.
|
||||
pub async fn delete_alert_rule(&self, uid: &str) -> Result<()> {
|
||||
self.transport
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("v1/provisioning/alert-rules/{uid}"),
|
||||
Option::<&()>::None,
|
||||
"grafana delete alert rule",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Org ----------------------------------------------------------------
|
||||
|
||||
/// Get the current organization.
|
||||
pub async fn get_current_org(&self) -> Result<Organization> {
|
||||
self.transport
|
||||
.json(Method::GET, "org", Option::<&()>::None, "grafana get current org")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update the current organization.
|
||||
pub async fn update_org(&self, body: &serde_json::Value) -> Result<()> {
|
||||
self.transport
|
||||
.send(Method::PUT, "org", Some(body), "grafana update org")
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = GrafanaClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://grafana.sunbeam.pt/api");
|
||||
assert_eq!(c.service_name(), "grafana");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = GrafanaClient::from_parts(
|
||||
"http://localhost:3000/api".into(),
|
||||
AuthMethod::None,
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:3000/api");
|
||||
}
|
||||
}
|
||||
269
sunbeam-sdk/src/monitoring/loki.rs
Normal file
269
sunbeam-sdk/src/monitoring/loki.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Loki API client.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::{self, *};
|
||||
|
||||
/// Client for the Loki HTTP API (`/loki/api/v1`).
|
||||
pub struct LokiClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for LokiClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"loki"
|
||||
}
|
||||
|
||||
fn base_url(&self) -> &str {
|
||||
&self.transport.base_url
|
||||
}
|
||||
|
||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
||||
Self {
|
||||
transport: HttpTransport::new(&base_url, auth),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LokiClient {
|
||||
/// Build a LokiClient from domain (e.g. `https://loki.{domain}/loki/api/v1`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://loki.{domain}/loki/api/v1");
|
||||
Self::from_parts(base_url, AuthMethod::None)
|
||||
}
|
||||
|
||||
// -- Query --------------------------------------------------------------
|
||||
|
||||
/// Execute an instant query.
|
||||
pub async fn query(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
time: Option<&str>,
|
||||
) -> Result<QueryResult> {
|
||||
let mut path = format!("query?query={}", types::urlencode(query));
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={l}"));
|
||||
}
|
||||
if let Some(t) = time {
|
||||
path.push_str(&format!("&time={t}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki query")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute a range query.
|
||||
pub async fn query_range(
|
||||
&self,
|
||||
query: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
limit: Option<u32>,
|
||||
step: Option<&str>,
|
||||
) -> Result<QueryResult> {
|
||||
let mut path = format!(
|
||||
"query_range?query={}&start={start}&end={end}",
|
||||
types::urlencode(query),
|
||||
);
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={l}"));
|
||||
}
|
||||
if let Some(s) = step {
|
||||
path.push_str(&format!("&step={s}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki query_range")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all label names.
|
||||
pub async fn labels(
|
||||
&self,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<String>>> {
|
||||
let mut path = String::from("labels");
|
||||
let mut sep = '?';
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("{sep}start={s}"));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("{sep}end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki labels")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get values for a specific label.
|
||||
pub async fn label_values(
|
||||
&self,
|
||||
label: &str,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<String>>> {
|
||||
let mut path = format!("label/{label}/values");
|
||||
let mut sep = '?';
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("{sep}start={s}"));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("{sep}end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki label values")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Find series matching label matchers.
|
||||
pub async fn series(
|
||||
&self,
|
||||
match_params: &[&str],
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<serde_json::Value>>> {
|
||||
let mut path = String::from("series?");
|
||||
for (i, m) in match_params.iter().enumerate() {
|
||||
if i > 0 {
|
||||
path.push('&');
|
||||
}
|
||||
path.push_str(&format!("match[]={}", types::urlencode(m)));
|
||||
}
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("&start={s}"));
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("&end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki series")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Index --------------------------------------------------------------
|
||||
|
||||
/// Get index statistics.
|
||||
pub async fn index_stats(&self) -> Result<serde_json::Value> {
|
||||
self.transport
|
||||
.json(Method::GET, "index/stats", Option::<&()>::None, "loki index stats")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get index volume for a query.
|
||||
pub async fn index_volume(
|
||||
&self,
|
||||
query: &str,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut path = format!("index/volume?query={}", types::urlencode(query));
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("&start={s}"));
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("&end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki index volume")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get index volume range for a query.
|
||||
pub async fn index_volume_range(
|
||||
&self,
|
||||
query: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
step: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut path = format!(
|
||||
"index/volume_range?query={}&start={start}&end={end}",
|
||||
types::urlencode(query),
|
||||
);
|
||||
if let Some(s) = step {
|
||||
path.push_str(&format!("&step={s}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki index volume_range")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Patterns -----------------------------------------------------------
|
||||
|
||||
/// Detect log patterns.
|
||||
pub async fn detect_patterns(
|
||||
&self,
|
||||
query: &str,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut path = format!("patterns?query={}", types::urlencode(query));
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("&start={s}"));
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("&end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "loki detect patterns")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Ingest -------------------------------------------------------------
|
||||
|
||||
/// Push log entries.
|
||||
pub async fn push(&self, body: &serde_json::Value) -> Result<()> {
|
||||
self.transport
|
||||
.send(Method::POST, "push", Some(body), "loki push")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Status -------------------------------------------------------------
|
||||
|
||||
/// Check readiness. Note: Loki's `/ready` is at the server root,
|
||||
/// not under the API prefix.
|
||||
pub async fn ready(&self) -> Result<ReadyStatus> {
|
||||
// Build URL from base by stripping the API path suffix
|
||||
let base = self.transport.base_url.trim_end_matches("/loki/api/v1");
|
||||
let url = format!("{base}/ready");
|
||||
let resp = self
|
||||
.transport
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| crate::error::SunbeamError::network(format!("loki ready: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(crate::error::SunbeamError::network(format!("loki ready: {body}")));
|
||||
}
|
||||
Ok(ReadyStatus {
|
||||
status: Some("ready".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = LokiClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://loki.sunbeam.pt/loki/api/v1");
|
||||
assert_eq!(c.service_name(), "loki");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = LokiClient::from_parts(
|
||||
"http://localhost:3100/loki/api/v1".into(),
|
||||
AuthMethod::None,
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:3100/loki/api/v1");
|
||||
}
|
||||
}
|
||||
10
sunbeam-sdk/src/monitoring/mod.rs
Normal file
10
sunbeam-sdk/src/monitoring/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Monitoring service clients: Prometheus, Loki, and Grafana.
|
||||
|
||||
pub mod grafana;
|
||||
pub mod loki;
|
||||
pub mod prometheus;
|
||||
pub mod types;
|
||||
|
||||
pub use grafana::GrafanaClient;
|
||||
pub use loki::LokiClient;
|
||||
pub use prometheus::PrometheusClient;
|
||||
268
sunbeam-sdk/src/monitoring/prometheus.rs
Normal file
268
sunbeam-sdk/src/monitoring/prometheus.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! Prometheus API client.
|
||||
|
||||
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
|
||||
use crate::error::Result;
|
||||
use reqwest::Method;
|
||||
use super::types::{self, *};
|
||||
|
||||
/// Client for the Prometheus HTTP API (`/api/v1`).
|
||||
pub struct PrometheusClient {
|
||||
pub(crate) transport: HttpTransport,
|
||||
}
|
||||
|
||||
impl ServiceClient for PrometheusClient {
|
||||
fn service_name(&self) -> &'static str {
|
||||
"prometheus"
|
||||
}
|
||||
|
||||
fn base_url(&self) -> &str {
|
||||
&self.transport.base_url
|
||||
}
|
||||
|
||||
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
|
||||
Self {
|
||||
transport: HttpTransport::new(&base_url, auth),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrometheusClient {
|
||||
/// Build a PrometheusClient from domain (e.g. `https://prometheus.{domain}/api/v1`).
|
||||
pub fn connect(domain: &str) -> Self {
|
||||
let base_url = format!("https://prometheus.{domain}/api/v1");
|
||||
Self::from_parts(base_url, AuthMethod::None)
|
||||
}
|
||||
|
||||
// -- Query --------------------------------------------------------------
|
||||
|
||||
/// Execute an instant query.
|
||||
pub async fn query(
|
||||
&self,
|
||||
query: &str,
|
||||
time: Option<&str>,
|
||||
) -> Result<QueryResult> {
|
||||
let mut path = format!("query?query={}", types::urlencode(query));
|
||||
if let Some(t) = time {
|
||||
path.push_str(&format!("&time={t}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus query")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute a range query.
|
||||
pub async fn query_range(
|
||||
&self,
|
||||
query: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
step: &str,
|
||||
) -> Result<QueryResult> {
|
||||
let path = format!(
|
||||
"query_range?query={}&start={start}&end={end}&step={step}",
|
||||
types::urlencode(query),
|
||||
);
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus query_range")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Format a PromQL expression.
|
||||
pub async fn format_query(&self, query: &str) -> Result<FormattedQuery> {
|
||||
let path = format!("format_query?query={}", types::urlencode(query));
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus format_query")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Metadata -----------------------------------------------------------
|
||||
|
||||
/// Find series matching label matchers.
|
||||
pub async fn series(
|
||||
&self,
|
||||
match_params: &[&str],
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<serde_json::Value>>> {
|
||||
let mut path = String::from("series?");
|
||||
for (i, m) in match_params.iter().enumerate() {
|
||||
if i > 0 {
|
||||
path.push('&');
|
||||
}
|
||||
path.push_str(&format!("match[]={}", types::urlencode(m)));
|
||||
}
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("&start={s}"));
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("&end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus series")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all label names.
|
||||
pub async fn labels(
|
||||
&self,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<String>>> {
|
||||
let mut path = String::from("labels");
|
||||
let mut sep = '?';
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("{sep}start={s}"));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("{sep}end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus labels")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get values for a specific label.
|
||||
pub async fn label_values(
|
||||
&self,
|
||||
label: &str,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<String>>> {
|
||||
let mut path = format!("label/{label}/values");
|
||||
let mut sep = '?';
|
||||
if let Some(s) = start {
|
||||
path.push_str(&format!("{sep}start={s}"));
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(e) = end {
|
||||
path.push_str(&format!("{sep}end={e}"));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus label values")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get metadata about metrics scraped by targets.
|
||||
pub async fn targets_metadata(
|
||||
&self,
|
||||
metric: Option<&str>,
|
||||
) -> Result<ApiResponse<Vec<serde_json::Value>>> {
|
||||
let mut path = String::from("targets/metadata");
|
||||
if let Some(m) = metric {
|
||||
path.push_str(&format!("?metric={}", types::urlencode(m)));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus targets metadata")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get per-metric metadata.
|
||||
pub async fn metadata(
|
||||
&self,
|
||||
metric: Option<&str>,
|
||||
) -> Result<ApiResponse<serde_json::Value>> {
|
||||
let mut path = String::from("metadata");
|
||||
if let Some(m) = metric {
|
||||
path.push_str(&format!("?metric={}", types::urlencode(m)));
|
||||
}
|
||||
self.transport
|
||||
.json(Method::GET, &path, Option::<&()>::None, "prometheus metadata")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Infrastructure -----------------------------------------------------
|
||||
|
||||
/// Get current target discovery status.
|
||||
pub async fn targets(&self) -> Result<TargetsResult> {
|
||||
self.transport
|
||||
.json(Method::GET, "targets", Option::<&()>::None, "prometheus targets")
|
||||
.await
|
||||
}
|
||||
|
||||
/// List scrape pools.
|
||||
pub async fn scrape_pools(&self) -> Result<ApiResponse<serde_json::Value>> {
|
||||
self.transport
|
||||
.json(Method::GET, "scrape_pools", Option::<&()>::None, "prometheus scrape_pools")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get discovered Alertmanager instances.
|
||||
pub async fn alertmanagers(&self) -> Result<ApiResponse<serde_json::Value>> {
|
||||
self.transport
|
||||
.json(Method::GET, "alertmanagers", Option::<&()>::None, "prometheus alertmanagers")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get alerting and recording rules.
|
||||
pub async fn rules(&self) -> Result<RulesResult> {
|
||||
self.transport
|
||||
.json(Method::GET, "rules", Option::<&()>::None, "prometheus rules")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get active alerts.
|
||||
pub async fn alerts(&self) -> Result<AlertsResult> {
|
||||
self.transport
|
||||
.json(Method::GET, "alerts", Option::<&()>::None, "prometheus alerts")
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Status -------------------------------------------------------------
|
||||
|
||||
/// Get Prometheus configuration.
|
||||
pub async fn config(&self) -> Result<ConfigResult> {
|
||||
self.transport
|
||||
.json(Method::GET, "status/config", Option::<&()>::None, "prometheus config")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get command-line flags.
|
||||
pub async fn flags(&self) -> Result<ApiResponse<serde_json::Value>> {
|
||||
self.transport
|
||||
.json(Method::GET, "status/flags", Option::<&()>::None, "prometheus flags")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get runtime information.
|
||||
pub async fn runtime_info(&self) -> Result<ApiResponse<serde_json::Value>> {
|
||||
self.transport
|
||||
.json(Method::GET, "status/runtimeinfo", Option::<&()>::None, "prometheus runtimeinfo")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get build information.
|
||||
pub async fn build_info(&self) -> Result<ApiResponse<serde_json::Value>> {
|
||||
self.transport
|
||||
.json(Method::GET, "status/buildinfo", Option::<&()>::None, "prometheus buildinfo")
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get TSDB statistics.
|
||||
pub async fn tsdb(&self) -> Result<ApiResponse<serde_json::Value>> {
|
||||
self.transport
|
||||
.json(Method::GET, "status/tsdb", Option::<&()>::None, "prometheus tsdb")
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_url() {
|
||||
let c = PrometheusClient::connect("sunbeam.pt");
|
||||
assert_eq!(c.base_url(), "https://prometheus.sunbeam.pt/api/v1");
|
||||
assert_eq!(c.service_name(), "prometheus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_parts() {
|
||||
let c = PrometheusClient::from_parts(
|
||||
"http://localhost:9090/api/v1".into(),
|
||||
AuthMethod::None,
|
||||
);
|
||||
assert_eq!(c.base_url(), "http://localhost:9090/api/v1");
|
||||
}
|
||||
}
|
||||
361
sunbeam-sdk/src/monitoring/types.rs
Normal file
361
sunbeam-sdk/src/monitoring/types.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
//! Shared types for monitoring clients (Prometheus, Loki, Grafana).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prometheus / Loki shared response wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Standard Prometheus/Loki API response envelope.
|
||||
///
|
||||
/// Both APIs return `{"status":"success","data":{...}}` on success.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub status: String,
|
||||
pub data: Option<T>,
|
||||
#[serde(default, rename = "errorType")]
|
||||
pub error_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub warnings: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prometheus types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of an instant or range query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryData {
|
||||
pub result_type: String,
|
||||
pub result: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Alias: Prometheus/Loki query result is an `ApiResponse<QueryData>`.
|
||||
pub type QueryResult = ApiResponse<QueryData>;
|
||||
|
||||
/// Formatted PromQL query.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FormattedQuery {
|
||||
pub status: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Prometheus targets response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetsData {
|
||||
pub active_targets: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub dropped_targets: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub type TargetsResult = ApiResponse<TargetsData>;
|
||||
|
||||
/// Prometheus rules response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RulesData {
|
||||
pub groups: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub type RulesResult = ApiResponse<RulesData>;
|
||||
|
||||
/// Prometheus alerts response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlertsData {
|
||||
pub alerts: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub type AlertsResult = ApiResponse<AlertsData>;
|
||||
|
||||
/// Prometheus config response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigData {
|
||||
pub yaml: String,
|
||||
}
|
||||
|
||||
pub type ConfigResult = ApiResponse<ConfigData>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loki types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Loki readiness status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadyStatus {
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grafana types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Grafana dashboard create/update response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DashboardResponse {
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub version: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
/// Present on GET responses.
|
||||
#[serde(default)]
|
||||
pub dashboard: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Grafana dashboard search result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DashboardSearchResult {
|
||||
pub id: u64,
|
||||
pub uid: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_starred: Option<bool>,
|
||||
#[serde(default, rename = "folderUid")]
|
||||
pub folder_uid: Option<String>,
|
||||
}
|
||||
|
||||
/// Grafana datasource.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Datasource {
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uid: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_default: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub json_data: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub database: Option<String>,
|
||||
}
|
||||
|
||||
/// Grafana folder.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Folder {
|
||||
pub id: u64,
|
||||
pub uid: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated: Option<String>,
|
||||
}
|
||||
|
||||
/// Grafana annotation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub dashboard_id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub panel_id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub time: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub time_end: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Grafana annotation create/update response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnnotationResponse {
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Grafana alert rule.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlertRule {
|
||||
#[serde(default)]
|
||||
pub uid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default, rename = "folderUID")]
|
||||
pub folder_uid: Option<String>,
|
||||
#[serde(default, rename = "ruleGroup")]
|
||||
pub rule_group: Option<String>,
|
||||
#[serde(default, rename = "for")]
|
||||
pub for_duration: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub annotations: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Grafana organization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Organization {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub address: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimal percent-encoding for URL query parameters.
|
||||
pub(super) fn urlencode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_api_response_deserialize_success() {
|
||||
let json = r#"{"status":"success","data":{"resultType":"vector","result":[]}}"#;
|
||||
let resp: ApiResponse<QueryData> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.status, "success");
|
||||
assert_eq!(resp.data.unwrap().result_type, "vector");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_response_deserialize_with_warnings() {
|
||||
let json = r#"{
|
||||
"status": "success",
|
||||
"data": {"resultType":"matrix","result":[]},
|
||||
"warnings": ["some warning"]
|
||||
}"#;
|
||||
let resp: QueryResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.warnings.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_response_deserialize_error() {
|
||||
let json = r#"{
|
||||
"status": "error",
|
||||
"errorType": "bad_data",
|
||||
"error": "invalid query",
|
||||
"data": {"resultType":"","result":[]}
|
||||
}"#;
|
||||
let resp: QueryResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.status, "error");
|
||||
assert_eq!(resp.error_type.unwrap(), "bad_data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_search_result_roundtrip() {
|
||||
let item = DashboardSearchResult {
|
||||
id: 1,
|
||||
uid: "abc123".into(),
|
||||
title: "My Dashboard".into(),
|
||||
url: Some("/d/abc123".into()),
|
||||
kind: Some("dash-db".into()),
|
||||
uri: None,
|
||||
tags: vec!["prod".into()],
|
||||
is_starred: Some(false),
|
||||
folder_uid: None,
|
||||
};
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
let back: DashboardSearchResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.uid, "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_datasource_roundtrip() {
|
||||
let ds = Datasource {
|
||||
id: Some(1),
|
||||
uid: Some("prom".into()),
|
||||
name: "Prometheus".into(),
|
||||
kind: "prometheus".into(),
|
||||
url: Some("http://prometheus:9090".into()),
|
||||
access: Some("proxy".into()),
|
||||
is_default: Some(true),
|
||||
json_data: None,
|
||||
database: None,
|
||||
};
|
||||
let json = serde_json::to_string(&ds).unwrap();
|
||||
let back: Datasource = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "Prometheus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ready_status_minimal() {
|
||||
let json = r#"{}"#;
|
||||
let rs: ReadyStatus = serde_json::from_str(json).unwrap();
|
||||
assert!(rs.status.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_result_deserialize() {
|
||||
let json = r#"{"status":"success","data":{"yaml":"global:\n scrape_interval: 15s"}}"#;
|
||||
let resp: ConfigResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.data.unwrap().yaml.contains("scrape_interval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alert_rule_roundtrip() {
|
||||
let rule = AlertRule {
|
||||
uid: Some("rule-1".into()),
|
||||
title: Some("High CPU".into()),
|
||||
condition: Some("A".into()),
|
||||
data: None,
|
||||
folder_uid: Some("folder-1".into()),
|
||||
rule_group: Some("group-1".into()),
|
||||
for_duration: Some("5m".into()),
|
||||
labels: None,
|
||||
annotations: None,
|
||||
};
|
||||
let json = serde_json::to_string(&rule).unwrap();
|
||||
let back: AlertRule = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.uid.unwrap(), "rule-1");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user