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:
2026-03-21 20:30:24 +00:00
parent 21f9e18610
commit 915f0b254d
7 changed files with 1335 additions and 2 deletions

2
Cargo.lock generated
View File

@@ -3591,7 +3591,7 @@ dependencies = [
[[package]] [[package]]
name = "sunbeam-sdk" name = "sunbeam-sdk"
version = "0.8.0" version = "0.9.0"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "sunbeam-sdk" name = "sunbeam-sdk"
version = "0.9.0" version = "0.10.0"
edition = "2024" edition = "2024"
description = "Sunbeam SDK — reusable library for cluster management" description = "Sunbeam SDK — reusable library for cluster management"
repository = "https://src.sunbeam.pt/studio/cli" repository = "https://src.sunbeam.pt/studio/cli"

View 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");
}
}

View 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");
}
}

View 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;

View 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");
}
}

View 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");
}
}